quoxcli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
package/lib/quoxon/app.js CHANGED
@@ -6,65 +6,132 @@
6
6
  import React from 'react';
7
7
  import { Box, Static, Text, measureElement, useApp, useStdin, useStdout } from 'ink';
8
8
  import { spawnSync } from 'node:child_process';
9
- import { mkdtempSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { mkdtempSync, writeFileSync, writeSync, readFileSync, unlinkSync } from 'node:fs';
10
11
  import { tmpdir, hostname as osHostname } from 'node:os';
11
12
  import { join } from 'node:path';
12
- import { bufferReducer, initialBuffer, sanitizeForSend } from './textBuffer.js';
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';
13
15
  import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
14
- import { reduce, initialState, approvalKeyAllowed } from './events.js';
16
+ import { isHeldFromBulk, heldReason } from './bulkRisk.js';
17
+ import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, parseAnswer, parsePairs } from './approvalsView.js';
18
+ import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
19
+ import { reduce, initialState, approvalKeyAllowed, groupApprovals, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
15
20
  import { turnTokens } from './indicator.js';
16
21
  import {
17
- Composer, RingComposer, Pending, ApprovalCard, StatusLine, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
18
- } from './render.js';
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';
19
24
  import { c, mono } from './theme.js';
20
- import { findEntry, helpModel } from './commands.js';
25
+ import { strayCardAnswer, strayQuestion, alreadyQueued } from './stray.js';
26
+ import { findEntry, helpModel, nearestCommands, joinWrappedSlash, commandNames, REGISTRY, visibleRegistry } from './commands.js';
21
27
  import { osc52, pickCopy } from './copy.js';
22
28
  import { detectSlotAgent } from './context.js';
23
29
  import { rulesContext, rulesLabel, rulesLines } from './rules.js';
30
+ import { resolveCliSlash } from './slash/cliCommand.js';
31
+ import { parseBang, quoxArgv, needsTerminal, ttyFailure, runBang, footer as bangFooter, truncNote as bangTruncNote, shareText, withShared, cleanOutput, DEFAULT_TIMEOUT_MS } from './bang.js';
32
+ import { startupWhatsNew, listWhatsNew, unseenEntries, seenRecord, tryText, LIST_DEFAULT, unseenCommands, seenAfter, startupCommandLine, startupEnabled, toggleArg } from './whatsnew.js';
33
+ import { nextTip, normaliseMode, tipSettingsReply, tipsReply, DEFAULT_MODE, TIPS, tryCommand, tipText } from './tips.js';
24
34
  import { findSkill, skillRows, skillDetail, skillPrompt } from './skills.js';
25
- import { rosterView, peekLines, selectRows, backgroundAlerts, isExpired } from './roster.js';
35
+ import { createScreenContext } from './screenContext.js';
36
+ import { askText, askQuestion, askAnswer, askChatMessage, settledAskLine, settledAskTone, askOutcomeOf, isAskClarification, askClock } from './ask.js';
37
+ import { createHandoffStreak, handoffBiggerAskText, handoffFromEvent, handoffAskText, handoffAnsweredNote, handoffSupersededNote, handoffSkippedNote } from './handoffOffer.js';
38
+ import * as selfUpdate from '../core/selfUpdate.js';
39
+ import { runRequest, runAnswer, handOverRun, runFooter } from './slotRun.js';
40
+ import { profileChooserRows, liveApi, switchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
41
+ import { rosterView, peekLines, selectRows, backgroundAlerts, isExpired, applyProgress, SPIN_MS } from './roster.js';
26
42
  import { runObjectCommand } from './objects/index.js';
27
- const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile']);
43
+ import { peekLines as peekIdLines } from '../core/peek.js';
44
+ import { turnsFromItems, asideNoteText } from './asideView.js';
45
+ import { createMentionCache, loadMentions, receiptMentions } from './mentions.js';
46
+ const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile', '/fleet', '/prove', '/replay', '/health', '/core']);
28
47
  import { intentHint } from './intent.js';
29
48
  import { observerLines } from './observers.js';
49
+ import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
50
+ import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
30
51
  import { packageVersion } from './welcome.js';
31
- import { INVITE, cleanName, loadNames, saveName, matchAgent } from './session.js';
52
+ import { feedbackPayload, feedbackPreview } from './feedback.js';
53
+ import { vetSuggestion, namedCommand } from './suggestCommand.js';
54
+ import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // PLAN MODE (owner 2026-10-16)
55
+ import { echoLine } from './echo.js';
56
+ import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
57
+ import { pushHistory } from './promptHistory.js';
32
58
  import { termRows } from './term.js';
33
- import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows } from './connect.js';
59
+ import { cardTallerThanScreen } from './objects/view.js';
60
+ import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows, confirmLanAllow } from './connect.js';
34
61
  import { ConnectCard } from './connectCard.js';
35
- import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
36
- import { overviewLines } from '../core/sessionsOverview.js';
62
+ import { cmd as mark } from './palette.js';
63
+ import { createLoginController, LoginCard, SIGNED_OUT_LINE, signedOutCard, isSessionDead } from './signin.js';
64
+ import { createStepUp } from './slash/stepUp.js';
65
+ import { StepUpCard, PresentBar } from './slash/stepUpCard.js';
66
+ import { createStepUpCmds, declinedText } from './slash/stepUpCmds.js';
67
+ import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
68
+ import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
69
+ import { humanSessionLines } from '../core/sessionsOverview.js';
70
+ import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
37
71
  import { capabilityLines, offerFor, findCapability } from './capabilities.js';
38
- import { EyeLive, playEye, eyeActive } from './qmotion.js';
72
+ import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
73
+ import { DragonLive, playDragon, dragonActive, resetDragon } from './qmotion.js';
74
+ import { AUTOPLAY, AUTOPLAY_REQUEST, AUTOPLAY_PROOF, typingPlan } from './demo.autoplay.js';
75
+ import { motionLevel, levelFeatures, moodForEvent, ignitionGlow, normaliseLevel } from './motion/level.js';
76
+ import { CLIPS as DRAGON_CLIPS } from './qdragonScenes.js';
77
+ import { DRAGON_USAGE, DRAGON_EXPLAINS, DRAGON_ANSWERS, NOTHING_TO_EXPLAIN, ASK_PROMPT, WAKE_MS, explainPrompt, askPrompt, lastOutput, parseDragonArg, sourceLines } from './dragonModes.js';
78
+ import { SleepScreen, DragonTalk, FlyLive } from './dragonView.js';
79
+ import { FLY_PASS_MS, FLY_STILL_MS } from './qdragonFly.js';
39
80
  import { createSound } from './sound/index.js';
40
81
  import { createPalette } from './slash/paletteController.js';
41
82
  import { capabilityMap } from './slash/palette.js';
83
+ import { createArgSources } from './slash/argSources.js';
84
+ import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
85
+ import { parseBroadcastText } from './slash/broadcast.js';
86
+ import { isOwnBroadcast } from './ownBroadcast.js';
87
+ import { orgLabel, isOrgId } from './orgLabel.js';
88
+ import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine } from './slotChat.js';
89
+ import { homedir as osHome } from 'node:os';
90
+ import { createRowCmd } from './slash/rowCmd.js';
91
+ import { createKillerCmds } from './slash/killer.js';
92
+ import { createUndoWish } from './slash/undoWish.js';
93
+ import { createHatWalk } from './slash/hatWalk.js';
94
+ import { createRowEngine, runShell } from './rows.js';
95
+ import { createModelCmd } from './slash/modelCmd.js';
96
+ import { createChallengeCmd } from './slash/challenge.js';
97
+ import { createSpawnCmd } from './slash/spawn.js';
98
+ import { normPrefs, parseModelSub, label as favLabel } from './modelPrefs.js';
42
99
  import { ListView } from './slash/listView.js';
43
100
  import { createLedger, costModel } from './slash/ledger.js';
44
- import { statusModel, contextModel, doctorModel, chatChecks } from './slash/panels.js';
101
+ import { statusModel, contextModel, doctorModel, chatChecks, modelText } from './slash/panels.js';
102
+ import { parseAgentsArgs, filterAgents, agentPickerRow, toolLines, whichReply, looseCommand } from './slash/agents.js';
45
103
  import { createPicker } from './slash/picker.js';
46
104
  import { resumeRows, pickerRow, matchConversation, turnsToHistory } from './slash/resume.js';
47
105
  import { settingsList, applySettings, configRows, cycle } from './slash/settings.js';
48
- import { startBuildWatch } from './slash/restart.js';
106
+ import { countdownLine } from './countdownLine.js';
107
+ import { startBuildWatch, DIFFERENT_NOTICE } from './slash/restart.js';
108
+ import { dashboardLines } from './slash/dashboard.js';
109
+ import { supportsHyperlinks } from './markdown.js';
49
110
  import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
50
111
  import { intentPanel } from './intent.js';
112
+ import { awayDue, parseSince } from './away.js';
113
+ import { sitrepData } from './objects/sitrep.js';
114
+ import { mirrorErrorNote } from './objects/mirror.js';
51
115
  import { launchEgg } from './egg.js';
52
- import { INTRO_MS, EYE_MS } from './qart.js';
116
+ import { INTRO_MS } from './qart.js';
53
117
  import { Clock, FRAME_MS, SECOND_MS, motionAllowed, createFrames } from './motion/clock.js';
54
118
  import { FrameContext, useFrame } from './motion/frame.js';
55
119
  import { ringMode, ringAnimating } from './motion/ring.js';
56
120
  import { TIMING, revealSpan } from './motion/reveal.js';
121
+ import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
122
+ import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
123
+ import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
57
124
 
58
125
  const h = React.createElement;
59
126
  const PASTE_GUARD_MS = 40;
127
+ const ASK_ECHO_MS = 1500; // TUI-INPUT: how long an Enter after a single-key ask answer is treated as the confirm habit
60
128
  const MAX_ROSTER = 4;
61
129
  const ESC_ESC_MS = 500;
62
130
  const INTERRUPT_FALLBACK_MS = 8000;
63
- const INTRO_FRAME_MS = 50; // intro frame period on the shared clock (the plan's length is INTRO_MS, + the eye when the egg fires)
131
+ const INTRO_FRAME_MS = 50; // intro frame period on the shared clock (the plan's length is INTRO_MS)
64
132
  const HELD_WINDOW_MS = 1000; // a key seen this close before a card appears is treated as still held
65
133
  const APPROVE_WORD = 'approve';
66
134
  const CARD_GONE_MS = 400;
67
- const CARD_EDIT_KEYS = ['backspace', 'delete', 'delete-word', 'kill-line', 'left', 'right', 'home', 'end', 'word-left', 'word-right', 'undo', 'redo'];
68
135
 
69
136
 
70
137
  const STEER_OFF_NOTE = "This model can't take steering mid-turn; queued for next turn.";
@@ -87,31 +154,64 @@ export const friendlyError = (e, profile) =>
87
154
  ? `Not signed in for chat on profile ${profile}. Run: quox login -p ${profile}` : (e?.message || String(e));
88
155
 
89
156
  const byId = (id) => CHOICES.find(x => x.id === id) || null;
157
+ // the status line shows the new ask until the server answers
158
+ const NO_RAN = { provider: null, model: null, endpoint: null };
90
159
 
91
- export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api, profile = '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 = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, onRestart = null, restartCheck = null, startSha = null, buildWatch = startBuildWatch, env = process.env, motion, motionStats = null }) {
92
- const { exit } = useApp();
160
+ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, autoTickMs = 1000, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined }) {
161
+ // Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
162
+ // one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
163
+ // PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
164
+ // profile this window switched to with a y or /profile (null = as launched). Same conversation, attach, slot, groups.
165
+ const APX = React.useRef(null);
166
+ if (!APX.current || APX.current.from !== apiProp) APX.current = { live: apiProp, from: apiProp };
167
+ const APIP = React.useRef(null); APIP.current ??= liveApi(APX); const api = APIP.current;
168
+ const [who, setWho] = React.useState(null);
169
+ const profile = who?.profile ?? profileProp;
170
+ const agentLogin = who ? who.agentLogin : agentLoginProp;
171
+ const WHO = React.useRef(null); WHO.current = { profile, agentLogin };
172
+ const [signedOut, setSignedOut] = React.useState(signedOutProp);
173
+ const blocked = blockedProp || (signedOut ? SIGNED_OUT_LINE : null);
174
+ const signedOutRef = React.useRef(null); signedOutRef.current = signedOut;
175
+ const BL = React.useRef(null); BL.current = blocked; // effects/timers created at mount read the live value
176
+ const { exit: inkExit } = useApp();
93
177
  const { stdin, setRawMode, isRawModeSupported } = useStdin();
94
178
  const { stdout } = useStdout();
95
179
  const [cols, setCols] = React.useState(clampCols(columns ?? ((stdout?.columns || 80) - 1)));
96
180
  const [, bump] = React.useState(0);
181
+ // Every exit path goes through here: settle all in-flight katakana (Working word, drop-in reveals, sleep screen)
182
+ // to plain text, let that frame paint, then unmount. Ink leaves its last frame in scrollback, so it must be plain.
183
+ const exit = (afterMs = 0) => {
184
+ const R0 = R.current;
185
+ if (R0.exiting) return undefined;
186
+ R0.exiting = true; R0.sleep = null; R0.fly = null;
187
+ R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
188
+ resetDragon();
189
+ bump(n => n + 1);
190
+ return setTimeout(() => inkExit(), 60 + afterMs);
191
+ };
97
192
  const [epoch, setEpoch] = React.useState(0);
98
193
 
99
194
  const introItem = { id: 'intro', kind: 'intro', text: `${version} · ${profile} · ${cwd}`, cols: clampCols(columns ?? ((process.stdout.columns || 80) - 1)) };
100
195
  const introHistory = () => [introItem,
101
196
  ...(welcome ? [{ id: 'welcome', kind: 'welcome', version: packageVersion() }] : []),
102
197
  ...(demoLabel ? [{ id: 'demo', kind: 'note', text: demoLabel, tone: 'warn' }] : []),
198
+ ...(signedOutProp ? [{ id: 'signedOut', kind: 'note', text: signedOutCard(profile, signedOutProp.reason), tone: 'error' }] : []),
103
199
  ...(introNote ? [{ id: 'introNote', kind: 'note', text: introNote, tone: 'warn' }] : []),
104
- ...(invite ? [{ id: 'invite', kind: 'note', text: INVITE }] : [])];
200
+ ...(invite ? [{ id: 'invite', kind: 'note', text: inviteText({ slots: slotsAvailable }) }] : [])];
105
201
  // The animated intro lives in the live region (no flicker, nothing printed twice) and moves into
106
202
  // <Static> history once settled. Any keypress skips it; the composer is usable throughout.
107
203
  const introAnimating = animateIntro ?? introAnimates(stdout?.isTTY);
108
204
  // Motion (decorative animation) is decided once: a real terminal and no QUOX_REDUCED_MOTION / LITE. Colour
109
205
  // is a separate setting (NO_COLOR); one never implies the other.
110
206
  const motionOn = motion ?? motionAllowed(stdout?.isTTY);
207
+ const isInteractive = interactive ?? Boolean(stdout?.isTTY); // /dragon sleep takes over the screen: a real terminal only, never piped or headless
208
+ // Motion level (motion/level.js): read live, so a /config change applies at once. calm = everything above.
209
+ const feats = () => levelFeatures(motionLevel(env));
111
210
  const introStart = React.useRef(Date.now());
112
- // Launch egg: rarely the Dragon's Eye after Orbit & Lock (egg.js decides once per launch; never reduced motion).
113
- const eye = React.useMemo(() => (introAnimating && animateIntro === undefined ? launchEgg({ tty: Boolean(stdout?.isTTY) }) : false), []);
114
- const introMs = INTRO_MS + (eye ? EYE_MS + 400 : 0);
211
+ // Cinematic: Orbit & Lock on every launch, then the dragon greets you (open, look, wink). Launch egg: rarely the same
212
+ // dragon greeting on other levels (egg.js decides once per launch; never reduced motion).
213
+ const introDragon = React.useMemo(() => introAnimating && !mono() && (feats().introDragon || (animateIntro === undefined && feats().launchEgg && launchEgg({ tty: Boolean(stdout?.isTTY) }))), []);
214
+ const introMs = INTRO_MS + (introDragon ? DRAGON_CLIPS.greeting.dur + 300 : 0);
115
215
  const introDone = React.useRef(!introAnimating);
116
216
  const finishIntro = () => {
117
217
  if (introDone.current) return;
@@ -134,53 +234,589 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
134
234
  const committedRef = React.useRef(introAnimating ? 0 : S.current.history.length);
135
235
  const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
136
236
  const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
137
- const K = React.useRef({ keys: initialKeyState(), lastKeyAt: null, lastKeyCh: null, lastDataAt: 0, lastPasteAt: 0, lastEscAt: 0, ctrlC: 0, guard: null, cardFocus: false, cardGoneAt: 0, typed: '', danglingTimer: null });
237
+ const K = React.useRef({ keys: initialKeyState(), lastKeyAt: null, lastKeyCh: null, lastDataAt: 0, lastPasteAt: 0, lastEscAt: 0, ctrlC: 0, guard: null, dismissed: new Set(), cardFocus: false, confirm: false, cardGoneAt: 0, typed: '', danglingTimer: null });
138
238
  const chromeRef = React.useRef(null);
139
239
  const chromeH = React.useRef(null);
140
240
  const R = React.useRef({ abort: null, histIdx: -1, history: [], stash: [], reviewing: false, fallbackTimer: null, ctrlCTimer: null, banner: null, closed: false, pollTimers: new Set(), caps: { steer: true, interrupt: true }, confirmMulti: null, roster: { rows: [], all: false, allRows: [], alerts: [], sources: null, on: true, focus: false, sel: 0, peek: false, confirm: null, stopping: false } });
241
+ // QUOXON-HISTORY: Up recalls what was typed in earlier runs too (most recent first); main.js passes the file store.
242
+ if (!R.current.histLoaded) { R.current.histLoaded = true; try { R.current.history = historyStore?.load?.() || []; } catch { R.current.history = []; } }
243
+ const remember = (t) => { if (pushHistory(R.current.history, t)) { try { historyStore?.add?.(t); } catch { /* history is a convenience */ } } };
244
+ // E5: when this profile was last seen (read once at launch, before it is refreshed), and a throttled touch.
245
+ const AWAY = React.useRef({ last: null, touched: 0 });
246
+ const touchSeen = (force = false) => {
247
+ const now = Date.now();
248
+ if (!away || (!force && now - AWAY.current.touched < 60000)) return;
249
+ AWAY.current.touched = now;
250
+ try { away.write(profile, now); } catch { /* best effort */ }
251
+ };
141
252
 
142
253
  const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
254
+ /** QUOXON-FW15: the one card shown: the oldest waiting approval (this conversation first, then other sessions/plugins) not dismissed with Tab. */
255
+ const queueOf = (st) => { const all = [...st.approvals, ...st.waiting]; const live = new Set(all.map(x => x.callId)); for (const id of K.current.dismissed) if (!live.has(id)) K.current.dismissed.delete(id); return all.filter(x => !K.current.dismissed.has(x.callId)); };
256
+ const headCard = (st) => queueOf(st)[0] || null;
257
+ const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
143
258
  const dispatch = (a) => {
144
- transcript?.action(a); // dev transcript capture (off by default; see transcript.js)
259
+ if (a.type === 'note') SCX.current.notice(a.text, a.tone);
260
+ else if (a.type === 'error') SCX.current.error(a.text);
261
+ else if (a.type === 'event' && a.ev) {
262
+ if (a.ev.type === 'error') SCX.current.error(a.ev.error || a.ev.message || 'error');
263
+ else if (a.ev.type === 'turn.finished' && a.ev.status && !['ok', 'success', 'completed'].includes(a.ev.status)) SCX.current.failedTurn(a.ev.status);
264
+ }
265
+ transcript?.action(a.redact !== undefined ? { ...a, text: a.redact } : a); // dev transcript capture (off by default; see transcript.js); `redact` is what the capture sees instead of a secret-bearing text
145
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
146
268
  // Per shown card: remember the key that was already down when it appeared, and restart the typed word.
147
- const c0 = S.current.approvals[0];
269
+ const c0 = headCard(S.current);
148
270
  if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
149
271
  const gid = c0 ? `${c0.callId}:${c0.displayedAt}` : null;
150
272
  if (gid !== K.current.guard?.id) {
151
273
  const k = K.current;
152
274
  const held = c0 && k.lastKeyAt != null && Date.now() - k.lastKeyAt < HELD_WINDOW_MS ? k.lastKeyCh : null;
153
- k.guard = gid ? { id: gid, heldKey: held } : null;
154
- k.typed = '';
155
- k.cardFocus = false;
275
+ k.guard = gid ? { id: gid, heldKey: held, since: Date.now() } : null;
276
+ k.confirm = false; k.cardFocus = false;
156
277
  }
157
278
  if (!R.current.closed) bump(n => n + 1);
158
279
  };
159
- const edit = (a) => { B.current = bufferReducer(B.current, a); if (!B.current.text) R.current.multiWarned = false; bump(n => n + 1); };
160
- const say = (text, tone) => dispatch({ type: 'note', text, tone });
280
+ const edit = (a) => { B.current = bufferReducer(B.current, a); if (!B.current.text) { R.current.multiWarned = false; R.current.confirmMulti = null; } bump(n => n + 1); };
281
+ // ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): an ask is a LIVE card
282
+ // (never printed to scrollback while open); reconcileAsks settles it to one line once it is no longer open.
283
+ const askSeq = React.useRef(0);
284
+ const say = (text, tone) => {
285
+ if (tone !== 'ask') return dispatch({ type: 'note', text, tone });
286
+ const id = `ask-${++askSeq.current}`;
287
+ R.current.lastAsk = { text, question: askQuestion(text), id };
288
+ K.current.composerClicked = false; // TUI-INPUT: a new card is the active prompt until the composer is clicked again
289
+ dispatch({ type: 'ask.open', id, text, at: Date.now() });
290
+ if (typeof queueMicrotask === 'function') queueMicrotask(() => reconcileAsks()); // bind it to its open state
291
+ return undefined;
292
+ };
293
+ // QUOXON-ASK: every question for the human is ONE shape (▶ reason. question? keys), highlighted. Never say a raw y/N.
294
+ const ask = (question, opts) => say(askText(question, opts), 'ask');
295
+ // PLAN MODE: this window's mode ('ask' | 'plan'). Window-scoped: rides /restart, never the shared profile.
296
+ const MD = React.useRef(null); MD.current ??= normMode(mode);
297
+ const askerName = () => String(SN.current?.agent || where.current?.agent || 'Quox').toUpperCase(); // a model-suggested command: who asks
298
+ /** Z2H-3c: add-a-host lines, after probing that the server can actually serve its installer (null = unknown). */
299
+ const hostLines = async () => {
300
+ const base = await (api.hostsBaseUrl?.() ?? api.serverUrl?.());
301
+ let check = null; try { check = base && api.hostInstallCheck ? await api.hostInstallCheck(base) : null; } catch { check = null; }
302
+ return addHostLines(base, check);
303
+ };
161
304
  // /model: the --provider flag wins for this run ('auto' = ignore the saved choice), else the profile's saved choice.
162
305
  const MC = React.useRef(modelChoice === 'auto' ? null : normChoice(modelChoice) || (api.modelChoice?.() ?? null));
163
306
  // /connect card: owns its own secret box; the key never enters dispatch, the buffer or history.
164
307
  const CN = React.useRef(null);
165
308
  CN.current ??= createConnectController({ api, note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), refresh: () => bump(n => n + 1) });
309
+ // /login card: email (prefilled), masked password, MFA code if asked. Same login path as `quox login`.
310
+ const markSignedOut = (reason = null) => { if (BL.current) return; setSignedOut({ reason }); say(signedOutCard(profile, reason), 'error'); };
311
+ const LI = React.useRef(null);
312
+ LI.current ??= createLoginController({ api, note: (t, tone) => say(t, tone), refresh: () => bump(n => n + 1), onSignedIn: () => {
313
+ setSignedOut(null); BL.current = blockedProp || null;
314
+ R.current.notify = { fails: 0, authLost: false };
315
+ dispatch({ type: 'notify', patch: { error: null } });
316
+ refreshNotify(); refreshRoster();
317
+ if (away && api.away) { const last = AWAY.current.last; if (awayDue(last)) api.away(new Date(last).toISOString()).then((d) => dispatch({ type: 'object', obj: { type: 'away', data: { ...d, compact: true } } }), () => {}); }
318
+ } });
319
+ // QUOXON-STEPUP: the PIN card. The digits live in its closure box only: never dispatched, buffered, in history,
320
+ // undo, the transcript or any log. Agent logins never get a PIN field (slash/stepUp.js).
321
+ const SU = React.useRef(null);
322
+ SU.current ??= createStepUp({ api, agentLogin: () => WHO.current.agentLogin, onAgentLogin: () => offerSwitch(), refresh: () => bump(n => n + 1), note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), settingsUrl: async () => { try { const b = await api.dashboardBase?.(); return b ? `${String(b).replace(/\/$/, '')}/settings` : null; } catch { return null; } } });
323
+ const SUC = React.useRef(null);
324
+ SUC.current ??= createStepUpCmds({ api, stepUp: SU.current, say: (t, tone) => say(t, tone), lastBlock: () => R.current.lastBlock || null, orgOf: () => api.orgId?.() || null });
166
325
  // L0 palette: opens under the composer while the draft is a bare /word (slash/paletteController.js).
167
326
  const PL = React.useRef(null);
168
- PL.current ??= createPalette({ getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }), run: (t) => { R.current.history.push(t); command(t); }, say: (t, tone) => say(t, tone), ctx: () => ({ api, caps: R.current.contract, blocked }) });
327
+ // QUOXON-FW6: model favourites + default (server-side user preferences; profile = cache).
328
+ const MP = React.useRef(null);
329
+ MP.current ??= { prefs: normPrefs(api.localModelPrefs?.()), touched: false, warned: false };
330
+ const PK = React.useRef(null); // /resume, /config, /model
331
+ PK.current ??= createPicker({ refresh: () => bump(n => n + 1) });
332
+ const MM = React.useRef(null);
333
+ MM.current ??= createModelCmd({
334
+ api, say: (t, tone) => say(t, tone), command: (l) => command(l), mc: MC, mp: MP,
335
+ picker: PK.current, state: S, bump: () => bump(n => n + 1), cols: () => cols
336
+ });
337
+ const KC = React.useRef(null); KC.current ??= createKillerCmds({ api, say: (t, tone) => say(t, tone), getState: () => S.current, getFields: () => streamFields(MC.current) }); // /watch, /automate, /crystallize
338
+ // /undo last and /wish (slash/undoWish.js): one card, y/n on the next composer line.
339
+ const HW = React.useRef(null); // CUSTOM-HATS: /hat new|edit|delete walk
340
+ HW.current ??= createHatWalk({ api, say: (t, tone) => say(t, tone), bump: () => bump(n => n + 1) });
341
+ const UW = React.useRef(null);
342
+ UW.current ??= createUndoWish({
343
+ api, say: (t, tone) => say(t, tone), getItems: () => [...S.current.history, ...S.current.blocks],
344
+ runTurn: (t) => runTurn(t),
345
+ draft: async (prompt) => { // the spec is drafted by a normal governed turn; its answer is read back
346
+ if (S.current.turn) throw new Error('wait for the current turn or press Esc first');
347
+ const before = (S.current.answers || []).slice(-1)[0]?.id ?? null;
348
+ await runTurn(prompt);
349
+ const last = (S.current.answers || []).slice(-1)[0];
350
+ return last && last.id !== before ? last.text : null;
351
+ }
352
+ });
353
+ const CH = React.useRef(null);
354
+ CH.current ??= createChallengeCmd({ api, say: (t, tone) => say(t, tone), getState: () => S.current, sendTurn: (t) => submit(t) });
355
+ const SPN = React.useRef(null);
356
+ SPN.current ??= createSpawnCmd({ api, say: (t, tone) => say(t, tone), session: () => SN.current.name || S.current.ctx.conversationId || 'quox', from: () => senderLabel({ slot: where.current.slot || null, display: myDisplay() }), statePath: spawnStatePath });
357
+ React.useEffect(() => { if (!blocked && !demoLabel && api.slotSay) SPN.current.sweep().catch(() => {}); }, []); // release this session's overdue VMs
358
+ // QUOXON-FW17: custom rows above the status line (rows.js engine, slash/rowCmd.js).
359
+ const RW = React.useRef(null);
360
+ RW.current ??= createRowCmd({
361
+ api, say: (t, tone) => say(t, tone), bump: () => bump(n => n + 1), cols: () => cols,
362
+ engine: createRowEngine({ fetchUrl: (u) => (api.rowFetch ? api.rowFetch(u) : Promise.reject(new Error('not available'))), fetchQuox: (src) => (api.rowFetchQuox ? api.rowFetchQuox(src) : Promise.reject(new Error('not available'))), runCmd: runShell, onChange: () => bump(n => n + 1) })
363
+ });
364
+ // Rows load once the window is usable: a window started signed out loads them after /login (owner lost the QPE bar 2026-10-15).
365
+ const rowsLoaded = React.useRef(false);
366
+ React.useEffect(() => { if (!blocked && !demoLabel && !rowsLoaded.current) { rowsLoaded.current = true; RW.current.load(); } }, [blocked]);
367
+ React.useEffect(() => () => RW.current.stopEngine(), []);
368
+ PL.current ??= createPalette({
369
+ getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
370
+ run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
371
+ ctx: () => ({ api, caps: R.current.contract, blocked: BL.current }),
372
+ source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all() }),
373
+ refresh: () => bump(n => n + 1),
374
+ // MENTIONS: agents, hosts and tasks fetched once per session on the first @; receipts are this session's own tool envelopes
375
+ mentions: blocked ? null : { cache: createMentionCache({ load: () => loadMentions(api), refresh: () => bump(n => n + 1) }), local: () => receiptMentions([...S.current.history, ...S.current.blocks]) }
376
+ });
377
+ React.useEffect(() => MM.current.startOnDefault({
378
+ flagged: modelChoice != null, off: Boolean(blocked || demoLabel),
379
+ dispatchCtx: () => dispatch({ type: 'ctx', ctx: NO_RAN })
380
+ }), []);
169
381
  const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
170
- const PK = React.useRef(null); PK.current ??= createPicker({ refresh: () => bump(n => n + 1) }); // /resume, /config
171
- React.useEffect(() => (startSha ? buildWatch({ startSha, onNotice: (t) => say(t, 'dim') }) : undefined), []);
382
+ // /restart auto: saved per profile (profile.quoxon.restartAuto). `newId` = the newest installed build seen; `cancelledId` = the build whose countdown a key cancelled.
383
+ const AR = React.useRef(null); AR.current ??= { on: (api.settings?.() || {}).restartAuto === true, newId: null, cancelledId: null, timer: null, endsAt: 0 };
384
+ // Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
385
+ const countdownMs = () => { if (autoRestartMs != null) return autoRestartMs; const n = Number(env.QUOX_RESTART_COUNTDOWN); return (env.QUOX_RESTART_COUNTDOWN != null && env.QUOX_RESTART_COUNTDOWN !== '' && Number.isFinite(n) && n >= 0 ? n : 10) * 1000; };
386
+ const cdLog = (phase, seconds = null) => transcript?.action({ type: 'restart.countdown', phase, seconds, build: AR.current.newId }); // lifecycle in the dev transcript
387
+ const stopCountdown = () => { clearTimeout(AR.current.timer); AR.current.timer = null; AR.current.endsAt = 0; };
388
+ React.useEffect(() => (startSha ? buildWatch({ startSha, repeat: true, onNotice: (t, id) => {
389
+ AR.current.newId = id || t;
390
+ if (!AR.current.on) say(t === DIFFERENT_NOTICE ? t : 'a newer quox is installed: /restart (or /restart auto on)', 'dim');
391
+ } }) : undefined), []);
392
+ const autoIdle = () => { // nothing for a restart to interrupt
393
+ const s = S.current;
394
+ return !s.turn && !s.approvals.length && !s.pending.length && B.current.text === '' && !R.current.pendingWalk && !R.current.bang && !R.current.sleep
395
+ && !R.current.restarting && !R.current.exiting && !LI.current.isOpen() && !SU.current.isOpen() && !CN.current.isOpen() && !PK.current.isOpen() && !blocked;
396
+ };
397
+ React.useEffect(() => {
398
+ const id = setInterval(() => {
399
+ const a = AR.current;
400
+ if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting) return;
401
+ if (a.timer) { if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); if (R.current.pendingWalk) say('Restart held: it waits until the walkthrough ends.', 'dim'); } else bump(n => n + 1); return; } // ticks the visible countdown
402
+ if (!autoIdle()) return;
403
+ const ms = countdownMs();
404
+ if (ms <= 0) { cdLog('timed-out', 0); doRestart(); return; }
405
+ a.endsAt = Date.now() + ms;
406
+ cdLog('started', Math.round(ms / 1000));
407
+ a.timer = setTimeout(() => { stopCountdown(); if (AR.current.on && autoIdle()) { cdLog('timed-out'); doRestart(); } else { cdLog('held'); bump(n => n + 1); } }, ms);
408
+ bump(n => n + 1);
409
+ }, autoTickMs);
410
+ return () => { clearInterval(id); stopCountdown(); };
411
+ }, []);
412
+ // /restart carried a slot: re-check it out under this window's label before the old one is gone. /restart auto tends to
413
+ // fire just as a new build lands, which is often while the collector redeploys, so a timeout or 5xx is retried; if it
414
+ // never answers, the "Reattach to slot X? y/N" ask is offered instead of leaving the window silently slotless.
415
+ React.useEffect(() => { if (restartSlot && !blocked && api.checkoutSlot) carrySlot(restartSlot); }, []);
416
+ // /restart carried an /attach (agent or slot): re-attach once the window can act (owner 2026-10-15 "why dont u reattach").
417
+ const attachCarried = React.useRef(false);
418
+ React.useEffect(() => {
419
+ if (!restartAttach || blocked || attachCarried.current) return;
420
+ attachCarried.current = true;
421
+ if (S.current.turn) { R.current.pendingAttach = restartAttach; say(`Re-attaching to ${restartAttach.replace(/^(agent|slot):/, '')} when this turn ends.`, 'dim'); return; } // a resumed turn would refuse it
422
+ command(`/attach ${restartAttach}`);
423
+ }, [blocked]);
424
+ React.useEffect(() => { // any other way the resumed turn ends (reattach path, Esc): carry the attach then
425
+ if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
426
+ });
427
+ async function carrySlot(nm) {
428
+ for (let i = 0; ; i++) {
429
+ if (R.current.closed || SC.current.slot) return; // closed, or this window checked a slot out meanwhile (/slot); a slot merely inherited from a parent Claude session does not count
430
+ const r = await takeSlot(nm, true, { quietTransient: true });
431
+ if (!r?.transient) return;
432
+ if (i >= slotRetryDelays.length) {
433
+ if (R.current.closed || SC.current.slot) return;
434
+ SC.current.reattach = { slot: nm, take: true, at: Date.now() };
435
+ say(`slot ${nm}: could not reattach after the restart (${r.message}).`, 'warn');
436
+ ask(`Reattach to slot ${nm}?`, { action: `/slot ${nm} --take` });
437
+ return;
438
+ }
439
+ if (i === 0) say(`slot ${nm}: reattaching after the restart is slow (${r.message}); retrying.`, 'dim');
440
+ await new Promise((res) => { const t = setTimeout(res, slotRetryDelays[i]); t.unref?.(); });
441
+ }
442
+ }
443
+ React.useEffect(() => { // a TUI-held slot lapses to stale after 15m without a heartbeat
444
+ if (!api.slotHeartbeat) return undefined;
445
+ const id = setInterval(() => { const sl = where.current.slot; if (sl) api.slotHeartbeat(sl, slotSession()); }, 5 * 60 * 1000);
446
+ id.unref?.();
447
+ return () => clearInterval(id);
448
+ }, []);
449
+ // ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
450
+ const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
451
+ const realCwd = () => (cwd ? String(cwd).replace(/^~(?=\/|$)/, osHome()) : process.cwd());
452
+ const myDisplay = () => { try { return api.displayName?.() || null; } catch { return null; } };
453
+ const myLabel = () => senderLabel({ slot: where.current.slot || null, display: myDisplay() });
454
+ /** Listen on the held slot's live stream; every message lands as a chat line above the composer. */
455
+ function listenSlot(nm) {
456
+ SC.current.listener?.stop(); SC.current.listener = null; SC.current.slot = nm || null;
457
+ if (!nm || !api.slotStream || R.current.closed) return;
458
+ const label = myLabel();
459
+ let paused = false;
460
+ const l = createSlotListener({
461
+ open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: label }, signal),
462
+ wait: api.slotWait ? ({ since }) => api.slotWait(nm, { since, excludeFrom: label }) : null,
463
+ onMessage: (m0) => {
464
+ // a broadcast carries the sender's session, not its slot: look the slot up so the header shows the reply address
465
+ if (m0.from_slot || !api.slots || !parseBroadcastText(m0.text)) return handle(m0);
466
+ Promise.resolve().then(() => api.slots()).then(rows => handle({ ...m0, from_slot: slotOfHolder(rows, m0.from) || undefined }), () => handle(m0));
467
+ },
468
+ onState: (st) => {
469
+ if (st.error && st.failures === 1 && !R.current.closed) { paused = true; streamPausedNote(`slot ${nm}`, st.error); }
470
+ else if (st.connected && paused && !R.current.closed) { paused = false; say(`✔ slot ${nm}: reconnected`, 'dim'); }
471
+ },
472
+ });
473
+ function handle(m) {
474
+ {
475
+ if (typeof m.text === 'string') m = { ...m, text: decodeEntities(m.text) };
476
+ noteReply(m); noteSender(m);
477
+ if (R.current.closed || SC.current.slot !== nm || (label && m.from === label)) return;
478
+ if (isOwnBroadcast(m, { label, display: myDisplay(), userId: api.userId?.() || null })) return; // your own broadcast is not news: the "BROADCAST sent" card told you
479
+ { const ln = slotLine(m); const bc = parseBroadcastText(m.text); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() }); if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: bc ? { ...ln, urgent: bc.urgent, broadcast: true } : ln }); }
480
+ rememberSender(m);
481
+ if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
482
+ }
483
+ }
484
+ SC.current.listener = l; l.start({});
485
+ }
486
+ React.useEffect(() => () => { SC.current.listener?.stop(); SC.current.listener = null; for (const t of SC.current.waits?.values() || []) clearTimeout(t); }, []);
487
+ // ---- SLOT-RUN B: a slot sender asks the human to run ONE command here (slotRun.js) ------------------
488
+ /** Show the request (the full command, always) and, when it may run, arm the y/N/c ask for RUN_TTL_MS. */
489
+ function armRun(rq, ln) {
490
+ if (rq.runnable) {
491
+ const prev = SC.current.runAsk;
492
+ if (prev && Date.now() < prev.expiresAt) say(`${prev.who}'s earlier run request was replaced by this one. It was not run.`, 'warn');
493
+ if (prev) settleRun(prev, { kind: 'closed' });
494
+ rq = { ...rq, askId: `run-${++askSeq.current}` };
495
+ SC.current.runAsk = { ...rq, at: Date.now() };
496
+ (SC.current.runCards ??= new Map()).set(rq.askId, rq); // held live until settled (see commitLimit)
497
+ }
498
+ const show = () => dispatch({ type: 'slotmsg', line: { ...ln, run: rq } });
499
+ show();
500
+ if (rq.runnable) R.current.lastAsk = { text: '', question: `Run \`${rq.cmd}\` (${rq.who} asked: ${rq.why || 'no reason given'})?`, redo: show };
501
+ }
502
+ const runAskOpen = () => Boolean(SC.current.runAsk && runNow() < SC.current.runAsk.expiresAt); // one clock: the run-ask clock decides open and expired alike
503
+ /** ASK-SETTLE: history may go to <Static> (scrollback, immutable) only up to the first armed run card. */
504
+ function commitLimit(s0) {
505
+ const held = SC.current.runCards;
506
+ if (held?.size) for (let i = committedRef.current; i < s0.history.length; i++) { const r = s0.history[i]?.line?.run; if (r?.askId && !r.settled && held.has(r.askId)) return i; }
507
+ return s0.history.length;
508
+ }
509
+ /** y: hand the terminal to the user's own shell for the command (like /restart's hand-over), then resume. Never the model. */
510
+ function runSlotCommand(ra) {
511
+ if (R.current.bang) { SC.current.runAsk = ra; return say('A command is still running. Ctrl+C stops it, then answer again.', 'warn'); }
512
+ say(`$ ${ra.cmd}`, 'accent');
513
+ say(`Running ${ra.who}'s command in your shell, with this terminal handed over. Quox comes back when it ends.`, 'dim');
514
+ setTimeout(() => {
515
+ if (R.current.closed || R.current.exiting) return;
516
+ const r = handOverRun({ cmd: ra.cmd, env, cwd: realCwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
517
+ transcript?.bang?.(ra.cmd, r.error ? 'error' : (r.signal || r.code));
518
+ say(`${ra.who}'s command: ${runFooter(r)}`, !r.error && !r.signal && r.code === 0 ? 'ok' : 'warn');
519
+ bump(n => n + 1);
520
+ }, runHandOverDelayMs);
521
+ return undefined;
522
+ }
523
+ /** Say whether the recipient is live; if it is, one gold line when no reply comes. Older servers send no presence. */
524
+ function announceDelivery(slot, r) {
525
+ const p = presenceLine(slot, r.presence, agoText);
526
+ if (!p) return say(`✔ delivered to ${slot}`, 'ok');
527
+ say(p.text, p.tone);
528
+ const wait = SC.current.waits ??= new Map();
529
+ clearTimeout(wait.get(slot)); wait.delete(slot);
530
+ if (p.listening) {
531
+ const t = setTimeout(() => { wait.delete(slot); if (!R.current.closed) say(`no reply from ${slot} yet`, 'warn'); }, noReplyMs);
532
+ t.unref?.(); wait.set(slot, t);
533
+ } else if (r.presence?.state === 'not_listening') {
534
+ // Transcript fp 6a482d60fe83 (2026-10-16): an attached window got nothing for 4 minutes after "not listening".
535
+ // No reply within the wait: ONE wake ask for this message; y runs /poke <slot> (the keeper wake).
536
+ const seenAt = r.presence.last_checked_in_at || null;
537
+ const t = setTimeout(() => {
538
+ wait.delete(slot);
539
+ if (R.current.closed) return;
540
+ R.current.wakeAsk = { slot, at: Date.now() };
541
+ ask('Wake it now?', { action: `/poke ${slot}`, reason: `${slot} hasn't replied (not listening, ${seenAt ? `last checked in ${agoText(seenAt)}` : 'no check-in on record'})` });
542
+ }, noReplyMs);
543
+ t.unref?.(); wait.set(slot, t);
544
+ }
545
+ return undefined;
546
+ }
547
+ /** SLOTS-REPLY2: after a send into slot X, also show X's holder's lines arriving in X (30 min). Own slot is already listened. */
548
+ function watchReplies(slot, sent) {
549
+ if (!api.slotStream || R.current.closed) return;
550
+ SC.current.replyWatch ??= createReplyWatches({
551
+ open: (nm, q, signal) => api.slotStream(nm, { since: q.since }, signal),
552
+ wait: api.slotWait ? (nm, { since }) => api.slotWait(nm, { since }) : null,
553
+ ttlMs: replyWatchMs,
554
+ skipSlot: (nm) => nm === where.current.slot || SC.current.rooms?.has(nm),
555
+ isMine: (m) => { const l = myLabel(); return Boolean(l && m?.from === l); },
556
+ onReply: (nm, m) => {
557
+ if (R.current.closed) return;
558
+ noteReply(m); noteSender(m);
559
+ const ln = slotLine(m); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() });
560
+ if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: ln });
561
+ rememberSender(m);
562
+ },
563
+ });
564
+ SC.current.replyWatch.arm(slot, sent);
565
+ }
566
+ React.useEffect(() => () => { SC.current.replyWatch?.stop(); }, []);
567
+ // SLOTS-REPLY2: every slotSay names the session this window checked its slot out as, so the server can stamp from_slot from its hold record.
568
+ api.bindHolderSession?.(() => slotSession());
569
+ /** Any inbound line from a slot we are waiting on cancels its "no reply" nudge. */
570
+ function noteReply(m) {
571
+ const waits = SC.current.waits; if (!waits?.size) return;
572
+ for (const [slot, t] of waits) if (isHolderLine(m, slot)) { clearTimeout(t); waits.delete(slot); } // case-insensitive: LUCID is the holder of lucid
573
+ }
574
+ /** @slot, /say, /send: straight to the slot, no model turn, signed as this window; prints once delivered. */
575
+ // A typed slot name resolved against the org's slots ignoring case, hyphens, underscores and spaces; else as typed.
576
+ async function canonSlot(nm, { senders = false } = {}) {
577
+ return (await sayTarget(nm, { senders })).slot;
578
+ }
579
+ /** SLOTS-REPLY: names that recently spoke here with a verified slot (from_slot); /say <that name> reaches the slot. */
580
+ function noteSender(m) {
581
+ if (!m?.from || !m?.from_slot) return;
582
+ const list = SC.current.senders ??= [];
583
+ list.push({ from: m.from, from_slot: m.from_slot });
584
+ if (list.length > 200) list.splice(0, list.length - 200);
585
+ }
586
+ // -> { slot, label }: the slot to post to and how to show it (slot first, the name it was reached by in brackets).
587
+ // A slot name, or the session holding one ("/say SITE-CLI"), or (senders) a name that recently spoke with a slot.
588
+ async function sayTarget(nm, { senders = false } = {}) {
589
+ if (!nm) return { slot: nm, label: nm };
590
+ let rows = [];
591
+ if (api.slots) { try { rows = await api.slots(); } catch { rows = []; } }
592
+ const t = resolveSayTarget(nm, Array.isArray(rows) ? rows : [], senders ? SC.current.senders : null);
593
+ if (!t) return { slot: nm, label: nm };
594
+ return { slot: t.slot, label: t.via === 'slot' ? t.slot : addressLabel(t.slot, t.name) };
595
+ }
596
+ /** A dropped slot stream: a server restart (connection cut) is routine and says so calmly; anything else keeps the error. */
597
+ function streamPausedNote(where, err) {
598
+ if (/terminated|ECONNRESET|ECONNREFUSED|socket hang up|fetch failed|other side closed|502|503/i.test(String(err || ''))) return say(`${where}: Quox server restarting, reconnecting (nothing is lost).`, 'dim');
599
+ return say(`${where}: live messages paused (${err}); retrying.`, 'warn');
600
+ }
601
+ /** QUOXON-REPLY (owner 2026-10-15: "click the sender to reply"; no mouse tracking, so /r): who last messaged me. */
602
+ function rememberSender(m) {
603
+ const who = m?.from_slot || String(m?.from || '').replace(/\s*\(.*\)\s*$/, '').trim();
604
+ if (!who) return;
605
+ R.current.lastSender = who;
606
+ if (m?.from_slot && !R.current.replyHinted) { // no hint for a sender with no reply address: it would contradict "(no reply address)"
607
+ R.current.replyHinted = true;
608
+ say(`Tab (empty prompt) or /r <text> replies to ${who}`, 'dim');
609
+ }
610
+ }
611
+ /** /slots, /slot list: every slot, who holds it and how recently, one line each (held first). */
612
+ async function listSlots(all = false) {
613
+ if (!api.slots) return say('Slots are not available in this mode.', 'warn');
614
+ let rows; try { rows = await api.slots(); } catch (e) { return say(`Couldn't list slots: ${e?.message || 'failed'}`, 'warn'); }
615
+ const list = (Array.isArray(rows) ? rows : []).filter(r => r?.name);
616
+ if (!list.length) return say('No slots yet. /slot <name> gives this session one.');
617
+ const held = list.filter(r => r.holder_session && r.state !== 'free'), free = list.filter(r => !(r.holder_session && r.state !== 'free'));
618
+ const lines = [...slotsTable(list, { mine: where.current.slot, cols: Math.max(20, cols - 2), all }), `${held.length} held, ${free.length} free${where.current.slot ? ' · * this window' : ''}. /say <slot or session> <text> reaches a held one.`];
619
+ for (const n of new Set(list.map(r => r.human_presence?.away && r.human_presence.notice).filter(Boolean))) lines.push(`⏸ ${n}`); // PRESENCE
620
+ return say(lines.join('\n'));
621
+ }
622
+ async function sendToSlot(slot, text) {
623
+ const target = await sayTarget(slot, { senders: true });
624
+ slot = target.slot;
625
+ if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
626
+ if (SC.current.rooms.has(slot)) return postRoom(slot, text); // a joined room posts as a member
627
+ const r = await api.slotSay(slot, text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
628
+ if (!r.ok && r.status === 404 && api.slots) { let rows = []; try { rows = await api.slots(); } catch { /* the list is a hint only */ } return say(sayMissText(slot, rows), 'warn'); }
629
+ if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
630
+ const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
631
+ dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
632
+ announceDelivery(slot, r);
633
+ watchReplies(slot, r.message);
634
+ if (r.humanPresence?.away && r.humanPresence.notice) say(`⏸ ${r.humanPresence.notice}`, 'warn'); // PRESENCE
635
+ if (r.roomPending) say(`${slot} is new: listening windows have it; its chat room copy is queued, delivering when the room is ready.`, 'dim');
636
+ return undefined;
637
+ }
638
+ // ---- QUOXON-ROOMS: a slot as a group room. Joining subscribes (no hold); `#room text` posts; rooms list with unread. ----
639
+ const roomMe = () => slotSession();
640
+ const roomSigned = () => { const d = myDisplay(); return d ? `${roomMe()} (${d})` : roomMe(); };
641
+ const isMine = (m) => { const f = String(m?.from || ''); return f === roomMe() || f.startsWith(`${roomMe()} (`); };
642
+ async function joinRoom(nm, { resume = false } = {}) {
643
+ if (!api.slotJoin || !api.slotStream) return say('Rooms are not available in this mode.', 'warn');
644
+ const rooms = SC.current.rooms;
645
+ if (rooms.has(nm)) { if (!resume) say(`Already in #${nm}. #${nm} <text> posts; #${nm} alone makes it the default.`, 'dim'); return true; }
646
+ const me = roomMe();
647
+ if (!resume) {
648
+ const r = await api.slotJoin(nm, me, myDisplay());
649
+ if (!r.ok) { say(`#${nm}: ${r.message}`, 'warn'); return false; }
650
+ }
651
+ let since = null;
652
+ try { // a one-line-each backlog of the last ~10, so a join lands in the middle of the conversation
653
+ const rows = await (api.slotMessages ? api.slotMessages(nm, 10) : []);
654
+ for (const m of rows) { dispatch({ type: 'slotmsg', line: roomLine(nm, m) }); since = m.created_at || since; }
655
+ } catch { /* the live stream still starts; the backlog is a courtesy */ }
656
+ if (R.current.closed) return false;
657
+ const entry = { listener: null, unread: 0 };
658
+ entry.listener = createSlotListener({
659
+ open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: me, session: me }, signal),
660
+ wait: api.slotWait ? ({ since: sn }) => api.slotWait(nm, { since: sn, excludeFrom: me }) : null,
661
+ onMessage: (m) => {
662
+ noteReply(m); noteSender(m);
663
+ if (R.current.closed || !SC.current.rooms.has(nm) || isMine(m)) return;
664
+ if (SC.current.active !== nm) entry.unread += 1; // before the dispatch, so the status line's count is current
665
+ dispatch({ type: 'slotmsg', line: roomLine(nm, m) });
666
+ rememberSender(m);
667
+ if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
668
+ },
669
+ onState: (st) => { if (st.error && st.failures === 1 && !R.current.closed) streamPausedNote(`#${nm}`, st.error); },
670
+ });
671
+ rooms.set(nm, entry); bump(n => n + 1); // the status line's groups update now, even on a quiet resume
672
+ entry.listener.start({ since });
673
+ if (!resume) say(`Joined #${nm}. #${nm} <text> posts as ${roomSigned()}; #${nm} alone makes it the default target (a bare # undoes it); /leave ${nm} leaves.`, 'ok');
674
+ return true;
675
+ }
676
+ async function leaveRoom(nm) {
677
+ const e = SC.current.rooms.get(nm);
678
+ if (!e) return say(`You are not in #${nm}. /rooms lists the rooms you are in.`, 'dim');
679
+ e.listener?.stop(); SC.current.rooms.delete(nm);
680
+ if (SC.current.active === nm) SC.current.active = null;
681
+ bump(n => n + 1);
682
+ const r = await api.slotLeave?.(nm, roomMe());
683
+ say(r && !r.ok ? `Left #${nm} here; the server did not confirm (${r.message}).` : `Left #${nm}.`, r && !r.ok ? 'warn' : 'ok');
684
+ }
685
+ function showRooms() {
686
+ const rooms = SC.current.rooms;
687
+ if (!rooms.size) return say('No rooms joined. /join <slot> joins one (any slot in this org).', 'dim');
688
+ for (const [nm, e] of rooms) {
689
+ const live = e.listener?.state?.().connected ? '' : ' (reconnecting)';
690
+ say(`${SC.current.active === nm ? '> ' : ' '}#${nm} ${e.unread ? `${e.unread} unread` : 'no unread'}${SC.current.active === nm ? ' (default target)' : ''}${live}`, e.unread ? 'accent' : 'dim');
691
+ }
692
+ }
693
+ async function setActiveRoom(nm) {
694
+ if (!(await joinRoom(nm))) return;
695
+ SC.current.active = nm; const e = SC.current.rooms.get(nm); if (e) e.unread = 0;
696
+ say(`Default target is #${nm}: plain lines now post there. A bare # puts it back.`, 'ok');
697
+ }
698
+ async function postRoom(nm, text) {
699
+ if (!api.slotSay) return say('Rooms are not available in this mode.', 'warn');
700
+ if (!SC.current.rooms.has(nm) && !(await joinRoom(nm))) return;
701
+ const r = await api.slotSay(nm, text, { roomSession: roomMe(), fast: true });
702
+ if (!r.ok) return say(`#${nm}: ${r.message}`, 'warn');
703
+ const e = SC.current.rooms.get(nm); if (e) e.unread = 0;
704
+ dispatch({ type: 'slotmsg', line: { ...roomLine(nm, { from: roomSigned(), text, created_at: r.message?.created_at || new Date().toISOString() }, { out: true }), delivered: true } });
705
+ }
706
+ /** `/group-chat join|leave|<text>` and /join, /leave: a typed slot name is checked BEFORE "Ask Quox". */
707
+ async function slotNameCommand(cmdWord, rest) {
708
+ let nm = roomName(cmdWord.replace(/^\//, ''));
709
+ if (!nm || !api.slots) return false;
710
+ let rows = null;
711
+ try { rows = await api.slots(); } catch { return false; }
712
+ const hit = uniqueSquash(nm, (Array.isArray(rows) ? rows : []).map(x => x.name));
713
+ if (!hit) return false;
714
+ nm = hit.toLowerCase();
715
+ const t = String(rest || '').trim();
716
+ if (/^join$/i.test(t)) await joinRoom(nm);
717
+ else if (/^leave$/i.test(t)) await leaveRoom(nm);
718
+ else if (!t) await setActiveRoom(nm);
719
+ else await postRoom(nm, t);
720
+ return true;
721
+ }
722
+ React.useEffect(() => { // rooms are server truth: a restarted named window rejoins quietly
723
+ // keyed like roomMe(): the name, else the conversation an unnamed window joined under (restart resumes it)
724
+ const me = SN.current.name || resumeConversation;
725
+ if (blocked || demoLabel || !api.slotRooms || !me) return;
726
+ api.slotRooms(me).then((rows) => { for (const r of rows) joinRoom(String(r.slot_name), { resume: true }); }).catch(() => {});
727
+ }, []);
728
+
729
+ async function slotLog(nArg) {
730
+ const nm = where.current.slot;
731
+ if (!nm) return say('No slot held. /slot <name> gives this session one.', 'warn');
732
+ if (!api.slotMessages) return say('Slots are not available in this mode.', 'warn');
733
+ const n = Math.min(Math.max(parseInt(nArg, 10) || 20, 1), 100);
734
+ let rows;
735
+ try { rows = await api.slotMessages(nm, n); } catch (e) { return say(`slot ${nm}: ${e?.message || 'failed'}`, 'warn'); }
736
+ if (!rows.length) return say(`No messages in ${nm} yet.`);
737
+ say(`Last ${rows.length} in ${nm} (seen by Quox):`);
738
+ for (const m of rows) { // SLOT-RUN: an old run request is shown, never armed (only a live one can be answered)
739
+ noteSender(m); const rq = runRequest(m);
740
+ dispatch({ type: 'slotmsg', line: { ...slotLine(m), full: true, ...(rq ? { run: { ...rq, runnable: false, danger: null, refuse: 'it was sent earlier, and only a live request can be answered here' } } : {}) } });
741
+ }
742
+ return undefined;
743
+ }
744
+ async function sendFile(slot, p) {
745
+ const f = prepareFile(p, { cwd: realCwd() });
746
+ if (!f.ok) return say(f.message, 'warn');
747
+ if (f.hits) {
748
+ SC.current.pendingFile = { slot, f, at: Date.now() };
749
+ return ask(`Send it to ${slot} with ${f.hits === 1 ? 'that' : 'those'} redacted?`, { action: `send ${f.name} to slot ${slot}, ${f.hits} value${f.hits === 1 ? '' : 's'} replaced by [redacted]`, reason: `${f.name} has ${f.hits} secret-shaped value${f.hits === 1 ? '' : 's'}.` });
750
+ }
751
+ return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }));
752
+ }
753
+ // Startup: the slot this profile last held in this directory, free or stale (or still under our old label), is offered once.
754
+ React.useEffect(() => {
755
+ if (blocked || demoLabel || restartSlot || !api.checkoutSlot || !api.slots) return;
756
+ const rec = loadLastSlot({ profile, cwd: realCwd(), path: slotRecordPath });
757
+ if (!rec) return;
758
+ api.slots().then((rows) => {
759
+ const row = (Array.isArray(rows) ? rows : []).find(x => String(x.name) === rec.slot);
760
+ if (!row || SC.current.slot || R.current.closed) return; // only this window's own checkout suppresses the ask, not an inherited one
761
+ const mine = row.holder_session && rec.session && row.holder_session === rec.session;
762
+ if (row.state === 'free' || row.state === 'stale' || !row.holder_session || mine) {
763
+ SC.current.reattach = { slot: rec.slot, take: Boolean(mine || row.state === 'stale'), at: Date.now() };
764
+ ask(`Reattach to slot ${rec.slot}?`, { action: `/slot ${rec.slot}` });
765
+ }
766
+ }).catch(() => { /* no ask when the server cannot say */ });
767
+ }, []);
172
768
  React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume)); }, []);
173
- React.useEffect(() => { if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); }, () => {}); }, []);
769
+ React.useEffect(() => { if (!blocked && !demoLabel) aiConnected().then((st) => { R.current.noAi = st === false ? true : st === true ? false : undefined; bump(n => n + 1); }); }, []);
770
+ // Tips (tips.js): mode, rotation and "already used" persist on the profile (quoxon.tips / tipsSeen / tipsUsed).
771
+ // QUOXON-MOUSE: optional mouse (default off, saved per profile). `tty` = tracking is on at the terminal right now.
772
+ const MO = React.useRef(null);
773
+ MO.current ??= { on: (api.settings?.() || {}).mouse === 'on', tty: false, chain: Promise.resolve(), drag: null, last: null, onCursor: null, exitFn: null };
774
+ const TP = React.useRef(null);
775
+ TP.current ??= (() => { const q = api.settings?.() || {}; const arr = (v) => (Array.isArray(v) ? v.filter(x => typeof x === 'string') : []); return { mode: normaliseMode(q.tips) || DEFAULT_MODE, seen: arr(q.tipsSeen), used: arr(q.tipsUsed) }; })();
776
+ const showTip = async ({ startup: atLaunch = false } = {}) => {
777
+ const t = TP.current;
778
+ if (t.mode === 'off') return atLaunch ? undefined : say('Tips are off. /tip-settings all turns them back on.');
779
+ let hosts; try { hosts = await api.hostCount?.(); } catch { hosts = undefined; } // unknown never skips a tip
780
+ if (atLaunch && S.current.approvals.length) return undefined; // never over an approval
781
+ let plugins; try { plugins = await api.installedPlugins?.(); } catch { plugins = undefined; } // unreadable = plugin tips hidden
782
+ const signals = { hosts, modelFavs: MP.current.prefs.favourites.length, rows: RW.current.all().length };
783
+ const contract = R.current.contract || null;
784
+ const r = nextTip({ mode: t.mode, contract, plugins, signals, used: t.used, seen: t.seen });
785
+ if (!r.tip) return atLaunch ? undefined : say('No tips to show right now for this install.');
786
+ t.seen = r.seen; api.saveSetting?.('tipsSeen', t.seen);
787
+ if (R.current.sleep) return undefined; // no tip over the sleep screen
788
+ return dispatch({ type: 'tip', text: tipText(r.tip), cmd: r.tip.try });
789
+ };
790
+ const tipStarted = React.useRef(false);
791
+ // What's new (whatsnew.js): once per newer build; first ever run only records. Wins the startup slot over the tip.
792
+ const showWhatsNew = () => {
793
+ if (!whatsNewStartup || blocked || demoLabel) return false;
794
+ const st = api.settings?.() || {};
795
+ const r = startupWhatsNew({ build: version, last: st.whatsNew, entries: whatsNewEntries });
796
+ if (r.record) api.saveSetting?.('whatsNew', r.record);
797
+ if (welcome || !startupEnabled(st)) return false;
798
+ if (r.show.length) { dispatch({ type: 'whatsnew', items: r.show.map(e => ({ title: e.title, cmd: tryText(e) })), more: r.more }); return true; }
799
+ const line = startupCommandLine(unseenCommands({ registry: visibleRegistry(), seen: st.whatsNewSeen })); // /whatsnew marks them seen
800
+ if (!line) return false;
801
+ dispatch({ type: 'whatsnew', head: "What's new", pad: true, items: [{ title: line, cmd: '' }], more: 0 });
802
+ return true;
803
+ };
804
+ const startupTip = () => {
805
+ if (tipStarted.current || blocked) return;
806
+ tipStarted.current = true;
807
+ const shown = showWhatsNew();
808
+ if (tipStartup && !welcome && !shown) showTip({ startup: true });
809
+ };
810
+ React.useEffect(() => {
811
+ if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); startupTip(); }, () => startupTip());
812
+ else startupTip();
813
+ }, []);
174
814
 
175
815
  React.useEffect(() => {
176
816
  if (columns != null) { setCols(clampCols(columns)); return undefined; }
177
- // Rows the old width wrapped cannot be erased by Ink's line-count log-update (ghost composers). Wipe the
178
- // visible screen, then remount <Static> so history reprints once at the new width.
179
- const onResize = () => {
180
- if (stdout?.isTTY !== false) stdout.write('\x1b[2J\x1b[H');
181
- setEpoch(e => e + 1);
182
- setCols(clampCols((stdout.columns || 80) - 1));
183
- };
817
+ // Only the live region repaints at the new width (R3 D2): the stdout proxy (term.js) erases the old frame
818
+ // by its reflowed height, so no ghost composers, and history is never reprinted into scrollback.
819
+ const onResize = () => setCols(clampCols((stdout.columns || 80) - 1));
184
820
  stdout?.on?.('resize', onResize);
185
821
  return () => stdout?.off?.('resize', onResize);
186
822
  }, [stdout, columns]);
@@ -191,34 +827,102 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
191
827
  const SN = React.useRef({ name: cleanName(sessionName), saved: null, agent: null, baseAgent: where.current.agent ?? null });
192
828
  React.useEffect(() => {
193
829
  const id = S.current.ctx.conversationId; const key = `${id}|${SN.current.name}`;
194
- if (SN.current.name && id && SN.current.saved !== key) { SN.current.saved = key; saveName(id, SN.current.name, namesPath); }
830
+ if (SN.current.name && id && SN.current.saved !== key) { SN.current.saved = key; saveName(id, SN.current.name, namesPath, { profile, org: R.current.orgName ?? org ?? null }); }
195
831
  });
196
832
 
197
833
  // Live approvals count: refreshed from the inbox every 30 s. A failed refresh keeps the last
198
834
  // known count and records the error; it never reads as "0 waiting".
835
+ // The org's name, or "org …H398" while it is unknown; never a raw ULID. A lookup that failed at startup (server down)
836
+ // is retried on the next successful poll (owner 2026-10-18).
837
+ const orgShown = () => orgLabel(R.current.orgName ?? org, R.current.orgId ?? api.orgId?.() ?? null);
838
+ const retryOrgName = async () => {
839
+ const cur = R.current.orgName ?? org;
840
+ if ((cur && !isOrgId(cur)) || !api.orgs || R.current.orgLookup) return;
841
+ const id = R.current.orgId ?? api.orgId?.() ?? null; if (!id) return;
842
+ R.current.orgLookup = true;
843
+ try {
844
+ const hit = (await api.orgs()).find((o) => o?.id === id);
845
+ const nm = hit?.name || hit?.slug;
846
+ if (nm && !isOrgId(nm)) { R.current.orgName = nm; dispatch({ type: 'notify', patch: {} }); }
847
+ } catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
848
+ };
199
849
  const refreshNotify = async () => {
200
- if (blocked) return;
850
+ if (BL.current) return;
851
+ const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
201
852
  try {
202
853
  const items = await api.approvals();
854
+ if (N.authLost) say('Signed in again: approvals are back.', 'ok');
855
+ else if (N.slow) say('Quox server is back.', 'ok');
856
+ N.fails = 0; N.authLost = false; N.slow = false;
203
857
  dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
858
+ retryOrgName();
204
859
  dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
860
+ R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
205
861
  // Provider paths that emit no approval.requested still surface here, as the same card.
206
862
  for (const item of items) dispatch({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
207
- } catch (e) { dispatch({ type: 'notify', patch: { error: e?.message || 'failed' } }); }
863
+ } catch (e) {
864
+ N.fails++; // counted apart from an empty inbox
865
+ const msg = e?.message || 'failed';
866
+ if (!e?.transient && (e?.status === 401 || isSessionDead(e) || /No authentication token|service key|unauthori[sz]ed/i.test(msg))) {
867
+ N.authLost = true;
868
+ markSignedOut(e?.reason || null); // one card, then the pollers go quiet until /login
869
+ dispatch({ type: 'notify', patch: { error: null } });
870
+ return;
871
+ }
872
+ if (e?.transient && !N.slow) { N.slow = true; say('Quox server is slow or unreachable, retrying.', 'warn'); } // QUOXON-FW12: not signed out
873
+ dispatch({ type: 'notify', patch: { error: msg } });
874
+ }
208
875
  };
209
876
  React.useEffect(() => {
210
- refreshNotify();
211
- const id = setInterval(refreshNotify, notifyMs);
212
- return () => clearInterval(id);
877
+ // Back off on repeated failure (x2 per miss, capped at 60 s); the normal cadence returns after one success.
878
+ let timer = null, stopped = false;
879
+ const tick = async () => {
880
+ // Stale sign-in: the credentials file changed (another window refreshed, or `quox login` ran). While this
881
+ // window reads as signed out, re-read them and retry once; a still-dead session just marks itself again.
882
+ try {
883
+ if (api.credentialsChanged?.() && BL.current && signedOutRef.current && api.reloadCredentials?.()) {
884
+ setSignedOut(null); BL.current = blockedProp || null;
885
+ R.current.notify = { fails: 0, authLost: false };
886
+ refreshRoster();
887
+ }
888
+ } catch { /* a failed stat never blocks the poll */ }
889
+ await refreshNotify();
890
+ if (stopped) return;
891
+ const f = R.current.notify?.fails || 0;
892
+ timer = setTimeout(tick, f ? Math.min(60000, notifyMs * 2 ** Math.min(f, 12)) : notifyMs);
893
+ };
894
+ tick();
895
+ return () => { stopped = true; clearTimeout(timer); };
896
+ }, []);
897
+
898
+ // #904 part 3: a provider refusing turns (weekly limit, no credits) shows in the status area before
899
+ // you type into it. Polled once a minute; a failed read keeps the last answer (never "fine").
900
+ React.useEffect(() => {
901
+ if (!api.providerHealth) return undefined;
902
+ let timer = null, stopped = false;
903
+ const tick = async () => {
904
+ if (!BL.current) {
905
+ const list = await api.providerHealth();
906
+ // Only the provider this window uses (owner 2026-10-15: "only need to know if using openai"): the chosen
907
+ // model's provider, else the provider the last turn ran on. Unknown yet: say nothing; a failing turn says why.
908
+ const mine = String(MC.current?.provider || S.current?.ctx?.provider || '').toLowerCase();
909
+ const hits = list ? list.filter(e => mine && String(e.provider || '').toLowerCase() === mine) : null;
910
+ if (hits) dispatch({ type: 'notify', patch: { providerLines: hits.map(e => `⚠ ${e.line || e.provider + ' ' + e.state}: chat on it fails until then`) } });
911
+ }
912
+ if (!stopped) timer = setTimeout(tick, 60000);
913
+ };
914
+ tick();
915
+ return () => { stopped = true; clearTimeout(timer); };
213
916
  }, []);
214
917
 
215
918
  // Roster: refreshed from real list endpoints. A failing source adds no rows and is named in /roster.
216
919
  const refreshRoster = async () => {
217
- if (blocked || !api.roster) return;
920
+ if (BL.current || !api.roster) return;
218
921
  try {
219
922
  let r = await api.roster(Date.now());
220
923
  const R0 = R.current.roster;
221
924
  const prevKey = R0.rows[R0.sel]?.key;
925
+ applyProgress(r.rows, (R0.progress ||= new Map()), Date.now());
222
926
  R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
223
927
  r = { ...r, rows: selectRows(r.rows, { all: R0.all }) };
224
928
  R0.rows = r.rows;
@@ -257,6 +961,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
257
961
  if (!framesRef.current) framesRef.current = createFrames();
258
962
  const lastAppRender = React.useRef(0);
259
963
  const ringRef = React.useRef(null);
964
+ const dividerSlowRef = React.useRef({}); // TUI-DIVIDER: per-turn slow-redraw marker (dividerRule.noteFrameGap)
260
965
  const onClockTick = React.useRef(null);
261
966
  onClockTick.current = () => {
262
967
  if (R.current.closed) return;
@@ -275,14 +980,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
275
980
  if (!introDone.current) return true;
276
981
  const sec = (t) => Math.floor(t / 1000);
277
982
  if (R.current.roster.rows.length && sec(now) !== sec(lastAppRender.current)) return true; // roster ages
983
+ if (motionOn && !mono() && R.current.roster.rows.some((x) => x.state === 'running')) return true; // pulse spinner frames
278
984
  if (motionOn) {
279
- if (s0.history.length > committedRef.current && !fxActive(s0.history[committedRef.current], now) && !isActiveStage(s0, committedRef.current)) return true; // commit to Static
985
+ if (commitLimit(s0) > committedRef.current && !fxActive(s0.history[committedRef.current], now) && !isActiveStage(s0, committedRef.current)) return true; // commit to Static
280
986
  for (const r of R.current.roster.rows) if (rosterFx(r.key, now) != null || !rosterSeen.current.has(r.key)) return true;
281
987
  }
282
988
  const card0 = s0.approvals[0];
283
989
  if (card0) {
284
990
  const age = now - (card0.displayedAt ?? card0.shownAt);
285
- if (motionOn && age < 1000) return true; // arrival animation
991
+ if (motionOn && (age < 1000 || R.current.cardMidAnim)) return true; // arrival animation, plus one settled render if the last draw was mid-animation (a late tick on a loaded box left the title scrambled for good)
286
992
  if (!R.current.armedShown && age >= 300) return true; // the card arms
287
993
  }
288
994
  return false;
@@ -296,10 +1002,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
296
1002
  if (s0.turn) d.push(motionOn ? { every: FRAME_MS } : { every: SECOND_MS, since: s0.turn.startedAt });
297
1003
  const card0 = s0.approvals[0];
298
1004
  if (card0) { const at = (card0.displayedAt ?? card0.shownAt) + 320; if (now < at) d.push(motionOn ? { every: FRAME_MS } : { at }); }
299
- if (R.current.roster.rows.length) d.push({ every: SECOND_MS });
1005
+ if (R.current.roster.rows.length) d.push({ every: motionOn && !mono() && R.current.roster.rows.some((x) => x.state === 'running') ? SPIN_MS : SECOND_MS }); // SPIN_MS: the roster pulse spinner
300
1006
  if (ringAnimating(ringRef.current)) d.push({ every: FRAME_MS }); // the ring settling after the turn
301
- if (eyeActive(now, !motionOn)) d.push({ every: motionOn ? 50 : 500 }); // /eye (qmotion EyeLive) plays on the shared clock
1007
+ if (motionOn && !mono() && dividerLive(s0.divider, now)) { // TUI-DIVIDER: the AOCL strip, then its green/red hold; nothing once it is over
1008
+ d.push({ every: FRAME_MS });
1009
+ if (s0.divider.endedAt != null) d.push({ at: s0.divider.endedAt + (s0.divider.phase === 'done' ? GREEN_MS : RED_MS) + 1 });
1010
+ }
1011
+ if (dragonActive(now, !motionOn || mono())) d.push({ every: motionOn && !mono() ? 40 : 500 }); // /dragon and the cinematic moods (qmotion DragonLive)
302
1012
  if (motionOn && fxBusy(now)) d.push({ every: FRAME_MS }); // reveals in flight
1013
+ const sl = R.current.sleep;
1014
+ if (sl) d.push({ every: sl.wakeAt != null ? 50 : sl.still ? 1000 : 120 }); // sleep screen: low frame rate (shimmer, slow sway)
1015
+ if (dragonTalkVisible(now)) d.push({ every: motionOn && !mono() ? 60 : 500 }); // /dragon explain|ask
1016
+ if (flyActive(now)) d.push({ every: R.current.fly.still ? 500 : 60 }); // /dragon-fly
303
1017
  return d;
304
1018
  };
305
1019
  React.useEffect(() => { if (!R.current.closed) clockRef.current.schedule(demands()); });
@@ -308,23 +1022,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
308
1022
  const fxActive = (item, now) => { const e = fxRef.current.get(item.id); return Boolean(e) && now < fxEnd(e); };
309
1023
  const fxFor = (item, now) => {
310
1024
  const e = fxRef.current.get(item.id);
311
- if (!e || now >= fxEnd(e)) return null;
1025
+ if (!e || now >= fxEnd(e) || R.current.exiting) return null;
312
1026
  return { e: (now - e.at) / 1000, timing: e.timing, settle: e.settleAt != null ? (now - e.settleAt) / 1000 : null };
313
1027
  };
314
1028
  const registerFx = (item, now) => {
315
1029
  const t = revealTimingFor(item);
316
1030
  if (!t) return;
1031
+ if (item.kind === 'object' && !fxRef.current.has(item.id) && cardTallerThanScreen(item, cols, termRows(stdout) ?? 40, now)) return; // TUI-UX #5: a card taller than the screen never decodes in (it would leave a garbled copy in scrollback)
317
1032
  const e = fxRef.current.get(item.id);
318
- if (!e) fxRef.current.set(item.id, { at: now, timing: t, running: item.kind === 'tool' && item.status === 'running', settleAt: null });
1033
+ if (!e) { fxRef.current.set(item.id, { at: now, timing: t, running: item.kind === 'tool' && item.status === 'running', settleAt: null }); if (motionStats) (motionStats.reveals ||= []).push({ id: item.id, kind: item.kind, text: item.text ?? null, timing: t }); } // motionStats: which items started a decode-in, for a test that cannot rely on sampling a 0.28 s window
319
1034
  else if (e.running && item.status !== 'running' && e.settleAt == null) e.settleAt = now;
320
1035
  };
321
1036
  const rosterFx = (key, now) => { const at = rosterSeen.current.get(key); const e = at == null ? null : (now - at) / 1000; return e != null && e < revealSpan(TIMING.roster) ? e : null; };
322
1037
  function fxBusy(now) {
323
1038
  const s0 = S.current;
324
- if (s0.history.length > committedRef.current) return true;
1039
+ if (commitLimit(s0) > committedRef.current) return true;
325
1040
  if (s0.blocks.some(b => fxActive(b, now))) return true;
326
1041
  const card0 = s0.approvals[0];
327
- if (card0 && now - (card0.displayedAt ?? card0.shownAt) < 1000) return true;
1042
+ if (card0 && (now - (card0.displayedAt ?? card0.shownAt) < 1000 || R.current.cardMidAnim)) return true; // owed a settled draw
328
1043
  for (const r of R.current.roster.rows) if (rosterFx(r.key, now) != null) return true;
329
1044
  return false;
330
1045
  }
@@ -348,22 +1063,326 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
348
1063
  }
349
1064
  return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
350
1065
  };
351
- const answer = async (card, action) => {
1066
+ /** Where an answer came from, for the approve comment: the session name, else the conversation id, else a marker. */
1067
+ const slotSession = () => SN.current.name || S.current.ctx.conversationId || 'quox';
1068
+ const agoText = (iso) => { const m = iso ? Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000)) : null; return m == null || Number.isNaN(m) ? 'a while ago' : m < 1 ? 'just now' : m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`; };
1069
+ // Check a slot out for this window. A hold by another session of the SAME user is offered as a takeover (y/N, or --take).
1070
+ const takeSlot = async (nm, take, { quietTransient = false } = {}) => {
1071
+ const r = await api.checkoutSlot(nm, slotSession(), { take });
1072
+ if (!r.ok && r.transient && quietTransient) return r; // carrySlot retries and speaks for itself
1073
+ if (r.ok) {
1074
+ where.current = { ...where.current, slot: nm }; bump(x => x + 1);
1075
+ listenSlot(nm);
1076
+ saveLastSlot({ profile, cwd: realCwd(), slot: nm, session: slotSession(), path: slotRecordPath });
1077
+ const from = r.tookOver?.from;
1078
+ return say(from ? `Slot ${nm} checked out (took over your ${r.tookOver.state === 'stale' ? 'stale ' : ''}session '${from}'). Messages to ${nm} show here as they arrive.` : `Slot ${nm} checked out. Messages to ${nm} show here as they arrive; @<slot> <text> sends.`, 'ok');
1079
+ }
1080
+ const h = r.held;
1081
+ if (h?.same_user) {
1082
+ R.current.slotAsk = { nm, at: Date.now() };
1083
+ return ask('Take it over?', { action: `/slot ${nm} --take`, reason: `${nm} is held by your other session '${h.holder_session}' (last seen ${agoText(h.last_seen)}); /slot ${nm} --take also works.` });
1084
+ }
1085
+ if (h) return say(`slot ${nm} is held by ${h.holder_session || 'another user'}${h.since ? ` since ${h.since}` : ''}. It is not yours to take.`, 'warn');
1086
+ if (r.unlicensed) return say(r.message, 'warn');
1087
+ return say(`slot ${nm}: ${r.message}`, 'warn');
1088
+ };
1089
+ // QUOXON-STEPUP: the server decides what needs a person. We try with any live step-up token; on 403 step_up_required
1090
+ // we ask for the PIN (masked card) and retry once. `proactive` (/authorise) asks first. Deny never needs a PIN.
1091
+ const respondWithStepUp = async (card, action, { proactive = false, reason = null, bulk = false } = {}) => {
1092
+ const via = `tty ${sessionLabel()}`;
1093
+ const subject = { line1: `${action === 'approve' ? 'approve' : 'reject'} ${String(card.tool || card.inboxItemId || 'item')}${card.target ? ` · ${String(card.target)}` : ''}`, raisedBy: card.raisedBy || null, risk: card.risk || null };
1094
+ const ask = async () => {
1095
+ const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject });
1096
+ if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; }
1097
+ };
1098
+ if (action !== 'approve') return api.respond(card.inboxItemId, action, { via, reason });
1099
+ if (proactive && !SU.current.token('approve')) await ask();
1100
+ try { return await api.respond(card.inboxItemId, action, { via, stepUp: SU.current.headers('approve'), bulk }); }
1101
+ catch (e) {
1102
+ if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
1103
+ SU.current.drop('approve'); // a stale or mismatched token must not be reused
1104
+ await ask();
1105
+ return api.respond(card.inboxItemId, action, { via, stepUp: SU.current.headers('approve'), bulk });
1106
+ }
1107
+ };
1108
+ // QUOXON-STEPUP: an override id is single-use; the next turn carries it once, then it is gone.
1109
+ const takeOverride = () => { const o = R.current.override; R.current.override = null; return o && o.until > Date.now() ? o.id : undefined; };
1110
+ const sessionLabel = () => String(SN.current.name || (S.current.ctx.conversationId ? String(S.current.ctx.conversationId).slice(0, 12) : 'new session')).replace(/\s+/g, ' ').slice(0, 40);
1111
+ // APPROVALS-AUDIT: a typed number is a position. If the list moved since the owner last saw it, refuse and show it again.
1112
+ const listChanged = async () => {
1113
+ if (R.current.walkSend || !R.current.shownIds) return false;
1114
+ let items; try { items = await api.approvals(); } catch { return false; }
1115
+ const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
1116
+ const ids = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item.id));
1117
+ const seen = R.current.shownIds;
1118
+ if (ids.length === seen.length && ids.every((x, i) => x === seen[i])) return false;
1119
+ say('The list changed since you last saw it, so nothing was sent. Here it is again:', 'warn');
1120
+ await command('/approvals');
1121
+ return true;
1122
+ };
1123
+ // WALKTHROUGH: decisions one card at a time. Identity is the decision id (items are held by id), never a list position.
1124
+ // WALK-FIXES (owner 2026-10-17: "escape to exit survey. two came at once. didnt submit each answer as i gave it, exited
1125
+ // halfway thru and tried again, answered question returned"): ONE walk and ONE card on screen at a time (a second start
1126
+ // joins the walk already running); each answer is sent the moment it is given and the next card shows as soon as the
1127
+ // server took it; a line typed while an answer is still on its way is never applied to the card just answered; Esc or any
1128
+ // command ends the walk with a summary; an id answered in this window never comes back, and a list held from earlier
1129
+ // (a bulk card, a range) is re-read from the server before its first card.
1130
+ const WALK_TTL = 1800000;
1131
+ const walkAnsweredIds = () => (R.current.walkAnswered ||= new Set());
1132
+ const walkCloseCard = (W, why = 'next') => { if (W?.card) { W.card.at = Date.now(); W.card.walkWhy = why; W.card = null; } }; // why: next/done settle silently; stop = interrupted
1133
+ const walkKeysText = (it) => { const ks = it.payload.options.map(o => o.key); return ks.length > 1 ? `${ks.slice(0, -1).join(', ')} or ${ks[ks.length - 1]}` : ks.join(''); };
1134
+ // EXPIRED-ASK (owner 2026-10-17 21:23): a question whose time ran out is gone: say so plainly and move on, never "the question stays".
1135
+ const askExpiry = (it) => { const t = Date.parse(it?.due_at ?? it?.payload?.expires_at); return Number.isFinite(t) ? t : null; };
1136
+ const expiredText = (it) => { const t = askExpiry(it); return t ? `That question expired at ${askClock(t)}, so there's nothing to answer.` : "That question is no longer open, so there's nothing to answer."; };
1137
+ const walkGoneElsewhere = (W, id) => { const o = R.current.openIds; return Boolean(o && o.at > W.startedAt && !o.ids.has(id)); };
1138
+ const walkSummaryLines = (W, head) => {
1139
+ const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null;
1140
+ const left = W.ids.filter(id => !W.done.has(id) && id !== sending).map(id => W.items[id].title || id);
1141
+ const lines = walkSummary({ answered: W.answered, skipped: W.skipped.map(id => W.items[id].title || id), left });
1142
+ if (head) lines[0] = lines[0].replace(/^Walkthrough done/, head);
1143
+ if (sending) lines.push(` … still sending: ${W.items[sending].title || sending}`);
1144
+ return { lines, open: left.length + W.skipped.length };
1145
+ };
1146
+ const walkEnd = (why) => { // Esc, a command, or a card left too long: the walk stops here and says where it stands
1147
+ const W = R.current.pendingWalk; if (!W) return false;
1148
+ R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); walkCloseCard(W, 'stop');
1149
+ const { lines, open } = walkSummaryLines(W, `Walkthrough stopped (${why})`);
1150
+ say(' ');
1151
+ lines.forEach((l, k) => say(l, k === 0 ? 'ok' : undefined));
1152
+ if (open) say('Nothing else was sent. /walkthrough picks up what is still waiting.', 'dim');
1153
+ queueMicrotask(() => reconcileAsks());
1154
+ return true;
1155
+ };
1156
+ const walkShow = () => {
1157
+ endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
1158
+ const W = R.current.pendingWalk; if (!W) return;
1159
+ walkCloseCard(W); // the card just answered or skipped settles; only the next one is open
1160
+ const gone = walkAnsweredIds();
1161
+ let nextId = null;
1162
+ for (const id of W.ids) {
1163
+ if (W.done.has(id)) continue;
1164
+ if (gone.has(id)) { W.done.add(id); continue; } // answered in this window already: never shown again
1165
+ { const ex = askExpiry(W.items[id]); if (ex && ex <= Date.now()) { W.done.add(id); (W.dropped ||= new Set()).add(id); refreshNotify(); say(`"${clip(W.items[id].title || id, 60)}": ${expiredText(W.items[id])}`, 'accent'); continue; } }
1166
+ if (walkGoneElsewhere(W, id)) { W.done.add(id); say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
1167
+ nextId = id; break;
1168
+ }
1169
+ if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); queueMicrotask(() => reconcileAsks()); return; }
1170
+ const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
1171
+ const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
1172
+ W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
1173
+ say(' ');
1174
+ c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
1175
+ ask('Your choice, with an optional note', { who: it.payload.session || '', action: '', reason: '', keys: '· Esc stops', plain: true });
1176
+ };
1177
+ const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
1178
+ let decs = (list || []).filter(isDecision);
1179
+ if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
1180
+ try { const open = new Set((await api.approvals()).map(x => x.id)); decs = decs.filter(x => open.has(x.id)); } catch { /* slow server: the answered-in-this-window set below still applies */ }
1181
+ }
1182
+ const gone = walkAnsweredIds();
1183
+ decs = decs.filter(x => !gone.has(x.id));
1184
+ const W0 = R.current.pendingWalk;
1185
+ if (W0) { // one walk at a time: a second start joins the walk on screen, never a second card
1186
+ const add = decs.filter(x => !W0.ids.includes(x.id));
1187
+ for (const x of add) { W0.ids.push(x.id); W0.items[x.id] = x; }
1188
+ if (add.length) say(`${add.length} more question${add.length === 1 ? '' : 's'} added to this walkthrough (now ${W0.ids.length}).`, 'dim');
1189
+ return undefined;
1190
+ }
1191
+ if (!decs.length) return say('No question is waiting for you.');
1192
+ R.current.pendingWalk = { ids: decs.map(x => x.id), items: Object.fromEntries(decs.map(x => [x.id, x])), done: new Set(), answered: [], skipped: [], current: null, card: null, at: Date.now(), startedAt: Date.now() };
1193
+ if (!quiet) say(`${decs.length} question${decs.length === 1 ? '' : 's'} waiting for you. One at a time; answer with a choice, then an optional note; Esc stops. Risky ones (high risk, test changes, restarts) always come here, one at a time.`, 'dim'); // /approvals already said it once in its own header
1194
+ walkShow();
1195
+ return undefined;
1196
+ };
1197
+ // NOTE-CONFIRM (transcript review 2026-10-17): an answer with a note can contradict its option, so every note is confirmed first, unless it starts with "fyi".
1198
+ const noteNeedsConfirm = (note) => { const t = String(note ?? '').trim(); return Boolean(t && !/^fyi\b/i.test(t) && /\b(not|no|never|without|only|except|instead|unless|limit\w*|max\w*|at most|at least)\b|n't\b|\d/i.test(t)); }; // only a note that could change the choice asks first; a plain comment goes through with the note attached
1199
+ const noteCard = (st) => {
1200
+ say(' ');
1201
+ say(st.batch ? `You are answering ${st.count} questions (${st.label}), and your note says: "${clip(st.note, Math.max(30, cols - 50))}".` : `You chose (${st.key}) ${st.label}, and your note says: "${clip(st.note, Math.max(30, cols - 40))}".`, 'warn');
1202
+ say(`A note can change what you mean${st.batch ? ', and this one would go with every answer' : ''}. Send it as:`);
1203
+ say(` [1] the note wins (recommended) [2] the option${st.batch ? 's' : ''} as written${st.batch ? '' : `, (${st.key}) ${clip(st.label, 40)}`} [3] edit`);
1204
+ say(' y or Enter sends 1 · n sends 2 · e edits');
1205
+ R.current.noteAsk = { ...st, at: Date.now() };
1206
+ bump(n => n + 1);
1207
+ };
1208
+ const noteResolve = async (choice, st) => {
1209
+ if (choice === '3') { edit({ type: 'set', text: st.walk ? `${st.key} ${st.note}` : `/answer ${st.cmd} "${st.note}"` }); return; }
1210
+ const note2 = choice === '1' ? `[note overrides the option as written (${st.label})] ${st.note}` : `[sent as the option as written; the note did not change it] ${st.note}`;
1211
+ R.current.noteBypass = true;
1212
+ try { if (st.walk) await walkHandle(`${st.key} ${note2}`); else await command(`/answer ${st.cmd} "${note2}"`); }
1213
+ finally { R.current.noteBypass = false; }
1214
+ };
1215
+ const walkHandle = async (text) => {
1216
+ const W = R.current.pendingWalk; const it = W?.items[W.current];
1217
+ if (!it) { R.current.pendingWalk = null; return false; }
1218
+ dispatch({ type: 'walk.reply', text, id: it.id }); // the transcript keeps what was typed at each card
1219
+ if (R.current.walkSend) { // the last answer is still on its way: this line must not land on the card just answered
1220
+ edit({ type: 'set', text });
1221
+ say(`Still sending your answer to "${clip(it.title || it.id, 60)}". Your line is kept in the box; press Enter when the next card shows.`, 'warn');
1222
+ return true;
1223
+ }
1224
+ if (Date.now() - W.at >= WALK_TTL) { say('That card sat open for 30 minutes, so nothing was sent. Here it is again:', 'warn'); walkShow(); return true; }
1225
+ const rep = walkReply(it, text);
1226
+ if (!rep) { say(walkNoCatch(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1227
+ if (rep.notYesNo) { say(walkNotYesNo(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1228
+ if (rep.defer) { W.done.add(it.id); W.skipped.push(it.id); walkShow(); return true; }
1229
+ if (rep.ask) { W.at = Date.now(); submit(`Explain this decision to me in two plain sentences and say what you would do, about THIS one only. Title: ${it.title}. Asked by: ${it.payload.session || 'a session'}. Why: ${it.payload.why || 'not given'}. Options: ${it.payload.options.map(o => `${o.label} (${o.key})`).join('; ')}. Decision id: ${it.id}.`); return true; }
1230
+ const n = S.current.inbox.findIndex(x => x.id === it.id) + 1; // routed by id; the position is only looked up at the moment of sending
1231
+ if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
1232
+ if (askExpiry(it) && askExpiry(it) <= Date.now()) { W.done.add(it.id); (W.dropped ||= new Set()).add(it.id); refreshNotify(); say(expiredText(it), 'accent'); walkShow(); return true; }
1233
+ if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
1234
+ const o = it.payload.options.find(x => x.key === rep.key);
1235
+ noteCard({ walk: true, key: rep.key, label: o?.label || rep.key, note: rep.note, id: it.id });
1236
+ return true;
1237
+ }
1238
+ R.current.lastAnswered = null;
1239
+ R.current.walkSend = true;
1240
+ try { await command(`/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
1241
+ if (R.current.lastAnswered === it.id) {
1242
+ walkAnsweredIds().add(it.id);
1243
+ W.done.add(it.id); W.answered.push({ title: it.title, word: `${it.payload.options.find(o => o.key === rep.key).label}${rep.note ? ` (note: ${rep.note.length} characters)` : ''}` });
1244
+ if (R.current.pendingWalk === W) walkShow(); // stopped while it was sending: the answer stands, no further card
1245
+ } else if (R.current.goneAsk === it.id) { // the server says it is gone: nothing to answer, on to the next card
1246
+ R.current.goneAsk = null; W.done.add(it.id); walkAnsweredIds().add(it.id); refreshNotify();
1247
+ if (R.current.pendingWalk === W) walkShow();
1248
+ } else if (R.current.pendingWalk === W) { say('Not answered; the question stays. Say a choice again, s to skip, or Esc to stop.', 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; }
1249
+ return true;
1250
+ };
1251
+ const answer = async (card, action, { proactive = false, batch = false, reason = null, bulk = false } = {}) => {
352
1252
  if (R.current.answering?.has(card.callId)) return;
353
1253
  (R.current.answering ||= new Set()).add(card.callId);
354
1254
  try {
355
- await api.respond(card.inboxItemId, action);
356
- dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId });
357
- if (action === 'approve') pollResult({ callId: card.callId, approvalId: card.approvalId });
358
- } catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); }
1255
+ if (!card.inboxItemId && card.approvalId && !batch) { // an event card that never learned its inbox id: find it, never answer null
1256
+ try { card = { ...card, inboxItemId: (await api.approvals()).find(i => (i.payload?.approval_id || i.approval_id || i.aee_correlation_id) === card.approvalId)?.id || null }; } catch { /* falls through to the 404 path */ }
1257
+ }
1258
+ await respondWithStepUp(card, action, { proactive, reason, bulk });
1259
+ // The demo's stream carries on to its own tool result, so no "running, result later" note, no poll, no stale count.
1260
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
1261
+ if (demoLabel) { B.current = initialBuffer(); dispatch({ type: 'notify', patch: { approvals: 0, error: null } }); } // (a stray early keystroke is not left in the composer)
1262
+ if (action === 'approve' && card.tool !== 'hat.switch' && !card.foreign && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1263
+ return { ok: true };
1264
+ } catch (e) {
1265
+ if (e?.status === 404 || (e?.status === 400 && /not open for response/i.test(`${e?.code || ''} ${e?.body?.error || ''} ${e?.message || ''}`))) { // already resolved elsewhere (another window, /approve all, the dashboard)
1266
+ if (!batch) dispatch({ type: 'approval.gone', callId: card.callId, inboxItemId: card.inboxItemId || null });
1267
+ return { gone: true };
1268
+ }
1269
+ if (batch) throw e;
1270
+ dispatch({ type: 'error', text: `approval: ${e.message}` });
1271
+ }
359
1272
  finally { R.current.answering.delete(card.callId); }
360
1273
  };
361
1274
 
362
1275
  // ---- turn runner ---------------------------------------------------------------------------
363
- async function runTurn(text) {
364
- if (blocked) { say(blocked, 'warn'); return; }
1276
+ // Z2H-R2B: with no AI connected a turn is answered here, whole and wrapped, without a server call.
1277
+ async function aiConnected() {
1278
+ if (!api.connections) return null;
1279
+ try {
1280
+ const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
1281
+ return anyProviderConnected(vault, caps);
1282
+ } catch { return null; }
1283
+ }
1284
+ // Z2H-3B: a successful /connect makes the new provider the active choice when the current one is not a working
1285
+ // connected provider (session + the saved default). Another working provider stays active.
1286
+ async function adoptConnected(id, { host = null, model = null } = {}) {
1287
+ try {
1288
+ if (!api.connections) return;
1289
+ const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
1290
+ const a = adoptAfterConnect({ vault, caps, current: MC.current, id, host, model });
1291
+ if (!a.adopt) return;
1292
+ MC.current = a.choice; api.setModelChoice?.(a.choice);
1293
+ R.current.noAi = false;
1294
+ dispatch({ type: 'ctx', ctx: { provider: null, model: null, endpoint: null, auth: null } });
1295
+ say(a.line, 'ok');
1296
+ bump(n => n + 1);
1297
+ } catch { /* the connect itself succeeded; the hint line already says /model */ }
1298
+ }
1299
+ async function noAiAnswer(text) {
1300
+ if (R.current.noAi === false) return false;
1301
+ const st = await aiConnected();
1302
+ R.current.noAi = st === false ? true : st === true ? false : undefined;
1303
+ if (!R.current.noAi) { bump(n => n + 1); return false; }
1304
+ if (asksAddHost(text)) for (const l of await hostLines()) say(l);
1305
+ else if (asksWatchHosts(text)) {
1306
+ // Z2H-R4: watching starts with a host on the list; that part needs no AI, the answers about it do.
1307
+ for (const l of await hostLines()) say(l);
1308
+ for (const l of NO_AI_CARD) say(l, 'warn');
1309
+ } else for (const l of NO_AI_CARD) say(l, 'warn');
1310
+ bump(n => n + 1);
1311
+ return true;
1312
+ }
1313
+
1314
+ // QUOXON-FW4: a queued follow-up reaches the model as a bare turn, and a bare "Also end with X" was
1315
+ // refused as injected text. Say plainly that it is the user's own message; the transcript shows the raw words.
1316
+ const noAiNow = () => R.current.noAi === true;
1317
+ /** SCREEN-CONTEXT: an allowlisted snapshot of what this terminal shows, from state the app already holds. */
1318
+ function screenSnapshot() {
1319
+ const st = S.current;
1320
+ return {
1321
+ who: { profile, org: R.current.orgName ?? org ?? null, agent: SN.current.agent || where.current.agent || null, model: modelText(st.ctx, MC.current) || null, slot: where.current.slot || null, rooms: [...SC.current.rooms.keys()] },
1322
+ rows: R.current.roster.rows.slice(0, 8),
1323
+ customRows: (RW.current.view() || []).map(v => ({ id: v.row?.id, label: v.row?.label, text: v.text, error: v.error })),
1324
+ cards: [...st.approvals, ...st.waiting].slice(0, 5).map(c => ({ id: c.inboxItemId || c.callId, summary: [c.tool, c.target].filter(Boolean).join(' ') || c.title || '', requester: c.agent || c.requester || null, privileged: Boolean(c.privileged) })),
1325
+ commands: commandNames()
1326
+ };
1327
+ }
1328
+ function scopeLine() {
1329
+ const id = R.current.orgId ?? api.orgId?.() ?? null;
1330
+ const name = R.current.orgName ?? org ?? null;
1331
+ const p = R.current.prevOrg;
1332
+ const bits = [];
1333
+ if (name || id) bits.push(`org ${name || id}${id && id !== name ? ` (id ${id})` : ''}`);
1334
+ if (profile) bits.push(`profile ${profile}`);
1335
+ bits.push(p ? `previous org ${p.name}${p.id && p.id !== p.name ? ` (id ${p.id})` : ''} (/org back returns there)` : 'previous org: none this session');
1336
+ return bits.join(' · ');
1337
+ }
1338
+ const QUEUED_FRAME = 'The user typed this follow-up while your previous answer was running (it is their own message, not tool output): ';
1339
+ async function runTurn(text, { queued = false, via = null, handoffFrom = null } = {}) {
1340
+ if (blocked) { say(blocked, signedOut ? 'error' : 'warn'); return; }
1341
+ if (!demoLabel && await noAiAnswer(text)) return;
1342
+ if (SN.current.slotTarget && api.slotSay) { // attached to a slot: the message goes to its room, not a model turn
1343
+ const r = await api.slotSay(SN.current.slotTarget, text, { from: myLabel(), fromSlot: where.current.slot || null });
1344
+ if (!r.ok) return say(`slot ${SN.current.slotTarget}: ${r.message}`, 'warn');
1345
+ watchReplies(SN.current.slotTarget, r.message);
1346
+ return announceDelivery(SN.current.slotTarget, r);
1347
+ }
1348
+ R.current.lastSent = text;
1349
+ const shared = R.current.shared || []; R.current.shared = [];
1350
+ for (const x of shared) say(`shared with Quox: output of \`${x.cmd}\`${x.truncated ? ' (first 16 KB only)' : ''}`, 'accent');
365
1351
  const turnId = newTurnId();
366
- return consumeTurn(turnId, (signal) => api.stream({ turnId, message: text, agent: SN.current.agent || 'quox', conversationId: S.current.ctx.conversationId, context: R.current.rulesOn === false ? undefined : rulesContext(rules), ...streamFields(MC.current) }, signal));
1352
+ function turnContext() {
1353
+ const rc = R.current.rulesOn === false ? undefined : rulesContext(rules);
1354
+ const blk = noAiNow() ? null : SCX.current.build(screenSnapshot());
1355
+ if (blk) {
1356
+ R.current.screenSent = blk;
1357
+ transcript?.action({ type: 'screen.sent', text: `terminal context ${blk.bytes} bytes: ${blk.sections.join(', ') || 'nothing new'}` });
1358
+ }
1359
+ // Owner 2026-10-15 "u should always know what scope it is set to": every turn names the current org (name and
1360
+ // id), the profile and the previous org, even with /context off, so "which org am I in" and "switch back" are
1361
+ // answered from this, never from history.
1362
+ const scope = scopeLine();
1363
+ if (!blk?.text) return scope ? { ...(rc || {}), scope } : rc;
1364
+ return { ...(rc || {}), terminal: blk.text, ...(scope ? { scope } : {}) };
1365
+ }
1366
+ const open = (signal) => api.stream({ turnId, message: withShared(queued ? QUEUED_FRAME + text : text, shared), agent: via || SN.current.agent || 'quox', conversationId: S.current.ctx.conversationId, ...(handoffFrom ? { handoffFrom } : {}), heldSlot: where.current.slot ? { name: where.current.slot, session: slotSession(), display: myDisplay() } : null, context: turnContext(), overrideId: takeOverride(), ...(MD.current === 'plan' ? { mode: 'plan' } : {}), ...streamFields(MC.current) }, signal);
1367
+ // QUOXON-FW3: the server was unreachable before it said anything (a redeploy). Keep the message, wait for it
1368
+ // to come back, and send once. Only a send that produced NO event is ever retried, so nothing runs twice.
1369
+ let r = await consumeTurn(turnId, open, { keepOnDown: true });
1370
+ if (!r?.down) return r;
1371
+ say(SERVER_RESTARTING, 'warn');
1372
+ for (let round = 0; r?.down && round < 3; round++) {
1373
+ const ctrl = new AbortController();
1374
+ R.current.abort = ctrl; R.current.waitingServer = { text };
1375
+ const res = await waitForServer({ health: (ms) => api.health(ms), signal: ctrl.signal, delays: resendDelays });
1376
+ R.current.waitingServer = null; R.current.abort = null;
1377
+ if (res !== 'up') {
1378
+ dispatch({ type: 'turn.deferred' });
1379
+ edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
1380
+ return say(res === 'cancelled' ? 'Cancelled. Your message is back in the composer; it was not sent.' : 'The Quox server did not come back in time. Your message is back in the composer; it was not sent.', 'warn');
1381
+ }
1382
+ r = await consumeTurn(turnId, open, { keepOnDown: true });
1383
+ }
1384
+ if (r?.down) { dispatch({ type: 'turn.deferred' }); edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text }); say('The server keeps dropping the connection. Your message is back in the composer; it was not sent.', 'warn'); }
1385
+ return r;
367
1386
  }
368
1387
 
369
1388
  // QUOXON C5: render a turn that is already running on the server (after /resume, --conversation or a
@@ -379,23 +1398,72 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
379
1398
  if (r?.running && r.turnId && !S.current.turn && S.current.ctx.conversationId === conversationId) followTurn(conversationId, r.turnId);
380
1399
  }
381
1400
 
382
- async function consumeTurn(turnId, open) {
1401
+ // The assistant handed us a slash command (terminal_suggest_command). Vet it here; the y/N answer runs it as if typed.
1402
+ function offerSuggestion(ev) {
1403
+ const seen = (R.current.suggestSeen ||= new Set());
1404
+ if (ev.call_id && seen.has(ev.call_id)) return; // a replayed event must not ask twice
1405
+ if (ev.call_id) seen.add(ev.call_id);
1406
+ const v = vetSuggestion(ev.suggest_command);
1407
+ if (!v.ok) { say(`Not offered: ${clip(String(ev.suggest_command).slice(0, 60), 60)} (${v.reason}).`, 'warn'); return; }
1408
+ if (v.readOnly && ev.suggest_explicit === true) { say(`ran ${v.line}`, 'dim'); command(v.line); return; }
1409
+ R.current.suggestAsk = { line: v.line, at: Date.now() };
1410
+ // Asked AFTER the answer, last thing on screen (owner 2026-10-15: mid-stream it scrolled away above the reply).
1411
+ R.current.suggestLine = askText(`Run ${clip(v.line, cols - 20)}?`, { who: askerName(), action: v.line, reason: ev.suggest_reason ? String(ev.suggest_reason).replace(/[.\s]+$/, '') : '' });
1412
+ }
1413
+ // QUOXON-ASK safety net: the answer named ONE runnable command but the model did not offer it: offer it here, same ask.
1414
+ function offerNamed(text) {
1415
+ const v = namedCommand(text);
1416
+ if (!v) return;
1417
+ R.current.suggestAsk = { line: v.line, at: Date.now() };
1418
+ R.current.suggestLine = askText(`Run ${clip(v.line, cols - 20)}?`, { who: askerName(), action: v.line, reason: 'Quox named this command in its answer' });
1419
+ }
1420
+ function flushSuggestion() {
1421
+ const t = R.current.suggestLine; if (!t) return;
1422
+ R.current.suggestLine = null;
1423
+ if (R.current.suggestAsk) R.current.suggestAsk.at = Date.now(); // the answer window starts when it is shown
1424
+ say(t, 'ask');
1425
+ }
1426
+
1427
+ // QUOXON handoff offer: the SAME question to the suggested agent for this one turn; the window stays attached.
1428
+ async function runHandoff(a) {
1429
+ say(`↪ Asking ${a.h.name} this one: ${clip(a.text, Math.max(20, cols - 30))}`, 'dim');
1430
+ R.current.handoffTurn = true; // the handed-over turn answers without a suggestion; that must not break the streak
1431
+ try { await runTurn(a.text, { via: a.h.agent, handoffFrom: a.h.from }); } finally { R.current.handoffTurn = false; }
1432
+ say(handoffAnsweredNote(a.h), 'dim');
1433
+ bump(n => n + 1);
1434
+ }
1435
+ async function consumeTurn(turnId, open, { keepOnDown = false } = {}) {
383
1436
  R.current.caps.steer = true; R.current.steerNoted = false; // steer support is learned per turn (provider paths differ)
384
1437
  R.current.durable = false; // learned from the server: v1.1 events carry a seq
385
1438
  dispatch({ type: 'turn.begin', turnId });
386
1439
  const ctrl = new AbortController();
387
1440
  R.current.abort = ctrl;
388
- let offered = false;
1441
+ let offered = false, sawEvent = false, down = false;
1442
+ let answerText = '', suggested = false, gate = null; // the safety net reads the answer when no suggestion came
389
1443
  try {
390
1444
  for await (const ev of open(ctrl.signal)) {
391
1445
  if (ctrl.signal.aborted) break;
1446
+ sawEvent = true;
392
1447
  if (typeof ev?.seq === 'number') R.current.durable = true;
393
1448
  LG.current.observe(ev);
394
1449
  dispatch({ type: 'event', ev });
1450
+ if (ev.type === 'tool.finished' && ev.suggest_command) { suggested = true; offerSuggestion(ev); }
1451
+ if (ev.type === 'turn.handoff_suggested') { const ho = handoffFromEvent(ev, SN.current.agent); if (ho) R.current.handoffPending = { h: ho, text: R.current.lastSent || '' }; } // QUOXON handoff offer
1452
+ if (ev.type === 'chunk' || ev.type === 'text.delta') { const g = textGate(gate, ev); gate = g.st; if (g.append && answerText.length < 20000) answerText += String(ev.content); }
1453
+ // Z2H-3B: a server default that is not connected must not linger in the status bar.
1454
+ if (ev.type === 'error' && /No AI is connected/i.test(String(ev.error || ev.message || ''))) {
1455
+ R.current.noAi = true;
1456
+ dispatch({ type: 'ctx', ctx: { provider: null, model: null, endpoint: null, auth: null } });
1457
+ }
395
1458
  const fb = fallbackNote(MC.current, ev);
396
1459
  if (fb) say(fb, 'warn');
397
1460
  const offer = connectOfferFor(ev);
398
1461
  if (offer && !offered) { offered = true; say(offer); }
1462
+ if (isConsentError(ev)) { // the words are in the error block; here the link and the way back
1463
+ R.current.consent = ev.retryable ? null : { provider: ev.provider || null, text: R.current.lastSent || null };
1464
+ let base = null; try { base = await api.dashboardBase?.(); } catch { base = null; }
1465
+ for (const l of consentHintLines(ev, base)) say(l, 'dim');
1466
+ }
399
1467
  }
400
1468
  if (S.current.turn?.id === turnId) {
401
1469
  // stream closed without a terminal event
@@ -403,36 +1471,113 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
403
1471
  dispatch({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
404
1472
  }
405
1473
  } catch (e) {
406
- if (!ctrl.signal.aborted) {
407
- dispatch({ type: 'event', ev: { type: 'error', error: friendlyError(e, profile) } });
1474
+ if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
1475
+ else if (!ctrl.signal.aborted) {
1476
+ // FW4: settle in-flight tools explicitly (never leave a row "running"), then explain the cut with a way forward.
1477
+ if (e?.status === 423 && typeof e?.body?.block_id === 'string') { // QUOXON-STEPUP: a policy block; a person may override it once
1478
+ R.current.lastBlock = { blockId: e.body.block_id, agentId: e.body.safety?.agent || null, text: String(e.body.message || e.message || '').slice(0, 200) };
1479
+ say(`Blocked by policy (${e.body.block_id}). A person can override this one action: /override last <reason>`, 'warn');
1480
+ }
1481
+ // TUI-DIVIDER: a refusal before the stream opened names the layers it really evaluated; the rule lights them.
1482
+ if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers) dispatch({ type: 'event', ev: { type: 'aocl.layer', ...l } });
1483
+ const why = sawEvent ? explainTermination(e) : null;
1484
+ if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running') dispatch({ type: 'event', ev: { type: 'tool.finished', call_id: b.callId, tool: b.tool, status: 'interrupted', result_summary: 'connection lost before a result; verify before retrying' } });
1485
+ const dead = !why && (e?.status === 401 || isSessionDead(e));
1486
+ dispatch({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
1487
+ if (dead) markSignedOut(e?.reason || null);
1488
+ if (why) say(RETRY_HINT, 'warn');
408
1489
  // A lost reattach is not a failed turn: the server may still be finishing it (C5).
409
1490
  if (S.current.turn?.id === turnId) dispatch({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
410
1491
  }
411
1492
  } finally {
412
1493
  clearTimeout(R.current.fallbackTimer);
413
1494
  LG.current.end();
414
- R.current.abort = null;
415
- if (S.current.turn?.id === turnId) dispatch({ type: 'stopped.local' });
1495
+ if (!down) R.current.abort = null;
1496
+ if (!down && S.current.turn?.id === turnId) dispatch({ type: 'stopped.local' });
1497
+ const planDone = MD.current === 'plan' && !down && !ctrl.signal.aborted && Boolean(answerText);
1498
+ if (!suggested && !down && !ctrl.signal.aborted && answerText && MD.current !== 'plan') offerNamed(answerText); // plan mode: the plan card below is the one next step
1499
+ // QUOXON handoff offer: the attached agent lacks the tools for this ask. The handoff card answers the actual question,
1500
+ // so it wins over the agent's own suggested command (owner live 22:42: "Run /vm list?" took the slot and the handoff
1501
+ // card was dropped silently). Nothing is dropped silently: a superseded or skipped card says so in one line.
1502
+ const hp = R.current.handoffPending; R.current.handoffPending = null;
1503
+ const hpOk = Boolean(hp && !down && !ctrl.signal.aborted && hp.text);
1504
+ if (hpOk && !planDone && R.current.suggestLine) {
1505
+ const line = R.current.suggestAsk?.line || '';
1506
+ R.current.suggestLine = null; R.current.suggestAsk = null;
1507
+ say(handoffSupersededNote(hp.h, line), 'dim');
1508
+ }
1509
+ const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone);
1510
+ flushSuggestion();
1511
+ if (hpOk && planDone) say(handoffSkippedNote(hp.h), 'dim');
1512
+ else if (hpOk) {
1513
+ const hs = (R.current.handoffStreak ??= createHandoffStreak()).offered(hp.h.from);
1514
+ if (hs.escalate) { R.current.handoffAsk = { ...hp, at: Date.now(), bigger: true, count: hs.count }; say(handoffBiggerAskText(hp.h, hs.count), 'ask'); } // same agent, 2+ turns in a row: offer to move the window
1515
+ else { R.current.handoffAsk = { ...hp, at: Date.now() }; say(handoffAskText(hp.h, hp.text, cols), 'ask'); }
1516
+ }
1517
+ if (!hpOk && !down && !ctrl.signal.aborted && !R.current.handoffTurn) R.current.handoffStreak?.clear(); // a turn with no suggestion breaks the streak
1518
+ if (planDone) { R.current.planAsk = { at: Date.now() }; ask('Run this plan now?', { who: askerName(), action: `switch to /mode ask and send "${PLAN_GO}"` }); }
1519
+ if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
1520
+ const ra = R.current.reask; R.current.reask = null; // y/N/c: the chat about an ask is over: the same ask again, fresh window
1521
+ if (ra && !newOffer && askOpen()) { touchAsks(); if (ra.redo) ra.redo(); else say(ra.text, 'ask'); }
416
1522
  }
1523
+ return down ? { down: true } : undefined;
417
1524
  }
418
1525
 
419
1526
  React.useEffect(() => {
1527
+ if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length) wakeDragon(); // an approval always wakes it
420
1528
  const s = S.current;
421
1529
  if (!s.turn && s.autoDrain && s.pending.length) {
422
1530
  const next = s.pending[0];
423
1531
  dispatch({ type: 'queue.shift' });
424
- dispatch({ type: 'user', text: next.text, queued: true });
425
- runTurn(next.text);
1532
+ runQueued(next.text);
426
1533
  }
427
1534
  });
428
1535
 
1536
+ // A queued or steered draft that is a slash command runs as that command when its turn comes; it is never sent as chat.
1537
+ function runQueued(text, { checked = false } = {}) {
1538
+ if (text.startsWith('/') && !text.includes('\n')) { command(text); return; }
1539
+ const stray = checked ? null : strayCardAnswer(text, commandNames()); // TUI-INPUT: "n/help" = a card answer that leaked into the composer
1540
+ if (stray) { R.current.strayAsk = { text, command: stray.command, at: Date.now() }; ask(strayQuestion(stray)); return; }
1541
+ dispatch({ type: 'user', text, queued: true });
1542
+ runTurn(text, { queued: true });
1543
+ }
1544
+
1545
+ function echoCmd(text) { // a command the human typed (or confirmed): shown like any prompt line, before its output
1546
+ const e = echoLine(text, { cols });
1547
+ dispatch({ type: 'user', text: e.shown, full: e.full, echo: true });
1548
+ }
1549
+
1550
+ // PRESENCE (owner 2026-10-16): /afk and /sleep tell the server, so sessions and LLMs know you are not at this
1551
+ // terminal (approvals still queue; nothing is approved). /back or any typed turn marks you present.
1552
+ async function setPresence(state, note) {
1553
+ if (!api.setPresence) return { ok: true, local: true }; // demo / offline: the local mark is all there is
1554
+ try { return await api.setPresence(state, { note: note || undefined, slots: where.current.slot ? [where.current.slot] : undefined }); }
1555
+ catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
1556
+ }
1557
+ const presenceFail = (r) => say(`Sessions can't see that (${r.status ? `HTTP ${r.status}` : r.message || 'failed'}): this window still collects for /back, but other sessions do not know.`, 'warn');
1558
+ function markPresent(action = 'chat') {
1559
+ if (/^\/(afk|sleep|back|away)(\s|$)/.test(String(action).replace(/^slash /, ''))) return;
1560
+ if (AWAY.current.afk) { // this window set the flag: a typed turn here is the owner back (explicit, as before)
1561
+ AWAY.current.afk = null;
1562
+ setPresence('present').then((r) => { if (!r.ok) presenceFail(r); }, () => {});
1563
+ return;
1564
+ }
1565
+ // PRESENCE (owner 2026-10-19): a window that never set the flag (restarted TUI) still clears the server's stale one,
1566
+ // but only from a real terminal (api.noteActivity is a no-op for --print, pipes and proof runs). Welcome shows once.
1567
+ if (!api.noteActivity) return;
1568
+ api.noteActivity(action).then((r) => {
1569
+ if (r?.welcome?.away_text && !AWAY.current.welcomed) { AWAY.current.welcomed = true; say(`✔ Welcome back, you were away ${r.welcome.away_text}`, 'ok'); }
1570
+ }, () => {});
1571
+ }
429
1572
  function submit(text) {
1573
+ touchSeen();
1574
+ markPresent(/^\//.test(text.trim()) ? `slash ${text.trim().split(/\s+/)[0]}` : 'chat');
430
1575
  dispatch({ type: 'user', text });
431
1576
  const tip = intentHint(text); // a hint only: the turn still goes to the model
432
1577
  if (tip) say(tip);
433
1578
  const panel = intentPanel(text); // the same panel as the command, from local facts, above the answer
434
1579
  if (panel) showPanel(panel, { local: true });
435
- R.current.history.push(text);
1580
+ remember(text);
436
1581
  R.current.histIdx = -1;
437
1582
  runTurn(text);
438
1583
  }
@@ -460,6 +1605,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
460
1605
  }
461
1606
 
462
1607
  async function interrupt() {
1608
+ if (R.current.waitingServer) { R.current.abort?.abort(); return; } // QUOXON-FW3: Esc cancels the wait; runTurn restores the text
463
1609
  const s = S.current;
464
1610
  if (!s.turn || s.turn.status === 'interrupting') return;
465
1611
  dispatch({ type: 'interrupt.requested' });
@@ -482,13 +1628,42 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
482
1628
  say(`A turn is running. ${kind === 'restart' ? 'Restarting' : 'Leaving'} disconnects it, and the server stops it before its next step (a tool already running finishes and is recorded). /${kind === 'restart' ? 'restart' : 'quit'} again within 5 s to go ahead, or wait for the turn.`, 'warn');
483
1629
  return false;
484
1630
  };
1631
+ async function agentsPicker(filter) {
1632
+ if (!api.agents) return say('Agents are not available in this mode.', 'warn');
1633
+ let list;
1634
+ try { list = await api.agents(); } catch (e) { return say(`Couldn't check agents: ${listFailure(e, profile)}`, 'warn'); }
1635
+ const rows = filterAgents(list, filter);
1636
+ if (!rows.length) return say(filter ? `No agent matches "${filter}". /agents list shows them all.` : 'No agents found.', 'warn');
1637
+ PK.current.open({ title: filter ? `Agents matching "${filter}"` : 'Agents', rows: rows.map(agentPickerRow), hint: '↑↓ select · Enter show card · Esc cancel', onPick: (_, i) => command(`/agents ${rows[i].agent_id}`) });
1638
+ return undefined;
1639
+ }
1640
+ async function agentTools(name) {
1641
+ if (!api.objects?.agent) return say('Object views are not available in this mode.', 'warn');
1642
+ let r;
1643
+ try { r = await api.objects.agent(name); } catch (e) { return say(`agent: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}`, 'warn'); }
1644
+ if (r?.error) return say(clip(r.error, cols - 4), r.tone || 'warn');
1645
+ for (const [t, tone] of toolLines(r.data)) say(clip(t, cols - 4), tone === 'plain' ? undefined : tone);
1646
+ return undefined;
1647
+ }
1648
+ function showWhich(arg) {
1649
+ const sl = where.current.slot || null;
1650
+ const r = whichReply(arg, {
1651
+ profile, agentLogin, org: R.current.orgName ?? org ?? null, agent: SN.current.agent || where.current.agent,
1652
+ hat: S.current.ctx.hat, model: modelText(S.current.ctx, MC.current),
1653
+ slot: SN.current.slotTarget ? `${SN.current.slotTarget} (chatting)` : sl, slotLive: Boolean(sl && SC.current.listener?.state?.().connected),
1654
+ conversationId: S.current.ctx.conversationId, sessionName: SN.current.name
1655
+ });
1656
+ if (r.error) return say(r.error, 'warn');
1657
+ if (r.rows.length === 1) return say(`${r.rows[0][0]}: ${r.rows[0][1]}`);
1658
+ return help([{ kind: 'head', text: 'Which' }, ...r.rows.map(([key, text]) => ({ kind: 'row', key, text }))]);
1659
+ }
485
1660
  async function showPanel(cmd, { local = false } = {}) {
486
1661
  const base = { ctx: S.current.ctx, choice: MC.current, agent: SN.current.agent || where.current.agent, conversationId: S.current.ctx.conversationId };
487
1662
  if (cmd === '/cost') return help(costModel(LG.current));
488
1663
  if (cmd === '/status') {
489
1664
  let hl = R.current.lastHealth || null;
490
1665
  if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; }
491
- return help(statusModel({ ...base, profile, org: org || null, serverUrl: api.serverUrl?.() || hl?.url || null, health: hl, slot: where.current.slot, sessionName: SN.current.name, version, local: local && !hl }));
1666
+ return help(statusModel({ ...base, profile, org: orgShown(), serverUrl: api.serverUrl?.() || hl?.url || null, health: hl, slot: where.current.slot, sessionName: SN.current.name, version, local: local && !hl }));
492
1667
  }
493
1668
  if (cmd === '/context') {
494
1669
  let skills = R.current.skillsInfo || null; let memory = R.current.memoryInfo || null;
@@ -504,32 +1679,58 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
504
1679
  try { server = contextFacts(await api.conversationContext(base.conversationId)); }
505
1680
  catch (e) { serverError = e?.status === 404 ? 'no stored turns yet' : e?.status ? `HTTP ${e.status}` : (e?.message || 'failed'); }
506
1681
  }
507
- return help(contextModel({ ...base, server, serverError, tokens: LG.current.conversationTokens(S.current.ctx.conversationId), rules, rulesOn: R.current.rulesOn !== false, skills, memory }));
1682
+ return help(contextModel({ ...base, server, serverError, tokens: LG.current.conversationTokens(S.current.ctx.conversationId), rules, rulesOn: R.current.rulesOn !== false, skills, memory, screen: { on: SCX.current.on, last: R.current.screenSent || null } }));
508
1683
  }
509
1684
  return undefined;
510
1685
  }
511
- async function resumeTo(row) {
1686
+ /** gh #1157: a resume that meets a restarting collector says one calm line and retries with backoff (about 2 min); past the cap it rethrows marked gaveUp. */
1687
+ async function reconnecting(fn, st = {}) {
1688
+ for (let i = 0; ; i++) {
1689
+ try { return await fn(); } catch (e) {
1690
+ if (!isResumeRetryable(e) || R.current.closed) throw e;
1691
+ if (i >= resumeRetryDelays.length) { if (e && typeof e === 'object') e.reconnectGaveUp = true; throw e; }
1692
+ if (!st.noted) { st.noted = true; say(RESUME_RECONNECTING, 'dim'); }
1693
+ await new Promise(r => setTimeout(r, resumeRetryDelays[i]));
1694
+ if (R.current.closed) throw e;
1695
+ }
1696
+ }
1697
+ }
1698
+ function resumeGaveUp(id) { return say(`Quox server is still not answering after about 2 minutes. ${id ? `/resume ${id}` : '/resume'} to try again.`, 'warn'); }
1699
+ async function resumeTo(row, st = {}) {
512
1700
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
513
1701
  let turns;
514
- try { turns = await api.conversationTurns(row.id); } catch (e) { return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
1702
+ try { turns = await reconnecting(() => api.conversationTurns(row.id), st); } catch (e) { if (R.current.closed) return undefined; if (e?.reconnectGaveUp) return resumeGaveUp(row.id); if (e?.status === 404) return say('That conversation is not on the server any more (it keeps them 7 days).', 'warn'); return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
515
1703
  const t = turnsToHistory(turns);
516
1704
  dispatch({ type: 'ctx', ctx: { conversationId: row.id } });
517
1705
  SN.current.name = loadNames(namesPath)[row.id]?.name || null; SN.current.saved = null;
1706
+ if (!t.total && SN.current.name) SN.current.saved = `${row.id}|${SN.current.name}`; // nothing found: do not re-stamp the saved name
518
1707
  say(`Resumed "${clip(row.title, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
519
1708
  dispatch({ type: 'history.add', items: t.items, answers: t.answers });
1709
+ if (!t.total && agentLogin && row.byName) {
1710
+ const h = humanProfiles.find(x => x !== profile);
1711
+ return say(`${row.byName} was saved by another profile. Start with quox -p ${h || '<human profile>'} to resume it.`, 'warn');
1712
+ }
520
1713
  say(t.total ? `Showing the last ${t.shown} of ${t.total} stored messages. Your next message continues this conversation.` : 'The server has no stored messages for it (it keeps them 7 days). Your next message continues it anyway.', 'dim');
521
1714
  return reattachIfRunning(row.id);
522
1715
  }
523
1716
  async function resumeCmd(q) {
524
1717
  if (!api.conversations || !api.conversationTurns) return say('Resume is not available in this mode.', 'warn');
525
- let rows;
1718
+ let rows; const st = {};
526
1719
  try {
527
- const [list, index] = await Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]);
1720
+ const [list, index] = await reconnecting(() => Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]), st);
528
1721
  rows = resumeRows(list, index, loadNames(namesPath));
529
- } catch (e) { return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
1722
+ } catch (e) { if (R.current.closed) return undefined; if (e?.reconnectGaveUp) return resumeGaveUp(q ? String(q).trim() : null); return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
530
1723
  if (q) {
531
1724
  const m = matchConversation(rows, q);
532
- if (m.row) return resumeTo(m.row);
1725
+ if (m.row) return resumeTo(m.row, st);
1726
+ if (!m.ambiguous) {
1727
+ // Beyond the last 30 listed: a full id, or an exact saved local name, resumes directly.
1728
+ const names = loadNames(namesPath); const want = String(q).trim();
1729
+ if (/^QXC_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(want)) return resumeTo({ id: want, title: names[want]?.name || want, agent: null }, st);
1730
+ const hit = pickNamed(names, want, profile);
1731
+ if (hit?.ambiguous) return say(`"${q}" matches ${hit.ambiguous.length} conversations (saved by different profiles): ${hit.ambiguous.map(x => x.slice(0, 12)).join(', ')}. Use more of the id.`, 'warn');
1732
+ if (hit) return resumeTo({ id: hit.id, title: hit.entry.name, agent: null, byName: want }, st);
1733
+ }
533
1734
  return say(m.ambiguous ? `"${q}" matches ${m.ambiguous.length} conversations: ${m.ambiguous.map(x => x.slice(0, 12)).join(', ')}. Use more of the id.` : `No recent conversation matches "${q}". /resume lists them.`, 'warn');
534
1735
  }
535
1736
  if (!rows.length) return say('No recent conversations to resume.');
@@ -537,38 +1738,267 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
537
1738
  PK.current.open({ title: 'Resume a conversation', rows: rows.map(r => pickerRow(r, now)), hint: '↑↓ select · Enter resume · Esc cancel', onPick: (_, i) => resumeTo(rows[i]) });
538
1739
  return undefined;
539
1740
  }
1741
+ // ---- /dragon explain | ask | sleep (dragonModes.js) -----------------------------------------------
1742
+ const dragonTalkVisible = (now = Date.now()) => {
1743
+ const d = R.current.dragonTalk;
1744
+ return Boolean(d) && (d.armed || !d.endedAt || now - d.endedAt < 4000);
1745
+ };
1746
+ /** One governed turn on whichever provider is selected, framed for the simplest possible answer. */
1747
+ function dragonTurn(label, prompt) {
1748
+ const d = R.current.dragonTalk = { since: Date.now(), endedAt: null, armed: false, label };
1749
+ say(label, 'ok');
1750
+ Promise.resolve(runTurn(prompt)).catch(() => {}).finally(() => { d.endedAt = Date.now(); bump(n => n + 1); });
1751
+ bump(n => n + 1);
1752
+ return true;
1753
+ }
1754
+ function dragonExplain() {
1755
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
1756
+ const o = lastOutput(S.current);
1757
+ if (!o) return say(NOTHING_TO_EXPLAIN);
1758
+ return dragonTurn(DRAGON_EXPLAINS, explainPrompt(o.text));
1759
+ }
1760
+ function dragonAsk(question) {
1761
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
1762
+ if (!question) { R.current.askArmed = true; R.current.dragonTalk = { since: Date.now(), endedAt: Date.now(), armed: true, label: ASK_PROMPT }; bump(n => n + 1); return true; }
1763
+ dispatch({ type: 'user', text: question });
1764
+ return dragonTurn(DRAGON_ANSWERS, askPrompt(question));
1765
+ }
1766
+ function dragonSleep() {
1767
+ if (!isInteractive) return say('/dragon sleep needs an interactive terminal.', 'warn');
1768
+ if (S.current.approvals.length) return say('An approval is waiting. Answer it first; the dragon will not sleep over it.', 'warn');
1769
+ R.current.sleep = { startedAt: Date.now(), wakeAt: null, still: !motionOn || mono() };
1770
+ bump(n => n + 1); return true;
1771
+ }
1772
+ function dragonFly() { // one pass of the dragon in flight in the live region; Esc stops it, a new call restarts it
1773
+ R.current.fly = { startedAt: Date.now(), still: !motionOn || mono() };
1774
+ bump(n => n + 1); return true;
1775
+ }
1776
+ const flyActive = (now = Date.now()) => { const f = R.current.fly; return Boolean(f) && now - f.startedAt < (f.still ? FLY_STILL_MS : FLY_PASS_MS + 400); };
1777
+ function wakeDragon() {
1778
+ const sl = R.current.sleep;
1779
+ if (!sl || sl.wakeAt != null) return;
1780
+ sl.wakeAt = Date.now();
1781
+ const end = () => { R.current.sleep = null; bump(n => n + 1); };
1782
+ if (sl.still) end(); else R.current.wakeTimer = setTimeout(end, WAKE_MS + 60);
1783
+ bump(n => n + 1);
1784
+ }
1785
+
540
1786
  function openConfig() {
541
1787
  const list = settingsList();
542
1788
  const saved = api.settings ? api.settings() : (R.current.sessionSettings ||= {});
543
1789
  const vals = { verbose: S.current.verbose, ...saved, sound: SND.current.settings?.().profile };
544
- const set = (it, v) => {
1790
+ // R3 D2: changes are staged in the card; Enter saves them, Esc (or typing) discards them.
1791
+ let staged = {};
1792
+ const apply = (it, v) => {
545
1793
  vals[it.id] = v;
546
1794
  if (it.id === 'sound') { SND.current.command(v, say); return; } // the sound controller saves its own profile
547
1795
  const ok = api.saveSetting ? api.saveSetting(it.id, v) : ((R.current.sessionSettings ||= {})[it.id] = v, false);
548
1796
  if (it.id === 'verbose') dispatch({ type: 'verbose', mode: v });
549
- if (it.id === 'theme' || it.id === 'motion') {
550
- applySettings(vals, env);
551
- if (it.id === 'theme' && stdout?.isTTY !== false) { stdout.write('\x1b[2J\x1b[H'); setEpoch(e => e + 1); } // repaint history in the new look
552
- }
1797
+ // Theme and motion apply to what is drawn from now on; history already in scrollback keeps its look
1798
+ // (repainting it would print the whole transcript again).
1799
+ if (it.id === 'theme' || it.id === 'motion') applySettings(vals, env);
553
1800
  R.current.configSaved = ok;
554
1801
  };
1802
+ const commit = () => {
1803
+ const ids = Object.keys(staged);
1804
+ if (!ids.length) return;
1805
+ for (const id of ids) apply(list.find(x => x.id === id), staged[id]);
1806
+ const what = ids.map(id => `${id} ${staged[id]}`).join(', ');
1807
+ staged = {};
1808
+ say(`${api.saveSetting ? `Saved to profile ${profile}` : 'Applied for this session'}: ${what}.${ids.includes('theme') ? ' The theme applies to new output; earlier lines keep their colours.' : ''}`, 'ok');
1809
+ };
555
1810
  PK.current.open({
556
- title: api.saveSetting ? `Settings · saved to profile ${profile}` : 'Settings · this session only (no profile in this mode)',
557
- rowsFn: () => configRows(list, vals, { model: modelLabel(S.current.ctx, MC.current) || 'auto' }),
558
- hint: '↑↓ select · ←→ or Space change · Enter on Model opens /model · Esc close',
559
- onAdjust: (i, dir) => { const it = list[i]; if (!it.action) set(it, cycle(it, vals[it.id], dir)); },
560
- keepOpen: (i) => !list[i].action,
561
- onPick: (_, i) => { const it = list[i]; if (it.action === 'model') command('/model'); else set(it, cycle(it, vals[it.id], 1)); }
1811
+ title: api.saveSetting ? `Settings · Enter saves to profile ${profile} · Esc cancels` : 'Settings · Enter applies for this session · Esc cancels',
1812
+ rowsFn: () => configRows(list, { ...vals, ...staged }, { model: modelLabel(S.current.ctx, MC.current) || 'auto' }).map((r, i) => (list[i].id in staged ? { ...r, note: 'unsaved · Enter saves' } : r)),
1813
+ hint: '↑↓ select · ←→ or Space change · Enter save · Esc cancel · typing goes to the composer',
1814
+ onAdjust: (i, dir) => {
1815
+ const it = list[i]; if (it.action) return;
1816
+ const now = it.id === 'motion' ? (normaliseLevel(vals.motion) || it.def) : (vals[it.id] ?? it.def); // F3: a pre-levels full/reduced profile
1817
+ const v = cycle(it, staged[it.id] ?? now, dir);
1818
+ if (v === now) delete staged[it.id]; else staged[it.id] = v;
1819
+ },
1820
+ onClose: () => { if (Object.keys(staged).length) say('Settings closed; nothing saved.', 'dim'); staged = {}; },
1821
+ onPick: (_, i) => { commit(); if (list[i].action === 'model') command('/model'); }
562
1822
  });
563
1823
  }
564
1824
 
1825
+ // ---- QUOXON-FW8: ! commands ---------------------------------------------------------------------
1826
+ function bangCommand(bang, { echoed = false } = {}) {
1827
+ if (bang.kind === 'reserved') return say('!! is reserved. Use ! followed by a command, e.g. !ls or !quox inbox list.', 'warn');
1828
+ if (bang.kind === 'empty') return say('Usage: !<command> runs it here, !quox <args> runs this quox as you. Output stays on your screen; /share sends it to Quox.', 'warn');
1829
+ let plan = { cmd: bang.cmd };
1830
+ if (bang.kind === 'quox') {
1831
+ plan = quoxArgv(bang.args, { profile });
1832
+ if (plan.refuse && !plan.tty) return say(plan.refuse, 'warn');
1833
+ }
1834
+ const cmd = bang.cmd;
1835
+ const timeoutMs = Number(env.QUOX_BANG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
1836
+ if (!echoed) say(`$ ${cmd}`, 'accent');
1837
+ // BANG-TTY (owner 2026-10-16): a known interactive command or an interactive quox leaf gets the terminal
1838
+ if (plan.tty || needsTerminal(bang)) return bangHandOver(cmd, plan.tty || plan);
1839
+ let pending = ''; let timer = null;
1840
+ const flush = (all) => {
1841
+ if (R.current.exiting) { pending = ''; return; }
1842
+ let out = pending;
1843
+ if (!all) { const i = out.lastIndexOf('\n'); if (i < 0) return; out = out.slice(0, i + 1); }
1844
+ pending = pending.slice(out.length);
1845
+ out = cleanOutput(out).replace(/\n+$/, '');
1846
+ if (out) dispatch({ type: 'bang.out', text: out });
1847
+ };
1848
+ const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
1849
+ const slow = setTimeout(() => { if (R.current.bang?.run === run && !R.current.exiting) say('still running (Ctrl+C stops it)', 'accent'); }, 5000);
1850
+ R.current.bang = { run, cmd };
1851
+ run.done.then((r) => {
1852
+ clearTimeout(timer); clearTimeout(slow); timer = null; flush(true);
1853
+ R.current.bang = null;
1854
+ const results = R.current.bangResults ??= [];
1855
+ results.push({ cmd, text: r.text, truncated: r.truncated });
1856
+ if (results.length > 10) results.shift();
1857
+ transcript?.bang?.(cmd, r.timedOut ? 'timeout' : r.killed ? 'killed' : r.code);
1858
+ if (R.current.exiting) return;
1859
+ if (r.truncated) say(bangTruncNote(r), 'warn');
1860
+ if (ttyFailure(r)) { say(bangFooter(r, { timeoutMs }), 'warn'); return bangHandOver(cmd, plan); } // it wanted a terminal: retry handed over
1861
+ if (r.stdinNote) say(r.stdinNote, 'warn');
1862
+ say(bangFooter(r, { timeoutMs }), r.code === 0 && !r.timedOut && !r.killed ? 'ok' : 'warn');
1863
+ bump(n => n + 1);
1864
+ });
1865
+ }
1866
+
1867
+ /** BANG-TTY: run a ! command with the terminal handed over (the run-request path: raw mode and mouse off, back after). */
1868
+ function bangHandOver(cmd, plan) {
1869
+ say('This needs a real terminal: handing it over. Quit it to come back.', 'dim');
1870
+ const hold = { run: { kill: () => {}, done: null }, cmd, handover: true };
1871
+ R.current.bang = hold; // nothing else starts in the gap before the hand-over
1872
+ setTimeout(() => {
1873
+ if (R.current.bang === hold) R.current.bang = null;
1874
+ if (R.current.closed || R.current.exiting) return;
1875
+ const target = plan.argv ? { file: process.execPath, args: plan.argv } : { cmd: plan.cmd ?? cmd };
1876
+ const r = handOverRun({ ...target, env, cwd: process.cwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
1877
+ transcript?.bang?.(cmd, r.error ? 'error' : (r.signal || r.code));
1878
+ say(runFooter(r), !r.error && !r.signal && r.code === 0 ? 'ok' : 'warn');
1879
+ bump(n => n + 1);
1880
+ }, runHandOverDelayMs);
1881
+ return undefined;
1882
+ }
1883
+
1884
+ function cliSlashRun(hit) {
1885
+ if (hit.cls === 'never') return say(`${hit.display} will not run from here (credentials, install or other never-from-chat commands). Run it in your own terminal if you mean it.`, 'warn');
1886
+ const bang = { kind: 'quox', args: hit.args, cmd: hit.display };
1887
+ if (hit.cls === 'write') { R.current.cliAsk = { bang, at: Date.now() }; say(hit.display, 'accent'); return ask('Run it?', { action: hit.display, reason: 'This quox command writes.' }); }
1888
+ if (R.current.bang) return say('A command is still running. Ctrl+C stops it.', 'warn');
1889
+ say(`ran ${hit.display}`, 'dim');
1890
+ return bangCommand(bang);
1891
+ }
1892
+
1893
+ function shareCommand(arg) {
1894
+ const res = R.current.bangResults || [];
1895
+ if (!res.length) return say('Nothing to share yet. Run a command with ! first, e.g. !quox inbox list, then /share.', 'warn');
1896
+ const n = arg ? Number(arg) : 1;
1897
+ if (!Number.isInteger(n) || n < 1 || n > res.length) return say(`Usage: /share [N] where N is 1 (latest) to ${res.length}.`, 'warn');
1898
+ const r = res[res.length - n];
1899
+ const sh = shareText(r.text);
1900
+ R.current.shared = [...(R.current.shared || []).filter(x => x.cmd !== r.cmd), { cmd: r.cmd, text: sh.text, truncated: sh.truncated }];
1901
+ say(`Output of \`${r.cmd}\` will be shared with Quox with your next message${sh.truncated ? ' (first 16 KB only, it was longer)' : ''}.`, 'accent');
1902
+ }
1903
+
1904
+ /** /update (owner 2026-10-16): the shared updater (core/selfUpdate.js) checks, the y answer hands the terminal to it. */
1905
+ async function updateCommand() {
1906
+ if (!onRestart) return say('Update is not available in this mode.', 'warn');
1907
+ if (S.current.turn) return say('A turn is running. Wait for it, then /update.', 'warn');
1908
+ if (R.current.updateChecking) return say('Already checking for updates…', 'dim');
1909
+ R.current.updateChecking = true;
1910
+ say('Checking for updates…', 'dim');
1911
+ let plan;
1912
+ try {
1913
+ const u = selfUpdate;
1914
+ plan = updater ? await updater.check() : await u.checkUpdate(u.detectInstall({}));
1915
+ const lines = u.updateLines(plan);
1916
+ const tone = !plan.ok || plan.refused ? 'warn' : plan.upToDate ? 'ok' : undefined;
1917
+ for (const l of lines) say(l.startsWith(' · ') ? clip(l, cols - 4) : l, tone); // a refusal or failure is said in full
1918
+ if (!plan.ok || plan.upToDate || plan.refused) return undefined;
1919
+ R.current.updateAsk = { plan, at: Date.now() };
1920
+ return say(u.updateAsk(plan), 'ask');
1921
+ } catch (e) {
1922
+ return say(`Could not check for updates: ${e?.message || e}`, 'warn');
1923
+ } finally { R.current.updateChecking = false; }
1924
+ }
1925
+
1926
+ // PROFILE-SWITCH: an agent-login window that needs a person offers the human login (y switches, never automatic);
1927
+ // with no human login on this machine it offers `quox login -p <name>` as a run card instead.
1928
+ function offerSwitch() {
1929
+ const { profile: me, agentLogin: isAgent } = WHO.current;
1930
+ if (!isAgent) return undefined;
1931
+ const human = humanProfiles.find(x => x !== me && okProfileName(x));
1932
+ if (human) { R.current.profileAsk = { name: human, at: Date.now() }; return say(switchAskLine(me, human), 'ask'); }
1933
+ const cand = loginCandidates.find(x => x !== me && okProfileName(x)) || 'me';
1934
+ return offerLogin(cand, `This window is an agent login (${me}) and no human login is signed in on this machine.`);
1935
+ }
1936
+ function offerLogin(name, lead) {
1937
+ return armRun(loginRunRequest(name, `${lead} Sign ${name} in, then /profile ${name} switches this window to it.`, runNow()), { who: 'Quox', glyph: 'Ⓠ', text: '' });
1938
+ }
1939
+ /** Switch this window to `name`: in place when main can build its api (keeps everything), else /restart into -p name. */
1940
+ async function switchTo(name) {
1941
+ const me = WHO.current.profile;
1942
+ if (!okProfileName(name)) return say(`"${clip(String(name), 40)}" is not a profile name.`, 'warn');
1943
+ if (name === me) return say(`This window is already ${me}.`, 'dim');
1944
+ if (S.current.turn) return say('A turn is running. Wait for it (or press Esc), then switch.', 'warn');
1945
+ if (switchProfile && !where.current.slot) { // a held slot belongs to the old identity: the restart hand-over carries it properly
1946
+ say(`Switching this window to ${name}…`, 'dim');
1947
+ let r;
1948
+ try { r = await switchProfile(name, { org: api.windowOrgId?.() || null }); } catch (e) { r = { ok: false, reason: e?.message || 'failed' }; }
1949
+ if (r?.ok && r.api) {
1950
+ APX.current.live = r.api;
1951
+ const next = { profile: name, agentLogin: Boolean(r.agentLogin) };
1952
+ WHO.current = next; setWho(next);
1953
+ if (r.orgName !== undefined) R.current.orgName = r.orgName;
1954
+ R.current.notify = { fails: 0, authLost: false };
1955
+ dispatch({ type: 'notify', patch: { error: null } });
1956
+ refreshNotify(); refreshRoster();
1957
+ return say(`✔ Switched this window to ${name}. Same conversation${SN.current.agent ? `, still attached to ${SN.current.agent}` : ''}.${r.note ? ` ${r.note}` : ''}`, 'ok');
1958
+ }
1959
+ if (r?.reason === 'not_signed_in') return offerLogin(name, `${name} is not signed in on this machine.`);
1960
+ if (!onRestart) return say(`Could not switch to ${name} (${r?.reason || 'failed'}). Still ${me}.`, 'warn');
1961
+ say(`Could not switch in place (${r?.reason || 'failed'}). Restarting into ${name} instead.`, 'warn');
1962
+ } else if (!switchProfile && !humanProfiles.includes(name)) return offerLogin(name, `${name} is not a signed-in profile on this machine.`);
1963
+ if (!onRestart) return say(`Switching profile is not available in this mode. Quit and run: quox -p ${name}`, 'warn');
1964
+ return doRestart({ profile: name });
1965
+ }
1966
+
1967
+ async function doRestart(extra = {}) {
1968
+ if (R.current.restarting) return say('Already restarting…', 'dim');
1969
+ R.current.restarting = true; stopCountdown();
1970
+ let target;
1971
+ if (restartCheck) { // never let go of the terminal for a quox that cannot start: say so and stay
1972
+ say('Checking the installed quox…', 'dim');
1973
+ const c = await restartCheck();
1974
+ if (!c.ok) { R.current.restarting = false; return say(`Could not restart: ${c.bin} did not start (${c.error}). Still in this client.`, 'warn'); }
1975
+ target = c.onPath || undefined;
1976
+ }
1977
+ say(`Restarting${S.current.ctx.conversationId ? ' into this conversation' : ''}…`, 'dim');
1978
+ onRestart({ conversationId: S.current.ctx.conversationId || null, name: SN.current.name || null, slot: where.current.slot || null, attach: SN.current.agent ? `agent:${SN.current.agent}` : (SN.current.slotTarget ? `slot:${SN.current.slotTarget}` : null), org: api.windowOrgId?.() || null, ...(MD.current === 'plan' ? { mode: 'plan' } : {}), bin: target, ...extra });
1979
+ return exit(30);
1980
+ }
1981
+
565
1982
  async function command(line) {
566
1983
  if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/note');
567
- const [cmd, arg] = line.trim().split(/\s+/);
568
- if (cmd !== '/note') transcript?.command(line);
1984
+ const [cmd0, arg] = line.trim().split(/\s+/);
1985
+ const cmd = cmd0 === '/agent' ? '/agents' : cmd0; // /agent is the same command
1986
+ if (!['/afk', '/sleep', '/back', '/away'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
1987
+ if (cmd !== '/note' && cmd !== '/context') SCX.current.command(line.trim());
1988
+ if (cmd !== '/note') transcript?.command(/^\/(authenticate|authorise|authorize)$/.test(cmd) && !/^[\d,\s-]*$/.test(line.trim().replace(/^\S+\s*/, '')) ? cmd : line); // QUOXON-STEPUP: never record a stray argument that might be a PIN
569
1989
  const restArg = line.trim().replace(/^\S+\s*/, '');
1990
+ touchSeen();
1991
+ if (TIPS.some(x => tryCommand(x) === cmd) && !TP.current.used.includes(cmd)) { TP.current.used = [...TP.current.used, cmd]; api.saveSetting?.('tipsUsed', TP.current.used); } // using a thing retires its tip
1992
+ let objArg = restArg;
1993
+ if (cmd === '/agents') { // list picker, tools list, or the card (agents.js: list/tools/show are verbs, never names)
1994
+ const p = parseAgentsArgs(restArg);
1995
+ if (p.mode === 'usage') return say(p.text, 'warn');
1996
+ if (p.mode === 'list') return agentsPicker(p.filter);
1997
+ if (p.mode === 'tools') return agentTools(p.name);
1998
+ objArg = p.name;
1999
+ }
570
2000
  if (OBJECT_CMDS.has(cmd) && !(cmd === '/rules' && arg)) { // inline object views (objects/): a card into history, or an honest note
571
- const r = await runObjectCommand(cmd, restArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks] });
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 });
572
2002
  if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : say(clip(r.note, cols - 4), r.tone);
573
2003
  }
574
2004
  switch (cmd) {
@@ -578,7 +2008,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
578
2008
  if (stdout?.isTTY) stdout.write(osc52(r.text));
579
2009
  return say(`copied ${r.text.length.toLocaleString('en-US')} chars to the clipboard (needs a terminal that allows OSC 52)`, 'dim');
580
2010
  }
2011
+ case '/share': return shareCommand(arg);
581
2012
  case '/note': {
2013
+ if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
582
2014
  if (!restArg) return say('Usage: /note <text> (or // text). Nothing to note.', 'warn');
583
2015
  if (!transcript?.note) return say('notes need dev transcripts on (touch ~/.config/quox/dev-transcripts)', 'warn');
584
2016
  transcript.note(restArg.slice(0, 2000));
@@ -588,63 +2020,229 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
588
2020
  if (!restArg) return say(SN.current.name ? `Session name: ${SN.current.name}` : 'This session has no name. Use /rename <name>.');
589
2021
  const n = cleanName(restArg);
590
2022
  SN.current.name = n; SN.current.saved = null; bump(x => x + 1);
2023
+ if (where.current.slot && api.checkoutSlot) { // the slot's holder label follows the name, or this window would be locked out of its own slot
2024
+ const r = await api.checkoutSlot(where.current.slot, n, { take: true });
2025
+ if (!r.ok) return say(`Session named "${n}", but slot ${where.current.slot} still carries the old label: ${r.message}`, 'warn');
2026
+ }
591
2027
  return say(`Session named "${n}".`, 'ok');
592
2028
  }
2029
+ case '/challenge': return CH.current.run(restArg);
2030
+ case '/spawn': return SPN.current.run(restArg);
2031
+ case '/ask': // owner typed /ask hyperion <question> (2026-10-16): same as /say
2032
+ case '/say': { // QUOXON-MESH: straight to a slot, no model turn
2033
+ const m = /^(\S+)\s+([\s\S]*\S[\s\S]*)$/.exec(restArg || '');
2034
+ if (!m) return say('Usage: /say <slot>[,<slot>...] <text>', 'warn');
2035
+ const to = [...new Set(m[1].split(',').map(x => x.trim().toLowerCase()).filter(Boolean))];
2036
+ if (to.length < 2) return sendToSlot(to[0] || m[1].toLowerCase(), m[2].trim());
2037
+ // QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
2038
+ const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
2039
+ const head = `(to ${slots.join(', ')}) `;
2040
+ for (const sl of slots) await sendToSlot(sl, head + m[2].trim());
2041
+ return undefined;
2042
+ }
2043
+ case '/join': { const nm = roomName(restArg); if (!nm) return say('Usage: /join <slot> (or #<slot>)', 'warn'); return joinRoom(nm); }
2044
+ case '/leave': { const nm = roomName(restArg); if (!nm) return say('Usage: /leave <slot>', 'warn'); return leaveRoom(nm); }
2045
+ case '/rooms': case '/groups': return showRooms();
2046
+ case '/broadcast': case '/page':
2047
+ return runBroadcast({ api, kind: cmd === '/page' ? 'page' : 'broadcast', arg: restArg, from: myLabel(), say, card: (m) => dispatch({ type: 'bcard', card: m }), isClosed: () => R.current.closed });
2048
+ case '/ack': return runAck({ api, arg: restArg, slot: where.current.slot || null, say });
2049
+ case '/send': { // QUOXON-MESH: a local text file into a slot (inline, 64 KB cap, secrets scrubbed on a yes)
2050
+ const m = /^(\S+)\s+(.+)$/.exec(restArg || '');
2051
+ if (!m) return say('Usage: /send <slot> <file>', 'warn');
2052
+ if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
2053
+ return sendFile(m[1].toLowerCase(), m[2]);
2054
+ }
593
2055
  case '/slot': {
594
2056
  if (!restArg) return say(where.current.slot ? `Slot: ${where.current.slot}` : 'No slot. Use /slot <name> to give this session an address.');
2057
+ if (/^log(\s+\d+)?$/.test(restArg.trim())) return slotLog(restArg.trim().split(/\s+/)[1]);
2058
+ if (/^(?:list|ls|help|all)$/i.test(restArg.trim())) return listSlots(/^all$/i.test(restArg.trim())); // owner 2026-10-15: "/slot help" must not check out a slot called help
595
2059
  if (!api.checkoutSlot) return say('Slots are not available in this mode.', 'warn');
596
- const nm = restArg.split(/\s+/)[0];
597
- const r = await api.checkoutSlot(nm, SN.current.name || S.current.ctx.conversationId || 'quox');
598
- if (!r.ok) return say(`slot ${nm}: ${r.message}`, 'warn');
599
- where.current = { ...where.current, slot: nm }; bump(x => x + 1);
600
- return say(`Slot ${nm} checked out. Discord replies are not relayed into this window yet.`, 'ok');
2060
+ const toks = restArg.split(/\s+/);
2061
+ const forceCreate = toks.includes('--create');
2062
+ const take = toks.includes('--take');
2063
+ let nm = toks.find(t => !t.startsWith('--')) || '';
2064
+ if (!nm) return say('Usage: /slot <name> [--create] [--take]', 'warn');
2065
+ if (!forceCreate) nm = await canonSlot(nm);
2066
+ if (!forceCreate && api.slots) { // a name that is not a real slot but is close to one is a typo, not a request to create
2067
+ let slots = null;
2068
+ try { slots = await api.slots(); } catch { slots = null; }
2069
+ if (Array.isArray(slots) && !slots.some(x => String(x.name) === nm)) {
2070
+ const near = await nearbyTargets(nm, slots);
2071
+ if (near.length) { // owner 2026-10-16: offer, don't instruct. The near misses stay as the line above the card.
2072
+ say(`Slot "${nm}" not found. Did you mean: ${near.join(', ')}?`, 'warn');
2073
+ R.current.slotCreateAsk = { nm, at: Date.now() };
2074
+ return say(slotCreateAskLine(nm), 'ask');
2075
+ }
2076
+ }
2077
+ }
2078
+ return takeSlot(nm, take);
601
2079
  }
602
2080
  case '/attach': {
603
2081
  if (!restArg) return say(SN.current.agent ? `Attached to ${SN.current.agent}. /detach returns to CommanderQ.` : 'Talking to CommanderQ. Use /attach <agent> to pick one.');
604
2082
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
605
2083
  if (!api.agents) return say('Agents are not available in this mode.', 'warn');
2084
+ const pt = parseTarget(restArg);
606
2085
  let list;
607
- try { list = await api.agents(); } catch (e) { return say(`could not list agents: ${e.message}`, 'warn'); }
608
- const m = matchAgent(restArg, list);
609
- if (!m.agent) return say(`No agent called "${restArg}".${m.matches.length ? ` Closest: ${m.matches.join(', ')}.` : ' Try quox agent list.'}`, 'warn');
2086
+ try { list = await api.agents(); } catch (e) { return say(`Couldn't check agents: ${listFailure(e, profile)}`, 'warn'); }
2087
+ let slots = null;
2088
+ if (pt.kind !== 'agent' && api.slots) {
2089
+ try { slots = await api.slots(); } catch (e) { if (pt.kind === 'slot') return say(`Couldn't check slots: ${listFailure(e, profile)}`, 'warn'); slots = null; }
2090
+ }
2091
+ const slotList = Array.isArray(slots) ? slots : [];
2092
+ const slotKey = pt.kind === 'agent' ? null : uniqueSquash(pt.name, slotList.map(x => x.name));
2093
+ const slotRow = slotKey ? slotList.find(x => String(x.name) === slotKey) : null;
2094
+ const m = pt.kind === 'slot' ? { matches: [] } : matchAgent(pt.name, list);
2095
+ const slotLive = !!(slotRow && slotRow.holder_session);
2096
+ let choice = null; // 'slot' | 'agent'
2097
+ if (slotRow && m.agent) choice = pt.kind === 'slot' || slotLive ? 'slot' : 'agent';
2098
+ else if (slotRow) choice = 'slot';
2099
+ else if (m.agent) choice = 'agent';
2100
+ if (!choice) {
2101
+ // A miss is only "no such agent" when the lists answered; a failed list is the failure, not a not-found.
2102
+ if (list?.listError) return say(`Couldn't check agents: ${listFailure(list.listError, profile)}`, 'warn');
2103
+ if (api.slots && slots === null && pt.kind !== 'agent') { try { slots = await api.slots(); } catch (e) { return say(`Couldn't check slots: ${listFailure(e, profile)}`, 'warn'); } }
2104
+ const near = await nearbyTargets(pt.name, slots, list);
2105
+ return say(`No ${pt.kind || 'agent or slot'} called "${pt.name}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ' Try quox agent list.'}`, 'warn');
2106
+ }
2107
+ const both = !!(slotRow && m.agent);
2108
+ if (choice === 'slot') {
2109
+ if (!api.slotSay) return say('Talking to a slot is not available in this mode.', 'warn');
2110
+ SN.current.agent = null; SN.current.slotTarget = slotRow.name;
2111
+ where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
2112
+ const host = slotRow.holder_identity ? String(slotRow.holder_identity).replace(/^resident:/, '') : (slotRow.host_name || slotRow.hostname || null); // RESTORE-TUI: a named host from an older server row still shows
2113
+ const lbl = `slot ${slotRow.name}${host ? ` (${host})` : ''}`;
2114
+ const alt = both ? ` To pick the agent instead: /attach agent:${pt.name}` : '';
2115
+ if (!pt.rest) return say(`Attached to ${lbl}. Your next messages go to it. /detach returns to CommanderQ.${alt}`, 'ok');
2116
+ const r = await api.slotSay(slotRow.name, pt.rest, { from: myLabel(), fromSlot: where.current.slot || null });
2117
+ if (!r.ok) return say(`Attached to ${lbl}, but the message was not sent: ${r.message}`, 'warn');
2118
+ return say(`Attached to ${lbl}. Sent: ${pt.rest}${alt}`, 'ok');
2119
+ }
2120
+ SN.current.slotTarget = null;
610
2121
  SN.current.agent = m.agent.id; where.current = { ...where.current, agent: m.agent.id }; bump(x => x + 1);
611
- return say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.`, 'ok');
2122
+ const alt = both ? ` To pick the slot instead: /attach slot:${pt.name}` : '';
2123
+ say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.${alt}`, 'ok');
2124
+ if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
2125
+ return undefined;
2126
+ }
2127
+ case '/peek': { // PEEK: the same lookup as `quox peek <id>`, in this window's org
2128
+ const pid = restArg.trim().split(/\s+/)[0] || '';
2129
+ if (!api.peek) return say('Peek is not available in this mode. In a terminal: quox peek <id>', 'warn');
2130
+ if (!pid) return say('Usage: /peek <id>. An envelope, inbox item, objective, workflow run, WARD receipt or agent id.', 'warn');
2131
+ let pr;
2132
+ try { pr = await api.peek(pid); } catch (e) { return say(`Peek failed: ${listFailure(e, profile)}`, 'warn'); }
2133
+ if (pr?.hit) return dispatch({ type: 'object', obj: { type: 'peek', data: pr } }); // a boxed card: the label column survives
2134
+ for (const l of peekIdLines(pr)) say(clip(l.text, cols - 4), l.tone === 'ok' ? undefined : l.tone || undefined);
2135
+ return undefined;
2136
+ }
2137
+ case '/aside': { // ASIDE: no tools, no conversation id; the answer is a separate read-only block
2138
+ const q = restArg.trim();
2139
+ if (!api.aside) return say('Asides are not available in this mode. In a terminal: quox aside "<question>"', 'warn');
2140
+ if (!q) return say('Usage: /aside <question>. A read-only side question: no tools, not added to this conversation.', 'warn');
2141
+ say('Asking on the side (read only, no tools)…', 'dim');
2142
+ let ar;
2143
+ try { ar = await api.aside(q, turnsFromItems([...S.current.history, ...S.current.blocks])); } catch (e) { return say(`Aside failed: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}. Nothing was changed.`, 'warn'); }
2144
+ dispatch({ type: 'note', text: asideNoteText(q, ar.text, ar), tone: 'aside' });
2145
+ return undefined;
2146
+ }
2147
+ case '/hosts': {
2148
+ if (/^add\b/i.test(restArg || '')) { for (const l of await hostLines()) say(l); return undefined; }
2149
+ say('To see the hosts your Quox knows: quox fleet hosts (in a terminal).');
2150
+ return say('To add one, no AI needed: /hosts add');
2151
+ }
2152
+ case '/login': { // /login <name> for a profile that is already signed in: switch this window to it (PROFILE-SWITCH)
2153
+ const ln = restArg.trim().split(/\s+/)[0] || '';
2154
+ if (ln && ln !== profile && humanProfiles.includes(ln)) return switchTo(ln);
2155
+ LI.current.open(); return undefined;
2156
+ }
2157
+ case '/profile': {
2158
+ const pn = restArg.trim().split(/\s+/)[0] || '';
2159
+ if (pn && pn !== 'list') return switchTo(pn);
2160
+ const others = humanProfiles.filter(x => x !== profile);
2161
+ say(`This window is ${profile} (${agentLogin ? 'agent login' : 'human login'}).${others.length ? ` Switch with ${others.map(o => `/profile ${o}`).join(' or ')}.` : ' No other signed-in profile on this machine: /profile <name> offers to sign one in.'}`);
2162
+ // PROFILE-CHOOSER: one list of profiles and this login's orgs; a pick changes THIS window only.
2163
+ let orgList = [], orgNote = null;
2164
+ if (api.orgs) { try { orgList = await Promise.race([api.orgs(), new Promise((_, rej) => setTimeout(() => rej(new Error('timed out')), 5000))]); } catch (e) { orgNote = `Orgs not listed: ${listFailure(e, profile)}`; } }
2165
+ const prow = profileChooserRows({ current: WHO.current.profile, humanProfiles, loginCandidates, orgs: orgList, orgId: R.current.orgId ?? api.orgId?.() ?? null });
2166
+ if (orgNote) say(orgNote, 'warn');
2167
+ if (prow.length < 2) return undefined;
2168
+ PK.current.open({ title: 'Switch profile or org (this window only)', rows: prow, sel: 0, hint: '↑↓ select · Enter switch (an org asks first) · Esc cancel', onPick: (row) => {
2169
+ const p = row?.pick || {};
2170
+ if (p.profile) return p.profile === WHO.current.profile ? say(`This window is already ${p.profile}.`, 'dim') : switchTo(p.profile);
2171
+ if (p.login) return switchTo(p.login);
2172
+ if (p.org) return p.org.id === (R.current.orgId ?? api.orgId?.() ?? null) ? say(`Already in ${p.org.name}.`, 'dim') : command(`/org switch ${p.org.slug || p.org.id}`);
2173
+ return undefined;
2174
+ } });
2175
+ return undefined;
612
2176
  }
613
2177
  case '/connect': {
2178
+ if (BL.current) return say(BL.current, 'warn');
614
2179
  if (!restArg) {
615
2180
  if (!api.connections) return say('Connections are not available in this mode.', 'warn');
616
- for (const [t, tone] of connectionLines(await api.connections())) say(clip(t, cols - 4), tone);
2181
+ const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
2182
+ for (const [t, tone] of connectionLines(vault, caps)) say(clip(t, cols - 4), tone);
617
2183
  return undefined;
618
2184
  }
619
2185
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2186
+ if (/^(yes|no)$/i.test(restArg.trim())) { // answers the private-network card
2187
+ const offer = R.current.lanOffer; R.current.lanOffer = null;
2188
+ if (!offer) return say('Nothing to confirm. Run /connect again.', 'warn');
2189
+ if (/^no$/i.test(restArg.trim())) return say('Left blocked. Nothing was changed.');
2190
+ const c = await confirmLanAllow(api, offer);
2191
+ for (const [t, tone] of c.lines) say(t, tone);
2192
+ // Z2H-R4: a LAN provider always arrives here; adopt it like the direct path does.
2193
+ if (c.ok && offer.providerId) await adoptConnected(offer.providerId, { host: c.host, model: c.model });
2194
+ return undefined;
2195
+ }
620
2196
  const pv = providerFor(arg);
621
2197
  if (pv?.url && pv.id !== 'ollama') { // B5: LM Studio / vLLM / custom: probed server side, then saved (custom may need a key)
622
2198
  const rest = restArg.replace(/^\S+\s*/, '');
623
2199
  say(`Checking the URL from the server…`);
624
2200
  const r = await connectCompatFlow(api, pv.id, rest);
625
2201
  for (const [t, tone] of r.lines) say(clip(t, cols - 4), tone);
2202
+ R.current.lanOffer = r.lanOffer ? { ...r.lanOffer, providerId: pv.id } : null;
626
2203
  if (r.needsKey) CN.current.openCustom(r.extra);
2204
+ else if (r.ok) { await adoptConnected(pv.id, { host: r.host, model: r.model }); await CN.current.askConsent(pv.type); }
627
2205
  return undefined;
628
2206
  }
629
2207
  if (pv?.url) { // B3: Ollama takes a URL, probed and saved server side
630
- say(`Checking ${parseOllamaArg(restArg.replace(/^\S+\s*/, '')).url || 'the URL'} from the server…`);
2208
+ { const u = parseOllamaArg(restArg.replace(/^\S+\s*/, '')).url; if (u) say(`Checking ${u} from the server…`); }
631
2209
  const r = await connectOllamaFlow(api, restArg.replace(/^\S+\s*/, ''));
632
- for (const [t, tone] of r.lines) say(clip(t, cols - 4), tone);
2210
+ for (const [t, tone] of r.lines) say(t, tone) /* Z2H-R3: notes wrap; clipping cut the egress message at "localho…" */;
2211
+ R.current.lanOffer = r.lanOffer ? { ...r.lanOffer, providerId: 'ollama' } : null;
2212
+ if (r.privateAsk) CN.current.askPrivate(r.privateAsk, async (c) => { if (c.ok) await adoptConnected('ollama', { host: c.host, model: c.model }); }); // #1288: y/N for this one address
2213
+ if (r.ok) await adoptConnected('ollama', { host: r.host, model: r.model });
633
2214
  return undefined;
634
2215
  }
635
2216
  CN.current.open(arg);
636
2217
  return undefined;
637
2218
  }
638
2219
  case '/model': {
2220
+ const sub = parseModelSub(restArg);
2221
+ const handled = MM.current.handle(sub);
2222
+ if (handled) return handled;
2223
+ if (sub.kind === 'pass') MP.current.touched = true;
639
2224
  if (!api.connections) return say('Model choice is not available in this mode.', 'warn');
640
- const res = await api.connections();
641
- if (!restArg) { for (const [t, tone] of modelLines(res, MC.current)) say(clip(t, cols - 4), tone); return undefined; }
642
- let r = selectModel(res, restArg);
2225
+ // One provider status for /model, /connect, /doctor, /capabilities: the server's capabilities, vault for detail.
2226
+ const [res, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
2227
+ if (sub.kind === 'list' || !restArg) {
2228
+ for (const [t, tone] of modelLines(res, MC.current, caps)) say(clip(t, cols - 4), tone);
2229
+ return undefined;
2230
+ }
2231
+ let r = selectModel(res, restArg, caps);
643
2232
  let installed = null;
644
2233
  if (r.ok && r.choice?.provider === 'ollama' && api.ollamaModels) { // B3: check against what that Ollama has installed
645
2234
  r = selectOllamaModel(await api.ollamaModels(), r.choice.model || null);
646
2235
  if (r.models) installed = ollamaModelRows(r.models, r.current || r.choice?.model);
647
2236
  }
2237
+ if (r.ok && r.choice?.via === 'subscription') { // QUOXON-MODEL: Claude CLI family aliases, no list to fetch
2238
+ if (r.choice.model) r = selectSubscriptionModel(r.choice);
2239
+ } else if (r.ok && (r.choice?.provider === 'openai' || r.choice?.provider === 'anthropic') && api.providerModels) {
2240
+ // QUOXON-MODEL: live model list (server cached 1 h, refreshed now); no hardcoded lists to go stale
2241
+ const row = CHOICES.find(x => x.provider === r.choice.provider && !x.via);
2242
+ const d = selectDiscoveredModel(await api.providerModels(r.choice.provider, { refresh: true }), r.choice, row);
2243
+ r = d.ok && r.tone === 'warn' && !d.models ? { ...d, text: `${d.text} ${r.text}`, tone: 'warn' } : d;
2244
+ if (r.models) installed = compatModelRows(r.models, r.current || r.choice?.model);
2245
+ }
648
2246
  if (r.ok && byId(r.choice?.provider)?.compat && api.compatModels) { // B5: check against the provider's own /models
649
2247
  r = selectCompatModel(await api.compatModels(r.choice.provider), r.choice.provider, r.choice.model || null);
650
2248
  if (r.models) installed = compatModelRows(r.models, r.current || r.choice?.model);
@@ -657,6 +2255,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
657
2255
  if (installed) for (const [t, tone] of installed) say(clip(t, cols - 4), tone);
658
2256
  return undefined;
659
2257
  }
2258
+ case '/row': return RW.current.handle(restArg);
2259
+ case '/watch': return KC.current.watch(restArg);
2260
+ case '/automate': return KC.current.automate(restArg);
2261
+ case '/crystallize': return KC.current.crystallize(restArg);
2262
+ case '/undo': return UW.current.undo(restArg);
2263
+ case '/wish': return UW.current.wish(restArg);
660
2264
  case '/capabilities': {
661
2265
  if (!api.capabilities) return say('Capabilities are not available in this mode.', 'warn');
662
2266
  const res = await api.capabilities();
@@ -670,49 +2274,69 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
670
2274
  for (const [t, tone] of capabilityLines(res)) say(clip(t, cols - 4), tone);
671
2275
  return undefined;
672
2276
  }
673
- case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': return undefined; // answered above (runObjectCommand)
674
- case '/agents': return say('Usage: /agents <name> shows an agent card.'); // the bare list belongs to the slash-commands work
2277
+ case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': case '/fleet': case '/prove': case '/replay': return undefined; // answered above (runObjectCommand)
2278
+ case '/agent': case '/agents': return undefined; // answered above (picker, tools, card)
2279
+ case '/which': return showWhich(arg);
675
2280
  case '/detach':
676
- SN.current.agent = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
2281
+ SN.current.agent = null; SN.current.slotTarget = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
677
2282
  return say('Back to CommanderQ.');
678
2283
  case '/sessions': {
679
- if (arg === 'all') {
680
- if (!api.sessionsIndex) return say('The session index is not available in this mode.', 'warn');
2284
+ // SESSIONS (owner 2026-10-15): name and where first; ids only with `refs`.
2285
+ const flags = new Set(String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean));
2286
+ if (api.sessionsIndex) {
681
2287
  try {
682
- for (const l of overviewLines(await api.sessionsIndex(), { localHost: osHostname(), names: loadNames(namesPath) })) say(clip(l.text, cols - 4), l.tone);
683
- } catch (e) { dispatch({ type: 'error', text: `sessions all: ${e.status ? `HTTP ${e.status}: ` : ''}${e.message}` }); }
2288
+ const lines = humanSessionLines(await api.sessionsIndex(), { localHost: osHostname(), names: loadNames(namesPath), all: flags.has('all'), refs: flags.has('refs') || flags.has('ids') });
2289
+ for (const l of lines) say(clip(l.text, cols - 4), l.tone);
2290
+ } catch (e) { dispatch({ type: 'error', text: `sessions: ${e.status ? `HTTP ${e.status}: ` : ''}${e.message}` }); }
684
2291
  return undefined;
685
2292
  }
686
2293
  if (!api.conversations) return say('Sessions are not available in this mode.', 'warn');
687
2294
  try {
688
2295
  const rows = await api.conversations(); const names = loadNames(namesPath);
689
2296
  if (!rows.length) return say('No recent conversations.');
690
- for (const r of rows) say(`${r.id.slice(0, 12)} ${clip(names[r.id]?.name || r.title || '(untitled)', Math.max(10, cols - 30))} ${r.updated_at ? String(r.updated_at).slice(0, 16).replace('T', ' ') : ''}`);
2297
+ for (const r of rows) say(clip(`${names[r.id]?.name || r.title || '(untitled)'} ${r.updated_at ? `· ${String(r.updated_at).slice(0, 16).replace('T', ' ')}` : ''}${flags.has('refs') ? ` · ${r.id}` : ''}`, cols - 4));
691
2298
  } catch (e) { dispatch({ type: 'error', text: `sessions: ${e.message}` }); }
692
2299
  return undefined;
693
2300
  }
2301
+ case '/active': {
2302
+ const words = String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean);
2303
+ const filter = words.find(w => ACTIVE_KINDS.includes(w)) || null;
2304
+ if (words.some(w => w !== 'all' && !ACTIVE_KINDS.includes(w))) return say('Usage: /active [sessions|slots|agents] [all]', 'warn');
2305
+ if (!api.sessionsIndex) return say('Active is not available in this mode.', 'warn');
2306
+ try {
2307
+ let unread = null; if (api.slots) { try { unread = new Map((await api.slots()).map((x) => [x.name, x.unread_count || 0])); } catch { /* the count is a nicety */ } }
2308
+ for (const l of activeRows(await api.sessionsIndex(), { filter, cols, unread })) say(l.text, l.tone); // TUI-UX: one wrapped row per session, never clipped
2309
+ } catch (e) { say(`active: ${e.status ? `HTTP ${e.status}: ` : ''}${e.message}`, 'warn'); }
2310
+ return undefined;
2311
+ }
694
2312
  case '/quit': case '/exit': {
695
2313
  const midTurn = Boolean(S.current.turn);
696
2314
  if (!leaveArmed('quit')) return undefined;
697
2315
  R.current.leaveAsk = null;
698
- return midTurn ? setTimeout(() => exit(), 30) : exit(); // mid-turn: let the "keeps working" line paint first
2316
+ return exit(midTurn ? 30 : 0); // mid-turn: let the "keeps working" line paint first
699
2317
  }
700
2318
  case '/restart': {
701
2319
  if (!onRestart) return say('Restart is not available in this mode.', 'warn');
2320
+ if (arg === 'auto') { // /restart auto [on|off]: bare toggles
2321
+ const v = String(restArg.split(/\s+/)[1] || '').toLowerCase();
2322
+ if (v && v !== 'on' && v !== 'off') return say('Usage: /restart auto [on|off]', 'warn');
2323
+ const a = AR.current; a.on = v ? v === 'on' : !a.on;
2324
+ if (!a.on) { clearTimeout(a.timer); a.timer = null; }
2325
+ api.saveSetting?.('restartAuto', a.on); bump(n => n + 1);
2326
+ return say(a.on ? 'Auto-restart is on. When a newer quox is installed and you are idle, this client restarts into the same conversation.' : 'Auto-restart is off.', a.on ? 'accent' : 'dim');
2327
+ }
702
2328
  if (!leaveArmed('restart')) return undefined;
703
2329
  R.current.leaveAsk = null;
704
- if (R.current.restarting) return say('Already restarting…', 'dim');
705
- R.current.restarting = true;
706
- let target;
707
- if (restartCheck) { // never let go of the terminal for a quox that cannot start: say so and stay
708
- say('Checking the installed quox…', 'dim');
709
- const c = await restartCheck();
710
- if (!c.ok) { R.current.restarting = false; return say(`Could not restart: ${c.bin} did not start (${c.error}). Still in this client.`, 'warn'); }
711
- target = c.onPath || undefined;
712
- }
713
- say(`Restarting${S.current.ctx.conversationId ? ' into this conversation' : ''}…`, 'dim');
714
- onRestart({ conversationId: S.current.ctx.conversationId || null, name: SN.current.name || null, bin: target });
715
- return setTimeout(() => exit(), 30);
2330
+ return doRestart();
2331
+ }
2332
+ case '/update': return updateCommand();
2333
+ case '/demo': {
2334
+ if (!leaveArmed('demo')) return undefined;
2335
+ R.current.leaveAsk = null;
2336
+ if (!onDemo) return say('/demo needs the terminal client.', 'warn');
2337
+ say('Starting the demo (autoplay, nothing real runs). Quit it to come back.', 'dim');
2338
+ onDemo();
2339
+ return exit(30);
716
2340
  }
717
2341
  case '/gripe': case '/gripe!': {
718
2342
  if (!api.gripe) return say('/gripe is not available in this mode.', 'warn');
@@ -735,17 +2359,61 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
735
2359
  }
736
2360
  return undefined;
737
2361
  }
738
- case '/status': case '/context': case '/cost': case '/usage':
2362
+ case '/context':
2363
+ if (arg === 'off' || arg === 'on') {
2364
+ SCX.current.on = arg === 'on';
2365
+ bump(n => n + 1);
2366
+ return say(arg === 'on' ? 'Terminal context on: each message carries what this screen shows (2 KB max, secrets masked).' : 'Terminal context off for this session: Quox no longer sees your roster, approvals, commands or notices.');
2367
+ }
2368
+ return showPanel('/context');
2369
+ case '/status': case '/cost': case '/usage':
739
2370
  return showPanel(cmd === '/usage' ? '/cost' : cmd);
740
2371
  case '/doctor': {
741
2372
  if (!api.doctorChecks) return say('/doctor is not available in this mode.', 'warn');
742
2373
  say('Running the quox doctor checks…', 'dim');
743
2374
  const [plat, chat] = await Promise.all([api.doctorChecks().then(c => ({ c }), e => ({ e })), chatChecks(api)]);
744
- return help(doctorModel(plat.c || [], chat, plat.e ? (plat.e.message || 'failed') : null));
2375
+ const empty = !plat.e && (!Array.isArray(plat.c) || !plat.c.length); // a run that produced no rows is a failure to say, not a blank panel
2376
+ return help(doctorModel(Array.isArray(plat.c) ? plat.c : [], chat, plat.e ? (plat.e.message || 'failed') : empty ? 'the doctor checks returned no results' : null));
2377
+ }
2378
+ case '/server': {
2379
+ if (!api.serverStatus) return say('/server is not available in this mode.', 'warn');
2380
+ say('Reading the server status…', 'dim');
2381
+ const st = await api.serverStatus().then(r => r, e => ({ ok: false, error: e?.message || 'failed' }));
2382
+ if (!st.ok) return say(`No server status: ${st.error}`, 'warn');
2383
+ help(st.rows);
2384
+ return say('Change it from a terminal: quox server update --dry-run shows the plan first (also rollback, uninstall, doctor, diag).', 'dim');
2385
+ }
2386
+ case '/dashboard': {
2387
+ if (!api.dashboardLink) return say('/dashboard is not available in this mode.', 'warn');
2388
+ const r = await api.dashboardLink(arg || '').catch(e => ({ kind: 'error', error: e?.message || 'failed' }));
2389
+ // The link is a credential: the lines go to the screen only; the dev transcript records that one was minted.
2390
+ for (const l of dashboardLines(r, { hyperlinks: supportsHyperlinks(env, Boolean(stdout?.isTTY)) })) {
2391
+ const sensitive = r.kind === 'link' && l.text.includes(r.url);
2392
+ dispatch({ type: 'note', text: l.text, tone: l.tone, ...(sensitive ? { redact: 'dashboard link minted' } : {}) });
2393
+ }
2394
+ return undefined;
745
2395
  }
746
2396
  case '/config': case '/settings':
747
2397
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
748
2398
  return openConfig();
2399
+ case '/consent': {
2400
+ if (!api.consentStatus) return say('/consent is not available in this mode.', 'warn');
2401
+ const pending = R.current.consent || null;
2402
+ if (arg === 'grant') {
2403
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2404
+ const prov = String(restArg.replace(/^\S+\s*/, '') || pending?.provider || '').toLowerCase();
2405
+ if (!prov) return say('Which provider? /consent grant openai', 'warn');
2406
+ try { await api.grantConsent(prov); } catch (e) { return say(`Could not record your consent (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}). Settings > Privacy in the dashboard does the same.`, 'warn'); }
2407
+ say(`✔ Consent recorded: ${prov} may receive your messages and tool results. Change it any time in Settings > Privacy.`, 'ok');
2408
+ R.current.consent = null;
2409
+ if (pending?.text) { say('Sending your message again.', 'dim'); return runTurn(pending.text); }
2410
+ return undefined;
2411
+ }
2412
+ let rec = null;
2413
+ try { rec = await api.consentStatus(); } catch (e) { return say(`Could not read your consent (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}).`, 'warn'); }
2414
+ for (const [t, tone] of consentStatusLines(rec, { provider: pending?.provider })) say(t, tone);
2415
+ return undefined;
2416
+ }
749
2417
  case '/compact': {
750
2418
  if (arg === 'show') {
751
2419
  const last = R.current.lastCompact;
@@ -771,8 +2439,48 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
771
2439
  case '/resume':
772
2440
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
773
2441
  return resumeCmd(restArg);
774
- case '/eye': playEye(); bump(n => n + 1); return true; // the Dragon's Eye, on demand (qmotion.js EyeLive); the bump lets the app's clock frame it
2442
+ case '/dragon': { // the living dragon (qmotion DragonLive): /dragon, classic and hero = the one dragon show, fire = its last act; explain / ask / sleep are dragonModes.js
2443
+ const m = parseDragonArg(restArg);
2444
+ if (!m) return say(DRAGON_USAGE);
2445
+ if (m.mode === 'explain') return dragonExplain();
2446
+ if (m.mode === 'ask') return dragonAsk(m.question);
2447
+ if (m.mode === 'sleep') return dragonSleep();
2448
+ if (m.mode === 'fly') return dragonFly();
2449
+ playDragon(m.mode, undefined); bump(n => n + 1); return true;
2450
+ }
2451
+ case '/dragon-fly': return arg ? say('Usage: /dragon-fly', 'warn') : dragonFly();
775
2452
  case '/sound': return SND.current.command(arg, say);
2453
+ case '/tip': return showTip();
2454
+ case '/whatsnew': {
2455
+ const tg = toggleArg(arg);
2456
+ if (tg) { api.saveSetting?.('whatsNewStartup', tg); return say(tg === 'on' ? "What's new will show at startup when there is something new." : "What's new is off at startup. /whatsnew still works any time; /whatsnew on turns it back on.", 'ok'); }
2457
+ const all = String(arg).trim().toLowerCase() === 'all';
2458
+ const st = api.settings?.() || {};
2459
+ const cs = unseenCommands({ registry: visibleRegistry(), seen: st.whatsNewSeen, all });
2460
+ if (cs.length) { say(`${all ? 'All commands' : 'New commands'} (${cs.length}):`); cs.forEach(e => say(`${e.usage ? e.usage.split(/\s+[\[<|]/)[0] : '/' + e.id}: ${e.desc}`)); }
2461
+ else say('No new commands since you last looked.');
2462
+ api.saveSetting?.('whatsNewSeen', seenAfter(REGISTRY));
2463
+ // QUOXON-WHATSNEW3: only entries newer than the last one shown (all: every entry), then mark them seen.
2464
+ const unseen = all ? listWhatsNew('all', whatsNewEntries).entries : unseenEntries(st.whatsNew, whatsNewEntries);
2465
+ const shown = all ? unseen : unseen.slice(0, LIST_DEFAULT);
2466
+ const entry = (e) => { say(`${e.title}: ${e.text}`); if (tryText(e)) say(`Try: ${tryText(e)}`); };
2467
+ say("What's new in Quox:");
2468
+ if (shown.length) { say(all ? `Every entry (${unseen.length}):` : `New since you last looked (${unseen.length}):`); shown.forEach(entry); if (unseen.length > shown.length) say(`${unseen.length - shown.length} more: /whatsnew all`); }
2469
+ else { const top = listWhatsNew('', whatsNewEntries).entries[0]; say('Nothing new since you last looked.'); if (top) say(`Latest: ${top.title}${tryText(top) ? ` (try ${tryText(top)})` : ''}. /whatsnew all lists every entry.`); }
2470
+ if (shown.length || !st.whatsNew?.id) api.saveSetting?.('whatsNew', seenRecord(version, whatsNewEntries)); return true; }
2471
+ case '/tips': { let plugins = []; try { plugins = (await api.installedPlugins?.()) || []; } catch { /* unreadable: plugin tips hidden */ }
2472
+ const r = tipsReply(arg, { mode: TP.current.mode, plugins });
2473
+ if (r.tips) { say(r.head); r.tips.forEach(t => dispatch({ type: 'tip', text: t.text, cmd: t.cmd })); r.tail.forEach(l => say(l)); return true; }
2474
+ r.lines.forEach((l, i) => say(l, i ? undefined : r.tone === 'plain' ? undefined : r.tone)); return true; }
2475
+ case '/tip-settings': {
2476
+ const r = tipSettingsReply(arg, TP.current.mode);
2477
+ if (r.mode) { TP.current.mode = r.mode; api.saveSetting?.('tips', r.mode); }
2478
+ return say(r.text, r.tone === 'plain' ? undefined : r.tone);
2479
+ }
2480
+ case '/welcome': { // replay the first screen: the Q art with the greeting text beside it, and (a real terminal, 40+ rows) the dragon greeting. No setup side effects: nothing is saved, no state is reset.
2481
+ dispatch({ type: 'introShow', text: introItem.text, cols: introItem.cols });
2482
+ if (motionOn && !mono() && (termRows(stdout) ?? 0) >= 40) { playDragon('greeting'); bump(n => n + 1); }
2483
+ return true; }
776
2484
  case '/about': return dispatch({ type: 'welcome', version: packageVersion() });
777
2485
  case '/help': return dispatch({ type: 'help', model: helpModel(arg, R.current.caps) });
778
2486
  case '/clear': case '/new': case '/reset': {
@@ -785,10 +2493,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
785
2493
  setEpoch(e => e + 1);
786
2494
  return say(prev ? 'Previous conversation saved. /resume to go back.' : 'New conversation.');
787
2495
  }
2496
+ case '/aocl': return dispatch({ type: 'aocl.table' });
788
2497
  case '/verbose':
789
2498
  if (!['off', 'on', 'full'].includes(arg)) return say(`verbose is ${S.current.verbose}. Use /verbose off|on|full`);
790
2499
  dispatch({ type: 'verbose', mode: arg });
791
- return say(`verbose ${arg}`);
2500
+ if (api.saveSetting) api.saveSetting('verbose', arg); // persisted per user (profile settings)
2501
+ return say(`verbose ${arg}${api.saveSetting ? ' (saved)' : ''}`);
792
2502
  case '/rules': {
793
2503
  if (arg === 'off' || arg === 'on') {
794
2504
  R.current.rulesOn = arg === 'on';
@@ -802,6 +2512,93 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
802
2512
  if (pages > 1) say(`page ${pg}/${pages}${pg < pages ? ` · /rules ${pg + 1} for more` : ''}`);
803
2513
  return undefined;
804
2514
  }
2515
+ case '/hat': { // quox#1183: change hats with no model turn. A normal hat switches now; a privileged one asks y/N.
2516
+ if (!api.hats || !api.bindHat) return say('Hat switching is not available in this mode.', 'warn');
2517
+ const R0 = R.current; const q = restArg.trim().replace(/^switch\b\s*/i, ''); // "/hat switch" is "/hat": the chooser
2518
+ const hatSub = q.split(/\s+/)[0].toLowerCase();
2519
+ if (hatSub === 'new' || hatSub === 'edit' || hatSub === 'delete') { // CUSTOM-HATS (owner 2026-10-17): own hats, walked one card at a time
2520
+ if (!api.createHat || !api.updateHat || !api.deleteHat) return say('Custom hats are not available in this mode.', 'warn');
2521
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2522
+ return HW.current.start(hatSub, q.slice(hatSub.length).trim());
2523
+ }
2524
+ if (q === 'yes') {
2525
+ const t = R0.hatPending; R0.hatPending = null;
2526
+ if (!t) return say('Nothing to confirm. /hat <name> first.', 'warn');
2527
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2528
+ let conv = S.current.ctx.conversationId;
2529
+ if (!conv) { conv = mintConversationId(randomUUID()); dispatch({ type: 'ctx', ctx: { conversationId: conv } }); }
2530
+ try { await api.bindHat(conv, t.id); } catch (e) { return say(`Hat not switched: ${e?.message || 'the server refused'}.`, 'warn'); }
2531
+ return say(`Hat switched: ${t.from || 'current'} → ${t.name}. Your next message runs on it.`, 'ok');
2532
+ }
2533
+ let hats, active = null;
2534
+ try { hats = await api.hats(); } catch (e) { return say(`Couldn't list hats: ${listFailure(e, profile)}`, 'warn'); }
2535
+ try { active = api.activeHat ? await api.activeHat(S.current.ctx.conversationId || null) : null; } catch { active = null; }
2536
+ if (!q || q === 'list') {
2537
+ if (!hats.length) return say('No hats on this server.', 'warn');
2538
+ const hw = Math.min(26, Math.max(4, ...hats.map(x => [...String(x.name || x.key)].length)));
2539
+ const hrows = hats.map(x => ({ key: `${x.key === active?.key ? '* ' : ' '}${String(x.name || x.key).padEnd(hw)}`, text: `${x.description || x.definition?.description || x.key}${hatIsPrivileged(x) ? ' (privileged: asks you first)' : ''}` }));
2540
+ PK.current.open({ title: 'Change hat for this conversation', rows: hrows, sel: Math.max(0, hats.findIndex(x => x.key === active?.key)), hint: '↑↓ select · Enter put it on · Esc cancel', onPick: (_, i) => command(`/hat ${hats[i].key}`) });
2541
+ return undefined;
2542
+ }
2543
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2544
+ const m = matchHat(q, hats);
2545
+ if (m.ambiguous) return say(`"${q}" matches more than one hat: ${hatChoices(m.ambiguous)}. Be more specific.`, 'warn');
2546
+ if (!m.hat) return say(`No hat matches "${q}". Hats you can pick: ${hatChoices(hats) || 'none'}.`, 'warn');
2547
+ if (active?.key === m.hat.key) return say(`Already wearing the ${m.hat.name || m.hat.key} hat.`);
2548
+ R0.hatPending = { id: m.hat.id, key: m.hat.key, name: m.hat.name || m.hat.key, from: active?.name || null, at: Date.now() };
2549
+ if (!hatIsPrivileged(m.hat)) return command('/hat yes'); // the person typed it: that is the authorization
2550
+ return ask(`Put on the ${R0.hatPending.name} hat?`, { action: `/hat ${m.hat.key}`, reason: 'It is a privileged hat: its tools can change real systems.' });
2551
+ }
2552
+ case '/org': {
2553
+ if (!api.orgs) return say('Org switching is not available in this mode.', 'warn');
2554
+ const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
2555
+ const sub = (restArg.match(/^(\S+)\s*(.*)$/) || [])[1] || '';
2556
+ const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
2557
+ let list;
2558
+ try { list = await api.orgs(); } catch (e) { return say(`Couldn't list your orgs: ${listFailure(e, profile)}`, 'warn'); }
2559
+ if (!sub || sub === 'list' || (sub === 'switch' && !query)) { // a bare /org switch offers the chooser, never a Usage line
2560
+ if (!list.length) return say('No orgs returned for this login.', 'warn');
2561
+ const orows = orgRows(list);
2562
+ const ow = Math.min(30, Math.max(4, ...orows.map(x => [...x.name].length)));
2563
+ PK.current.open({ title: 'Move this session to an org', rows: orows.map(x => ({ key: `${x.id === curId ? '* ' : ' '}${x.name.padEnd(ow)}`, text: `${x.slug || x.id}${x.role ? ` (${x.role})` : ''}` })), sel: Math.max(0, orows.findIndex(x => x.id === curId)), hint: '↑↓ select · Enter switch (you confirm first) · Esc cancel', onPick: (_, i) => (orows[i].id === curId ? say(`Already in ${orows[i].name}.`) : command(`/org switch ${orows[i].slug || orows[i].id}`)) });
2564
+ return undefined;
2565
+ }
2566
+ if (sub === 'yes') {
2567
+ const t = R0.orgPending; R0.orgPending = null;
2568
+ if (!t) return say('Nothing to confirm. /org switch <name> first.', 'warn');
2569
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2570
+ if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
2571
+ let done;
2572
+ try { done = await api.switchOrg(t.id); } catch (e) { return say(`Not switched. ${e?.message || 'The server refused.'} You are still in ${R0.orgName ?? org ?? 'the same org'}.`, 'warn'); }
2573
+ R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
2574
+ R0.orgId = done.id; R0.orgName = done.name;
2575
+ dispatch({ type: 'ctx', ctx: { conversationId: null } });
2576
+ SN.current.name = null; SN.current.saved = null;
2577
+ dispatch({ type: 'history.reset' });
2578
+ if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
2579
+ setEpoch(e => e + 1); bump(n => n + 1);
2580
+ return say(`Switched to ${done.name}. New conversation in that org.`, 'ok');
2581
+ }
2582
+ if (sub === 'back' || sub === '-') { // owner 2026-10-15 "switch back": no "type /org switch <the org you were in>"
2583
+ const p = R0.prevOrg;
2584
+ if (!p) return say('No previous org in this session yet. /org lists yours.', 'warn');
2585
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2586
+ R0.orgPending = { id: p.id, name: p.name, at: Date.now() };
2587
+ if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
2588
+ return ask(`Switch back to ${p.name}?`, { action: `/org switch ${p.id} (${p.name})`, reason: 'This starts a new conversation in that org.' });
2589
+ }
2590
+ if (sub !== 'switch') return say('Usage: /org, /org switch <name or id>, or /org back', 'warn');
2591
+ if (!query) return say('Usage: /org switch <name or id>. /org lists them.', 'warn');
2592
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2593
+ const m = matchOrg(query, list);
2594
+ if (m.ambiguous) return say(`"${query}" matches more than one: ${m.ambiguous.map(o => o.name).join(', ')}. Be more specific.`, 'warn');
2595
+ if (!m.org) return say(`No org of yours matches "${query}". /org lists them.`, 'warn');
2596
+ if (m.org.id === curId) return say(`Already in ${m.org.name}.`);
2597
+ R0.orgPending = { id: m.org.id, name: m.org.name, at: Date.now() };
2598
+ // Already said yes to the suggested "/org switch X? y/N": one confirmation, not two (owner 2026-10-15).
2599
+ if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
2600
+ return ask(`Switch to ${m.org.name}?`, { action: `/org switch ${m.org.id} (${m.org.name})`, reason: 'This starts a new conversation in that org.' });
2601
+ }
805
2602
  case '/skills': {
806
2603
  if (!api.skills) return say('This server has no skills list.', 'warn');
807
2604
  try {
@@ -849,60 +2646,572 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
849
2646
  if (ro.sources) say('Down on an empty composer focuses the roster. Enter peeks, s stops (y confirms), Esc returns.');
850
2647
  return undefined;
851
2648
  }
852
- case '/observers': {
2649
+ case '/vm': {
2650
+ if (!api.vm) return say('/vm is not available in this mode.', 'warn');
2651
+ if (!restArg) return say('Usage: /vm <list|info|doctor|start|stop|reboot|shutdown|snapshot|pool|node> <target> [args]', 'warn');
2652
+ try {
2653
+ const r = await api.vm(restArg.split(/\s+/));
2654
+ for (const l of r.lines) say(l, r.tone || undefined);
2655
+ } catch (e) { dispatch({ type: 'error', text: `vm: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}` }); }
2656
+ return undefined;
2657
+ }
2658
+ case '/hyperion': {
2659
+ if (!api.hyperion) return say('/hyperion is not available in this mode.', 'warn');
2660
+ try {
2661
+ const r = await api.hyperion();
2662
+ for (const l of r.lines) say(l, r.tone || undefined);
2663
+ } catch (e) { dispatch({ type: 'error', text: `hyperion: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}` }); }
2664
+ return undefined;
2665
+ }
2666
+ case '/sitrep': {
2667
+ if (!api.sitrep) return say('/sitrep is not available in this mode.', 'warn');
2668
+ const r = await api.sitrep();
2669
+ return dispatch({ type: 'object', obj: { type: 'sitrep', data: sitrepData(r.situation, r.approvals) } });
2670
+ }
2671
+ case '/afk': { // owner 2026-10-15: "/afk need a break, keep my requests organised for my return"
2672
+ const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: restArg || '' };
2673
+ try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
2674
+ say(`Away from ${new Date(at).toISOString().slice(11, 16)} UTC${restArg ? ` (${clip(restArg, 80)})` : ''}. Approvals and messages keep collecting; when you're back, /back shows everything since, grouped.`, 'ok');
2675
+ const pr = await setPresence('away', restArg);
2676
+ if (!pr.ok) presenceFail(pr);
2677
+ else if (!pr.local) say(`Sessions now see: ${pr.notice || 'you are away'}. Nothing is approved while you are away; /back or any message marks you present.`, 'dim');
2678
+ return undefined;
2679
+ }
2680
+ case '/back': { // PRESENCE: present on the server too, then the digest
2681
+ AWAY.current.afk = null;
2682
+ const pr = await setPresence('present');
2683
+ if (!pr.ok) presenceFail(pr);
2684
+ return command('/away');
2685
+ }
2686
+ case '/observe': { // owner 2026-10-16: a note to the observer (the QUOXON session watching this window)
2687
+ if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
2688
+ if (!restArg.trim()) return say('Usage: /observe <text> (posts into the quoxon slot, for the session that watches your window)', 'warn');
2689
+ if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
2690
+ const r = await api.slotSay('quoxon', restArg.trim(), { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
2691
+ if (!r.ok) return say(`observe: not delivered (${r.message})`, 'warn');
2692
+ return say('✔ noted for the observer (quoxon)', 'ok');
2693
+ }
2694
+ case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
2695
+ const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
2696
+ if (!m) return say(`Usage: ${cmd} <slot or session> [text] (an urgent page; they are asked to acknowledge, and an idle session is woken to read it, even with an unsent draft, which is set aside and put back)`, 'warn');
2697
+ return command(`/page ${m.slot} ${m.text ? m.text.trim() : `are you there? (${cmd.slice(1)} from ${myLabel() || 'the owner'})`}`);
2698
+ }
2699
+ case '/away': {
2700
+ if (!api.away) return say('/away is not available in this mode.', 'warn');
2701
+ const p = parseSince(restArg, { lastSeen: AWAY.current.last });
2702
+ if (!p.ok) return say(p.error, 'warn');
2703
+ try { dispatch({ type: 'object', obj: { type: 'away', data: await api.away(p.since) } }); }
2704
+ catch (e) { say(`away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). Nothing is assumed.`, 'warn'); }
2705
+ return undefined;
2706
+ }
2707
+ case '/mind': {
2708
+ if (!api.mind) return say('/mind is not available in this mode.', 'warn');
2709
+ if (MIND_HELP_ARGS.has(restArg.toLowerCase())) { for (const [text, tone] of mindHelpLines()) say(text, tone); return undefined; }
2710
+ try { for (const [text, tone] of mindLines(await api.mind(restArg || undefined))) say(text, tone); }
2711
+ catch (e) {
2712
+ const isCommand = (n) => REGISTRY.some(x => x.kind === 'command' && x.status === 'active' && x.id === n);
2713
+ for (const [text, tone] of mindErrorLines(e, restArg, { isCommand })) tone === 'error' ? dispatch({ type: 'error', text }) : say(text, tone);
2714
+ }
2715
+ return undefined;
2716
+ }
2717
+ case '/observer': case '/observers': case '/lucid': {
853
2718
  if (!api.observers) return say('Observers are not available in this mode.', 'warn');
854
2719
  try { for (const [text, tone] of observerLines(await api.observers(), cols)) say(text, tone); }
855
2720
  catch (e) { dispatch({ type: 'error', text: `observers: ${e.message}` }); }
856
2721
  return undefined;
857
2722
  }
858
2723
  case '/approvals': {
859
- try {
860
- const items = await api.approvals();
861
- dispatch({ type: 'inbox', items });
862
- dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
863
- if (!items.length) return say('Nothing is waiting for your approval.');
864
- say(`${items.length} waiting. /approve N or /deny N:`, 'warn');
865
- items.slice(0, 9).forEach((it, i) => say(`[${i + 1}] ${clip(it.title || it.summary || it.id, cols - 12)}`));
866
- } catch (e) { dispatch({ type: 'error', text: `approvals: ${e.message}` }); }
2724
+ if (/^help$/i.test(restArg.trim())) { // the syntax lives here, not above every list
2725
+ say(`${mark('/approve N')} ${mark('/deny N <reason>')} ${mark('/approvals N')} detail ${mark('/approvals walkthrough')} one at a time ${mark('/approvals mine|agents|old|<agent>')} filter (privileged: ${mark('/approve N confirm')})`);
2726
+ return undefined;
2727
+ }
2728
+ let items;
2729
+ try { items = await api.approvals(); }
2730
+ catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}). That does not mean nothing is waiting; try again or run: quox inbox list` }); }
2731
+ let stats = null; let statsErr = null;
2732
+ if (api.approvalStats) { try { stats = await api.approvalStats(); } catch (e) { statsErr = e.message; } }
2733
+ const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
2734
+ const groups = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds });
2735
+ const flat = groups.flatMap(g => g.items.map(x => x.item));
2736
+ dispatch({ type: 'inbox', items: flat });
2737
+ R.current.shownIds = flat.map(x => x.id);
2738
+ dispatch({ type: 'notify', patch: { approvals: flat.length, error: null } });
2739
+ const hidden = stats ? Math.max(0, stats.awaiting - flat.length) : 0;
2740
+ if (!flat.length && !hidden && !statsErr) return say('Nothing waiting for you.', 'ok');
2741
+ const fArg = restArg.trim();
2742
+ if (/^walkthrough$/i.test(fArg)) return walkStart(flat, { fresh: true });
2743
+ if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
2744
+ const it = flat[Number(fArg) - 1];
2745
+ if (!it) return say(`No such item: [${fArg}] (${flat.length} listed).`, 'warn');
2746
+ for (const l of approvalDetail(it, Number(fArg))) say(l);
2747
+ say(' ');
2748
+ if (isDecision(it)) { R.current.focusedDecision = { n: Number(fArg), id: it.id, at: Date.now() }; return say(`${mark(`/answer ${fArg} ${it.payload.options.map(o => o.key).join('|')}`)} with an optional "note", or ${mark(`/deny ${fArg} <reason>`)}`, 'warn'); }
2749
+ return say(`${mark(`/approve ${fArg}`)} or ${mark(`/deny ${fArg} <reason>`)}`, 'warn');
2750
+ }
2751
+ const keep = filterPredicate(fArg || null, { userId: api.userId?.() || null, conversationId: S.current.ctx.conversationId });
2752
+ const shown = new Set(flat.filter(keep));
2753
+ const nDec = flat.filter(isDecision).length;
2754
+ const cardFirst = nDec > 0 && !fArg; // plain /approvals opens straight on the one-at-a-time card
2755
+ if (cardFirst) say(`${nDec} question${nDec === 1 ? '' : 's'} waiting for you.`, 'warn');
2756
+ else if (nDec) { const k1 = flat.findIndex(isDecision) + 1; const ks = flat[k1 - 1].payload.options.map(o => o.key).join('|'); say(`${nDec} question${nDec === 1 ? '' : 's'} waiting for you; I take them one at a time below${nDec > 1 ? ' (/yestoall answers every yes/no at once)' : ''}. Typed form: /answer ${k1} ${ks}, with an optional "note".`, 'dim'); }
2757
+ if (cardFirst && flat.length > nDec) say(`${flat.length - nDec} other approval${flat.length - nDec === 1 ? '' : 's'} wait${flat.length - nDec === 1 ? 's' : ''} too: ${mark('/approve N')} or ${mark('/deny N <reason>')}; ${mark('/approvals help')} has the syntax.`, 'dim'); // plain /approvals: the walk takes the questions; the syntax wall lives in /approvals help
2758
+ else if (flat.length > nDec || (fArg && !cardFirst)) say(`${flat.length} waiting${fArg ? `, ${shown.size} match "${fArg}"` : ''}. ${mark('/approve N')} ${mark('/deny N <reason>')} ${mark('/approvals N')} detail ${mark('/approvals mine|agents|old|<agent>')} (privileged: ${mark('/approve N confirm')}):`, 'warn');
2759
+ let n = 0;
2760
+ for (const g of groups) {
2761
+ const rows = g.items.map(x => ({ x, k: ++n })).filter(r => shown.has(r.x.item) && !cardFirst);
2762
+ if (!rows.length) continue;
2763
+ say(' ');
2764
+ say(`${g.group}:`, 'warn');
2765
+ for (const r of rows) { say(approvalRow(r.x.item, r.k, { cols: cols - 2 })); if (isDecision(r.x.item)) say(' '); }
2766
+ }
2767
+ if (flat.length && !shown.size) say(`Nothing matches "${fArg}".`, 'warn');
2768
+ if (flat.length) say(' ');
2769
+ if (nDec && !fArg) walkStart(flat, { fresh: true, quiet: true });
2770
+ if (hidden) {
2771
+ const others = humanProfiles.filter(h => h !== profile);
2772
+ const whoTxt = agentLogin ? `${profile} (an agent login), so it can't approve these` : `${profile}, which cannot act on these`;
2773
+ say(`${hidden} more approval${hidden === 1 ? ' is' : 's are'} waiting org-wide. This window is signed in as ${whoTxt}.${others.length ? ` Quit and run: quox -p ${others[0]}${others.length > 1 ? ` (human profiles found: ${others.join(', ')})` : ''}. Or approve in the dashboard Approvals page, or: quox inbox -p ${others[0]} respond <id> --action approve|reject` : ' Approve in the dashboard Approvals page.'}`, 'warn');
2774
+ if (agentLogin) offerSwitch(); // one keypress instead of quit and relaunch
2775
+ }
2776
+ if (statsErr) say(`Could not check the org-wide approval count (${statsErr}); the list above is what the inbox returned.`, 'warn');
2777
+ return undefined;
2778
+ }
2779
+ case '/feedback': { // owner 2026-10-15: /feedback goes to the quox.ai feedback API (/gripe stays GitHub)
2780
+ if (!api.feedback) return say('/feedback is not available in this mode.', 'warn');
2781
+ const fb = feedbackPayload(restArg, version);
2782
+ if (!fb) return say('Usage: /feedback <text> (5 to about 4900 characters; goes to the Quox team at quox.ai)', 'warn');
2783
+ for (const l of feedbackPreview(fb)) say(clip(l, cols - 4), 'dim');
2784
+ R.current.feedbackAsk = { payload: fb, at: Date.now() };
2785
+ return ask('Send it?', { action: 'send the feedback above to https://api.quox.ai/api/feedback (the Quox team)' });
2786
+ }
2787
+ case '/mouse': {
2788
+ const v = arg.trim().toLowerCase();
2789
+ if (!v) return say(`Mouse is ${MO.current.on ? 'on' : 'off'}. /mouse ${MO.current.on ? 'off' : 'on'} to change it.`);
2790
+ if (v !== 'on' && v !== 'off') return say('Usage: /mouse on|off', 'warn');
2791
+ MO.current.on = v === 'on'; MO.current.drag = null;
2792
+ api.saveSetting?.('mouse', v);
2793
+ mouseTerm(MO.current.on);
2794
+ return say(MO.current.on ? mouseOnNote() : 'Mouse off. Your terminal selects, copies and scrolls as usual again.', MO.current.on ? undefined : 'ok');
2795
+ }
2796
+ case '/r': case '/reply': {
2797
+ if (!R.current.lastSender) return say('Nobody has messaged you in this window yet. /say <slot> <text> starts one.', 'warn');
2798
+ if (!restArg) return say(`Usage: /r <text> (replies to ${R.current.lastSender})`, 'warn');
2799
+ return sendToSlot(R.current.lastSender.toLowerCase(), restArg.trim());
2800
+ }
2801
+ // Dragon verbs (owner 2026-10-15), only where an existing command does exactly that; the rest are an idea, not code.
2802
+ case '/mode': { // PLAN MODE (owner 2026-10-16): /mode [ask|plan]; auto is planned, not built
2803
+ if (!restArg.trim()) { // same arrow-key chooser as /model; /mode -h still explains
2804
+ const cur = normMode(MD.current);
2805
+ PK.current.open({ title: 'Mode for this window', rows: MODE_ROWS.map(x => ({ key: `${x.id === cur ? '* ' : ' '}${x.id}`, text: x.text })), sel: MODES.indexOf(cur), hint: '↑↓ select · Enter use · Esc cancel', onPick: (_, i) => command(`/mode ${MODES[i]}`) });
2806
+ return undefined;
2807
+ }
2808
+ const m = modeReply(restArg, MD.current);
2809
+ if (m.mode) { MD.current = m.mode; R.current.planAsk = null; bump(n => n + 1); }
2810
+ return say(m.text, m.tone || undefined);
2811
+ }
2812
+ case '/sleep': { // owner typed /sleep first (2026-10-16); it now MEANS sleeping: sessions stop waiting on approvals
2813
+ const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: 'sleeping' };
2814
+ try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
2815
+ const pr = await setPresence('sleeping');
2816
+ if (!pr.ok) presenceFail(pr);
2817
+ return command('/dragon sleep');
2818
+ }
2819
+ case '/roar': return command(`/broadcast${restArg ? ` ${restArg}` : ''}`);
2820
+ case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
2821
+ case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
2822
+ case '/huh': return command(`/dragon explain${restArg ? ` ${restArg}` : ''}`); // owner 2026-10-15: shortcut for /dragon explain
2823
+ case '/slots': return listSlots(/^all$/i.test(restArg.trim()));
2824
+ case '/mirror': {
2825
+ // The Mirror itself: this org's server-built Mirror, what CommanderQ is told every turn (not /mind).
2826
+ if (!api.mirror) return say('/mirror is not available in this mode.', 'warn');
2827
+ const view = /^agents?$/i.test(restArg.trim()) ? 'agents' : null;
2828
+ try { dispatch({ type: 'object', obj: { type: 'mirror', data: { ...(await api.mirror()), ...(view ? { view } : {}) } } }); }
2829
+ catch (e) { say(mirrorErrorNote(e), 'warn'); }
2830
+ return undefined;
2831
+ }
2832
+ case '/core': case '/health': case '/collector-health': return command('/status'); // owner reached for these first; /health and /core are the dashboard card above when the objects api is there
2833
+ case '/walkthrough': return command('/approvals walkthrough');
2834
+ case '/yestoall': return command(`/approve all${arg ? ` ${arg}` : ''}`); // owner 2026-10-15: alias of /approve all
2835
+ case '/authenticate': {
2836
+ if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
2837
+ await SUC.current.authenticate(); bump(n => n + 1); return undefined;
2838
+ }
2839
+ case '/override': {
2840
+ const res = await SUC.current.override(restArg);
2841
+ if (res?.override_id && res.enforced) R.current.override = { id: res.override_id, until: Date.parse(res.expires_at) || Date.now() + 300000 };
2842
+ return undefined;
2843
+ }
2844
+ case '/authorise': case '/authorize': {
2845
+ if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
2846
+ let authArg = restArg;
2847
+ if (!authArg.trim()) { // QUOXON-1191: bare /authorise picks the one item that needs a PIN; with several it lists them
2848
+ let li = S.current.inbox;
2849
+ if (!li.length) { try { li = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: li }); } catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); } }
2850
+ const needs = li.map((x, k) => ((isDecision(x) ? x.payload.high_risk === true && x.payload.options.some(o => o.key === 'y') : (x.payload?.approval_context?.risk === 'high' || x.payload?.high_risk === true)) ? k + 1 : 0)).filter(Boolean);
2851
+ if (needs.length === 1) authArg = String(needs[0]);
2852
+ else return say(needs.length ? `${needs.length} items need your PIN, so say which: ${needs.map(k => mark(`/authorise ${k}`)).join(' ')}` : 'Usage: /authorise N (approve item N with your PIN; list them with /approvals). Nothing waiting needs a PIN right now.', 'warn');
2853
+ }
2854
+ R.current.authorising = true; // /approve's answer() asks for the PIN first when there is no live step-up
2855
+ try { await command(`/approve ${authArg}`); } finally { R.current.authorising = false; }
2856
+ return undefined;
2857
+ }
2858
+ case '/answer': { // quox ask: pick an option of a decision a session asked
2859
+ const words = line.trim().split(/\s+/).slice(1);
2860
+ const fo = R.current.focusedDecision;
2861
+ const pa = parseAnswer(words, { focusN: fo && Date.now() - fo.at < 600000 ? fo.n : null, raw: line.trim().replace(/^\S+\s*/, '') });
2862
+ if (pa.bad) return say(`Usage: ${mark('/answer 3 b')} (item number, then the option letter), optionally with a note: ${mark('/answer 3 b "after Friday"')}. Several at once: ${mark('/answer 1 y 2 n')}. List them with ${mark('/approvals')}.`, 'warn');
2863
+ if (await listChanged()) return undefined;
2864
+ let list = S.current.inbox;
2865
+ if (!list.length || (pa.n ? !list[pa.n - 1] : true)) {
2866
+ try {
2867
+ const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
2868
+ list = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item));
2869
+ dispatch({ type: 'inbox', items: list });
2870
+ } catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
2871
+ }
2872
+ if (pa.ns) { // QUOXON-1191: /answer 1-5 y, /answer 1,3,4 y: validate all, ask the PIN once if any is high risk
2873
+ const keyOf = (k) => pa.keys?.[k] ?? pa.key; // QUOXON-1191b: pairs carry their own y|n per item
2874
+ if (pa.ns.some(k => !list[k - 1])) {
2875
+ try { list = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: list }); }
2876
+ catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
2877
+ }
2878
+ if (pa.note && noteNeedsConfirm(pa.note) && !R.current.noteBypass) { // NOTE-CONFIRM: one card for the whole batch
2879
+ const base = pa.keys ? pa.ns.map(k => `${k} ${pa.keys[k]}`).join(' ') : `${pa.ns.join(',')} ${pa.key}`;
2880
+ noteCard({ walk: false, batch: true, count: pa.ns.length, cmd: base, key: pa.key, label: pa.keys ? [...new Set(Object.values(pa.keys))].join('/') : pa.key, note: pa.note });
2881
+ return undefined;
2882
+ }
2883
+ const bad = pa.ns.filter(k => !list[k - 1] || !isDecision(list[k - 1]) || !list[k - 1].payload.options.some(o => o.key === keyOf(k)));
2884
+ if (bad.length) return say(`Nothing sent. ${bad.map(k => `[${k}]`).join(' ')} ${bad.length === 1 ? 'is' : 'are'} not ${list.length ? `a decision with option "${bad.map(keyOf).filter((v, i, a2) => a2.indexOf(v) === i).join('/')}"` : 'listed'}. Use ${mark('/approvals')} to see the options.`, 'warn');
2885
+ const bulkAns = pa.ns.length > 1 && !pa.keys; // explicit per-item pairs (1 y 2 n) name each item and key themselves
2886
+ let walkAfter = null;
2887
+ const heldNs = bulkAns ? pa.ns.filter(k => keyOf(k) !== 'n' && isHeldFromBulk(list[k - 1])) : []; // a bulk yes never covers a risky ask
2888
+ if (heldNs.length) {
2889
+ pa.ns = pa.ns.filter(k => !heldNs.includes(k));
2890
+ say(`${heldNs.length} risky decision${heldNs.length === 1 ? '' : 's'} held back from the bulk yes (${[...new Set(heldNs.map(k => heldReason(list[k - 1])))].join(', ')}). I will take ${heldNs.length === 1 ? 'it' : 'them'} one at a time, with the full text, after the rest.`, 'warn');
2891
+ walkAfter = heldNs.map(k => list[k - 1]);
2892
+ }
2893
+ const items = pa.ns.map(k => list[k - 1]);
2894
+ const gate = pa.ns.some(k => keyOf(k) !== 'n' && list[k - 1].payload.high_risk === true);
2895
+ const askPin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject: { line1: `answer ${items.length} decisions ${[...new Set(pa.ns.map(keyOf))].join('/')}`, raisedBy: null, risk: 'high' } }); if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; } };
2896
+ try { if (gate && !SU.current.token('approve')) await askPin(); }
2897
+ catch (e) { return say(`Not answered: ${e.message}. Nothing was sent.`, 'warn'); }
2898
+ const okIds = []; const failedK = [];
2899
+ for (const k of pa.ns) {
2900
+ const x = list[k - 1];
2901
+ const go = () => api.answerDecision(x.id, keyOf(k), { note: pa.note || null, stepUp: SU.current.headers('approve'), bulk: bulkAns });
2902
+ try {
2903
+ try { await go(); }
2904
+ catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
2905
+ okIds.push(x.id); walkAnsweredIds().add(x.id);
2906
+ say(`✔ answered [${k}] ${x.payload.session || 'session'}: ${clip(x.title || x.id, Math.max(20, cols - 40))} -> (${keyOf(k)})`, 'ok');
2907
+ if (x.payload.slot && api.slotSay) await api.slotSay(String(x.payload.slot).toLowerCase(), `Your question "${clip(x.title, 80)}" was answered: ${keyOf(k)}. Read it with: quox ask status ${x.id}`, { from: myLabel() });
2908
+ } catch (e) { failedK.push(`[${k}] ${e?.status === 404 || e?.status === 400 ? 'already handled or expired' : e.message}`); if (e?.stepUpDeclined) break; }
2909
+ }
2910
+ if (failedK.length) say(`Not answered: ${failedK.join('; ')}`, 'warn');
2911
+ R.current.focusedDecision = null;
2912
+ dispatch({ type: 'inbox', items: list.filter(x => !okIds.includes(x.id)) });
2913
+ if (R.current.shownIds) R.current.shownIds = list.filter(x => !okIds.includes(x.id)).map(x => x.id);
2914
+ if (walkAfter) walkStart(walkAfter);
2915
+ return undefined;
2916
+ }
2917
+ if (!pa.n) { // no number: fine when exactly one decision is open
2918
+ const idx = list.map((x, k) => (isDecision(x) ? k + 1 : 0)).filter(Boolean);
2919
+ if (idx.length !== 1) return say(idx.length ? `${idx.length} decisions are open, so say which: ${idx.slice(0, 3).map(k => mark(`/answer ${k} ${list[k - 1].payload.options.map(o => o.key).join('|')}`)).join(' ')}` : 'No decision is waiting for an answer.', 'warn');
2920
+ pa.n = idx[0];
2921
+ }
2922
+ const it = list[pa.n - 1];
2923
+ if (!it) return say(`No such item: [${pa.n}] (${list.length} listed).`, 'warn');
2924
+ if (!isDecision(it)) return say(`[${pa.n}] is an approval, not a question. Use ${mark(`/approve ${pa.n}`)} or ${mark(`/deny ${pa.n} <reason>`)}.`, 'warn');
2925
+ if (!it.payload.options.some(o => o.key === pa.key)) return say(`[${pa.n}] has no option "${pa.key}". Options: ${it.payload.options.map(o => `(${o.key}) ${o.label}`).join(' ')}`, 'warn');
2926
+ if (pa.note && noteNeedsConfirm(pa.note) && !R.current.noteBypass) { noteCard({ walk: false, cmd: `${pa.n} ${pa.key}`, key: pa.key, label: it.payload.options.find(o => o.key === pa.key).label, note: pa.note, id: it.id }); return undefined; }
2927
+ say(`Answering: ${clip(it.title || it.id, Math.max(20, cols - 30))} → ${it.payload.options.find(o => o.key === pa.key).label} (${pa.key})${pa.note ? `, with your note (${pa.note.length} characters)` : ''}`, 'dim'); // DECISION-LABELS: say which question and which option BEFORE it is sent
2928
+ try { // QUOXON-1191: a yes to a high-risk ask needs the PIN: ask up front, or on the server's step_up_required, then retry once
2929
+ const gate = it.payload.high_risk === true && pa.key !== 'n';
2930
+ const subject = { line1: `answer ${String(it.title || "")}`, raisedBy: it.payload.session || null, risk: 'high' };
2931
+ const askPin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject }); if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; } };
2932
+ if (gate && !SU.current.token('approve')) await askPin();
2933
+ const go = () => api.answerDecision(it.id, pa.key, { note: pa.note || null, stepUp: SU.current.headers('approve') });
2934
+ try { await go(); }
2935
+ catch (e) {
2936
+ if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
2937
+ SU.current.drop('approve'); await askPin(); await go();
2938
+ }
2939
+ }
2940
+ catch (e) {
2941
+ if (e?.stepUpDeclined) return say(R.current.walkSend ? `Not answered: ${e.message}.` : `Not answered: ${e.message}. Run ${mark(`/answer ${pa.n} ${pa.key}`)} again when you can confirm.`, 'warn'); // in a walkthrough the card says what to do next
2942
+ if (e?.status === 404 || e?.status === 400) { R.current.goneAsk = it.id; return say(`[${pa.n}] ${expiredText(it)}`, 'accent'); }
2943
+ return dispatch({ type: 'error', text: `answer: ${e.message}` });
2944
+ }
2945
+ say(' ');
2946
+ say(`✔ answered [${pa.n}] ${it.payload.session || 'session'}: ${clip(it.title || it.id, Math.max(20, cols - 40))} -> (${pa.key}) ${it.payload.options.find(o => o.key === pa.key).label}${pa.note ? ` (note: ${clip(pa.note, 40)})` : ''}`, 'ok');
2947
+ say(' ');
2948
+ R.current.lastAnswered = it.id; walkAnsweredIds().add(it.id);
2949
+ if (it.payload.slot && api.slotSay) { // WALK-FIXES: the answer is already recorded; the slot post never holds up the next card
2950
+ const slotNote = (m) => say(`Answer recorded; could not post it to slot ${it.payload.slot} (${m}). The asker gets it from: quox ask wait ${it.id}`, 'warn');
2951
+ const posting = Promise.resolve().then(() => api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was answered: ${pa.key}${pa.note ? ` (${pa.note})` : ''}. Read it with: quox ask status ${it.id}`, { from: myLabel() })).then(r => { if (!r?.ok) slotNote(r?.message || 'no reply'); }, e => slotNote(e?.message || 'failed'));
2952
+ if (!R.current.walkSend) await posting;
2953
+ }
2954
+ R.current.focusedDecision = null;
2955
+ dispatch({ type: 'inbox', items: list.filter(x => x.id !== it.id) });
2956
+ if (R.current.shownIds) R.current.shownIds = list.filter(x => x.id !== it.id).map(x => x.id);
867
2957
  return undefined;
868
2958
  }
869
2959
  case '/approve': case '/deny': {
870
- const it = S.current.inbox[Number(arg) - 1];
871
- if (!it) return say('Run /approvals first, then /approve N.', 'warn');
872
- try {
873
- await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: clip(it.title || it.id, cols - 40), target: null }, cmd === '/approve' ? 'approve' : 'reject');
874
- } catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); }
2960
+ const verb = cmd === '/approve' ? 'approve' : 'reject';
2961
+ const words = line.trim().split(/\s+/).slice(1);
2962
+ const prs = cmd === '/approve' ? parsePairs(words) : null; // QUOXON-1191b: /approve 1 y 2 n, 1y 2n
2963
+ if (prs) {
2964
+ if (prs.bad) return say(`Could not read "${words.join(' ')}". Use each number once: ${mark('/approve 1 y 2 n')}.`, 'warn');
2965
+ let pl = S.current.inbox;
2966
+ if (!pl.length || prs.some(x => !pl[x.k - 1])) {
2967
+ try { pl = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: pl }); }
2968
+ catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
2969
+ }
2970
+ const missingP = prs.filter(x => !pl[x.k - 1]);
2971
+ if (missingP.length) return say(`Nothing sent. ${missingP.map(x => `[${x.k}]`).join(' ')} ${missingP.length === 1 ? 'is' : 'are'} not listed (${pl.length} listed). Use ${mark('/approvals')}.`, 'warn');
2972
+ const decP = prs.filter(x => isDecision(pl[x.k - 1])); const plainP = prs.filter(x => !isDecision(pl[x.k - 1]));
2973
+ if (decP.length) await command(`/answer ${decP.map(x => `${x.k} ${x.key}`).join(' ')}`);
2974
+ for (const x of plainP) await command(x.key === 'y' ? `/approve ${x.k}` : `/deny ${x.k}`);
2975
+ return undefined;
2976
+ }
2977
+ const pd = parseDecision(words, verb, { raw: line.trim().replace(/^\S+\s*/, '') });
2978
+ const { confirm, all, agent, reason } = pd; const nums = pd.nums;
2979
+ const spec = words.filter(w => w !== 'confirm');
2980
+ const usage = `Usage: ${mark(`${cmd} N [N...]`)} | ${mark(`${cmd} 1,2`)}${verb === 'approve' ? ` | ${mark('/approve 1 y 2 n')}` : ''} | ${mark(`${cmd} 1-3`)} | ${mark(`${cmd} all`)} | ${mark(`${cmd} agent <name>`)}${verb === 'reject' ? ` (add a reason after the target: ${mark('/deny 2 wrong host')})` : ''} (privileged ones need: ${mark(`${cmd} N confirm`)}). List them with ${mark('/approvals')}.`;
2981
+ if (pd.bad) return say(`${spec.length ? `Could not read "${spec.join(' ')}". ` : ''}${usage}`, 'warn');
2982
+ if (!all && !agent && nums.length && await listChanged()) return undefined;
2983
+ let list = S.current.inbox;
2984
+ if (!list.length || (!all && nums.some(k => !list[k - 1]))) { // never send the user back to /approvals: fetch the same list, in the same order, ourselves
2985
+ try {
2986
+ const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
2987
+ list = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item));
2988
+ dispatch({ type: 'inbox', items: list });
2989
+ } catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
2990
+ }
2991
+ if (!list.length) return say('Nothing is waiting for your approval.', 'warn');
2992
+ let targets = all ? list.map((_, k) => k + 1) : [...new Set(nums)];
2993
+ if (agent) {
2994
+ targets = list.map((it, k) => (matchesAgent(it, agent) ? k + 1 : 0)).filter(Boolean);
2995
+ if (!targets.length) return say(`Nothing waiting from "${agent}". ${mark('/approvals agents')} shows who is asking.`, 'warn');
2996
+ }
2997
+ const privOf = (it) => isPrivileged({ tool: it.payload?.approval_context?.method?.tool, risk: it.payload?.approval_context?.risk, flag: it.payload?.privileged === true || it.payload?.approval_context?.privileged === true, title: it.title });
2998
+ const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = [];
2999
+ const heldDecisions = []; const done = []; const heldPriv = []; const failed = []; const missing = []; const gone = [];
3000
+ const label = (k) => { const it = list[k - 1]; return `[${k}] ${approvalSourceLabel(it)}: ${approvalWhat(it)}`; }; // owner 2026-10-15: no repeated who, no cut at all (lines wrap); the server caps summary length
3001
+ for (const k of targets) {
3002
+ const it = list[k - 1];
3003
+ if (!it) { missing.push(k); continue; }
3004
+ if (isDecision(it)) {
3005
+ if (all || agent) { heldDecisions.push(k); continue; } // a bulk approve never answers a human question for you
3006
+ if (verb === 'approve' && bulkish && isHeldFromBulk(it)) { heldRisky.push(k); continue; } // risky asks are never part of a bulk yes
3007
+ if (verb === 'approve' && !bulkish && it.payload.options.some(o => o.key === 'y')) say(`Answering: ${it.title || it.id} → ${it.payload.options.find(o => o.key === 'y').label} (y)`, 'dim');
3008
+ if (verb === 'approve' && !it.payload.options.some(o => o.key === 'y')) { failed.push(`[${k}] is a question, not yes/no: /answer ${k} ${it.payload.options.map(o => o.key).join('|')} ${it.payload.options.map(o => `(${o.key}) ${o.label}`).join(' ')}`); continue; }
3009
+ }
3010
+ if (verb === 'approve' && privOf(it) && !(confirm && !all && !agent)) { heldPriv.push(k); continue; }
3011
+ try {
3012
+ const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
3013
+ const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
3014
+ if (r?.gone) gone.push(k); else if (r?.ok) done.push(k);
3015
+ } catch (e) { failed.push(`[${k}] ${e.message}`); }
3016
+ }
3017
+ if (done.length || gone.length || failed.length || missing.length) say(' ');
3018
+ for (const k of done) say(`${verb === 'approve' ? '✔ approved' : '✔ denied'} ${label(k)}${reason ? ` (reason: ${clip(reason, 40)})` : ''}`, 'ok');
3019
+ if (gone.length) say(`already handled: ${gone.map(k => `[${k}]`).join(' ')}`, 'accent');
3020
+ if (done.length > 1) say(`${verb === 'approve' ? 'Approved' : 'Denied'}: ${done.map(k => `[${k}]`).join(' ')}`, 'ok');
3021
+ if (missing.length) say(`No such item: ${missing.map(k => `[${k}]`).join(' ')} (${list.length} listed).`, 'warn');
3022
+ for (const f of [...new Set(failed)]) dispatch({ type: 'error', text: `approval: ${f}` });
3023
+ for (const k of done) { // quox ask: tell the asking session on its slot, as /answer does
3024
+ const it = list[k - 1];
3025
+ if (isDecision(it) && it.payload.slot && api.slotSay) await api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was ${verb === 'approve' ? 'answered: y' : `declined${reason ? ` (${reason})` : ''}`}. Read it with: quox ask status ${it.id}`, { from: myLabel() });
3026
+ }
3027
+ if (done.length || gone.length || failed.length || missing.length) say(' ');
3028
+ const letteredHeld = heldDecisions.filter(k => !list[k - 1].payload.options.some(o => o.key === 'y'));
3029
+ const riskyYn = verb === 'approve' ? heldDecisions.filter(k => !letteredHeld.includes(k) && isHeldFromBulk(list[k - 1])) : [];
3030
+ const walkKs = [...new Set([...letteredHeld, ...riskyYn, ...heldRisky])].sort((a, b) => a - b);
3031
+ const ynShown = heldDecisions.filter(k => !letteredHeld.includes(k) && !riskyYn.includes(k));
3032
+ const ynHeld = verb === 'approve' && all && !agent ? ynShown : [];
3033
+ if (ynShown.length) say(`${ynShown.length} decision${ynShown.length === 1 ? '' : 's'} left for you to pick: ${ynShown.map(k => `/answer ${k} ${list[k - 1].payload.options.map(o => o.key).join('|')}`).join(', ')} (or /deny ${ynShown.join(',')} <reason>)`, 'warn');
3034
+ if (ynHeld.length) { // ONE card for the LOW-RISK y/N decisions only, each listed in full; risky ones were held back for the walkthrough
3035
+ for (const k of ynHeld) say(` [${k}] ${list[k - 1].title || list[k - 1].id}`, 'dim');
3036
+ R.current.pendingGrant = { cmd, verb, items: ynHeld.map(k => list[k - 1]), ks: ynHeld, decisions: true, hi: 0, at: Date.now(), thenWalk: walkKs.length ? walkKs.map(k => list[k - 1]) : null };
3037
+ ask(`Answer yes to ${ynHeld.length === 1 ? 'it' : `all ${ynHeld.length}`}?`, { who: '', action: `/answer ${ynHeld.join(',')} y`, reason: `${walkKs.length ? `${walkKs.length} risky or lettered held back for the walkthrough. ` : ''}Each one listed above is answered yes.`, keys: 'y answers yes to these, N cancels' });
3038
+ }
3039
+ if (walkKs.length) {
3040
+ say(`${walkKs.length} question${walkKs.length === 1 ? '' : 's'} held back from any bulk yes (${[...new Set(walkKs.map(k => heldReason(list[k - 1]) || 'needs a real choice'))].join(', ')}). I will take ${walkKs.length === 1 ? 'it' : 'them'} one at a time, with the full text, each with your own yes.`, 'warn');
3041
+ if (!ynHeld.length) walkStart(walkKs.map(k => list[k - 1])); // with a bulk card up, the walkthrough starts once the card is answered
3042
+ }
3043
+ if (heldPriv.length && (all || agent)) say(`${heldPriv.length} privileged left: ${heldPriv.map(k => `${cmd} ${k} confirm`).join(', ')}`, 'warn');
3044
+ else if (heldPriv.length) { // ask inline instead of sending the user to a second command
3045
+ R.current.pendingGrant = { cmd, verb, items: heldPriv.map(k => list[k - 1]), at: Date.now() };
3046
+ for (const k of heldPriv) {
3047
+ const it = list[k - 1]; const c = it.payload?.approval_context || {};
3048
+ const tools = [].concat(c.elevated_tools || c.tools || it.payload?.tools || c.method?.target || []).filter(Boolean).join(', ');
3049
+ say(`Grant ${clip(it.title || it.id, cols - 30)} (privileged)${tools ? `: elevated tools ${tools}` : ''}${c.on_behalf_of?.conversation_id ? ' for that conversation' : ''}`, 'warn');
3050
+ }
3051
+ ask(`Grant ${heldPriv.length === 1 ? 'it' : `all ${heldPriv.length}`}?`, { who: heldPriv.map(k => approvalSourceLabel(list[k - 1])).filter(Boolean)[0] || '', action: `${cmd} ${heldPriv.join(' ')} confirm`, reason: 'privileged: it grants elevated tools.', keys: 'y grants, anything else cancels' });
3052
+ }
875
3053
  return undefined;
876
3054
  }
877
3055
  default: {
878
3056
  const e = findEntry(cmd);
879
3057
  if (e && e.status === 'planned') return say(`${e.usage} is planned, not built yet. /help shows what works today.`, 'warn');
880
- return say(`Unknown command ${cmd}. Try /help`, 'warn');
3058
+ if (!e && await slotNameCommand(cmd, restArg)) return undefined;
3059
+ const cliHit = resolveCliSlash(cmd, restArg); // QUOXON-FW: slash words that name a real quox CLI leaf
3060
+ if (cliHit) return cliSlashRun(cliHit);
3061
+ const loose = looseCommand(cmd, restArg);
3062
+ if (loose?.run) { say(`ran ${loose.name}`, 'dim'); return command(loose.run); }
3063
+ if (loose?.suggest) return say(`Unknown command ${cmd}. Did you mean ${loose.suggest}? (/help lists them)`, 'warn');
3064
+ if (loose?.ask) { R.current.askQuox = { text: loose.ask, at: Date.now() }; return ask('Ask Quox instead?', { action: `send as a chat turn: ${loose.ask}`, reason: 'Not a command.' }); }
3065
+ const near = nearestCommands(cmd);
3066
+ if (!near.length) return say(`Unknown command ${cmd}. Try /help`, 'warn');
3067
+ // spelling-only hints keep the plain "/a or /b" line; keyword hints say what each command does
3068
+ const byWords = near.some(x => x.kw);
3069
+ say(`Unknown command ${cmd}. Did you mean ${byWords ? near.map(x => (x.desc ? `${x.name} (${x.desc})` : x.name)).join(', ') : near.map(x => x.name).join(' or ')}? (/help lists them)`, 'warn');
3070
+ if (near.length === 1) { // one strong match: offer it as a y/N card (vetted, never a human-typed command)
3071
+ const v = vetSuggestion(near[0].name);
3072
+ if (v.ok) { R.current.suggestAsk = { line: v.line, at: Date.now() }; ask(`Run ${v.line}?`, { action: v.line, reason: 'Nearest command to what you typed.' }); }
3073
+ }
3074
+ return undefined;
3075
+ }
3076
+ }
3077
+ }
3078
+
3079
+ // Closest slots (with host) and agents for a mistyped name, each labelled which is which.
3080
+ async function nearbyTargets(name, slots, agents = null) {
3081
+ let ag = agents;
3082
+ if (!ag && api.agents) { try { ag = await api.agents(); } catch { ag = null; } }
3083
+ const out = [];
3084
+ const sl = Array.isArray(slots) ? slots : [];
3085
+ for (const n of nearest(name, sl.map(x => x.name))) {
3086
+ const row = sl.find(x => x.name === n);
3087
+ const host = row?.holder_identity ? String(row.holder_identity).replace(/^resident:/, '') : (row?.host_name || row?.hostname || null); // never host_id: an internal id
3088
+ out.push(`slot ${n}${host ? ` (${host})` : ''}`);
3089
+ }
3090
+ const ids = (Array.isArray(ag) ? ag : []).map(a => String(a?.agent_id ?? a?.id ?? a?.name ?? ''));
3091
+ for (const n of nearest(name, ids)) out.push(`agent ${n}`);
3092
+ return out;
3093
+ }
3094
+
3095
+ // QUOXON-ASK y/N/c (owner 2026-10-15 "i need y/n/chat more"): the open composer asks, each with its own answer
3096
+ // handler below. An ask counts as open while its 2-minute window runs.
3097
+ const ASK_TTL = 120000;
3098
+ function openAskStates() {
3099
+ const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
3100
+ const W = R.current.pendingWalk; const walkCardSt = W && W.card && Date.now() - W.at < WALK_TTL ? W.card : null; // WALK-FIXES: one card per decision, open as long as the walk (30 min), not the 2-minute ask clock
3101
+ return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.profileAsk, R.current.cliAsk, R.current.updateAsk, R.current.orgPending, R.current.hatPending, HW.current.card(), RW.current.walk.card(), R.current.suggestAsk && !R.current.suggestLine ? R.current.suggestAsk : null, R.current.handoffAsk, R.current.askQuox, R.current.strayAsk, R.current.planAsk, SC.current.pendingFile, SC.current.reattach, CH.current.pending, SPN.current.pending].map(fresh)].filter(Boolean);
3102
+ }
3103
+ const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || UW.current.pending?.() || runAskOpen();
3104
+ function touchAsks() { for (const x of openAskStates()) x.at = Date.now(); KC.current.touch?.(); UW.current.touch?.(); }
3105
+ // ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): each live ask card is
3106
+ // bound to the open state it asks about; once that state is no longer open the card goes and ONE line stays:
3107
+ // "✔ Wake it now? yes (00:44)", "⌛ ... expired, not answered", "↷ ... no longer open". Run requests likewise.
3108
+ const askBind = React.useRef(new Map()); // card id -> its state object | 'kc' | 'uw' | 'any'
3109
+ function askStateOpen(st) {
3110
+ if (st === 'kc') return Boolean(KC.current.pending?.());
3111
+ if (st === 'uw') return Boolean(UW.current.pending?.());
3112
+ if (st === 'any') return askOpen();
3113
+ return openAskStates().includes(st);
3114
+ }
3115
+ function settleRun(rq, outcome, now = Date.now()) {
3116
+ SC.current.runCards?.delete(rq.askId);
3117
+ dispatch({ type: 'slotmsg.settle', askId: rq.askId, text: settledAskLine(`Run ${clip(String(rq.cmd || ''), Math.max(20, cols - 40))}?`, outcome, now), tone: settledAskTone(outcome.kind) });
3118
+ }
3119
+ function reconcileAsks() {
3120
+ const now = Date.now();
3121
+ const takeAnswer = () => { const a = R.current.askAnswer; R.current.askAnswer = null; return a && now - a.at < 3000 ? a : null; };
3122
+ const bind = askBind.current;
3123
+ const cards = [...(S.current.asks || [])].reverse(); // newest first: an answer belongs to the newest card it closes
3124
+ for (const cd of cards) {
3125
+ if (!bind.has(cd.id)) {
3126
+ const taken = new Set(bind.values());
3127
+ const st = openAskStates().filter(x => !taken.has(x)).sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0];
3128
+ bind.set(cd.id, st || (KC.current.pending?.() ? 'kc' : UW.current.pending?.() ? 'uw' : 'any'));
3129
+ }
3130
+ const st = bind.get(cd.id);
3131
+ if (askStateOpen(st)) continue;
3132
+ bind.delete(cd.id);
3133
+ const expired = st && typeof st === 'object' ? now - (st.at ?? 0) >= ASK_TTL : false;
3134
+ if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
3135
+ takeAnswer();
3136
+ if (st.walkWhy === 'stop') dispatch({ type: 'ask.settle', id: cd.id, text: 'The question closed before you answered.', tone: 'plain' });
3137
+ else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
3138
+ continue;
881
3139
  }
3140
+ const outcome = expired ? { kind: 'expired' } : takeAnswer() || { kind: st === 'any' || st === 'kc' || st === 'uw' ? 'expired' : 'closed' };
3141
+ dispatch({ type: 'ask.settle', id: cd.id, text: settledAskLine(askQuestion(cd.text), outcome, now), tone: settledAskTone(outcome.kind) });
3142
+ }
3143
+ for (const [id, rq] of [...(SC.current.runCards || new Map())]) {
3144
+ const live = SC.current.runAsk?.askId === id;
3145
+ if (live && runAskOpen()) continue;
3146
+ const expired = rq.expiresAt != null && (runNow() >= rq.expiresAt || now >= rq.expiresAt);
3147
+ const outcome = expired ? { kind: 'expired' } : (!live && takeAnswer()) || { kind: 'closed' };
3148
+ settleRun(rq, outcome, now);
882
3149
  }
883
3150
  }
3151
+ React.useEffect(() => {
3152
+ const t = setInterval(() => { if ((S.current.asks || []).length || SC.current.runCards?.size) reconcileAsks(); }, 1000);
3153
+ t.unref?.();
3154
+ return () => clearInterval(t);
3155
+ }, []);
3156
+ /** c or free text while an ask is open: keep it open, send a chat turn about it, and show the same ask again after. */
3157
+ function chatAboutAsk(text) {
3158
+ const la = R.current.lastAsk;
3159
+ const q = la?.question || 'the question above';
3160
+ touchAsks();
3161
+ R.current.reask = la || null;
3162
+ const msg = askChatMessage(text, q);
3163
+ touchSeen(); dispatch({ type: 'user', text: /^c(hat)?$/i.test(text.trim()) ? msg : text.trim() }); remember(text); R.current.histIdx = -1;
3164
+ runTurn(msg);
3165
+ }
884
3166
 
885
3167
  function onEnter(now) {
886
3168
  if (now - K.current.lastPasteAt < PASTE_GUARD_MS) return; // post-paste guard
887
3169
  if (now - K.current.cardGoneAt < CARD_GONE_MS) return; // keys meant for the card must not send a turn
888
- const buf = B.current;
3170
+ let buf = B.current;
3171
+ // y/N/c: Enter alone answers an open ask "no" (the default), as typing n does.
3172
+ if (!buf.text.trim() && R.current.pendingWalk && !R.current.denyReason) { say('Type a choice for the card above (s skips, Esc stops). Enter alone answers nothing.', 'dim'); return; } // WALK-FIXES: an empty Enter must never answer "no"
3173
+ if (!buf.text.trim() && !R.current.denyReason && !S.current.turn && askOpen()) { B.current = { ...initialBuffer(), text: 'n', cursor: 1 }; buf = B.current; }
889
3174
  if (buf.text.endsWith('\\')) { edit({ type: 'set', text: buf.text.slice(0, -1) + '\n', keepPastes: true }); return; }
890
3175
  if (!buf.text.trim()) {
3176
+ if (R.current.noteAsk && Date.now() - R.current.noteAsk.at < 300000) { const st = R.current.noteAsk; R.current.noteAsk = null; noteResolve('1', st); return; } // Enter on the note card = the recommended choice
3177
+ if (R.current.denyReason) { const d = R.current.denyReason; R.current.denyReason = null; if (Date.now() - d.at < 120000) { answer(d.card, 'reject'); return; } } // Enter on "Reason?" skips it
891
3178
  const s = S.current;
892
- if (!s.turn && s.pending.length) { dispatch({ type: 'queue.shift' }); const n = s.pending[0]; dispatch({ type: 'user', text: n.text, queued: true }); runTurn(n.text); }
3179
+ if (!s.turn && s.pending.length) { dispatch({ type: 'queue.shift' }); runQueued(s.pending[0].text); }
893
3180
  return;
894
3181
  }
895
3182
  const chk = sanitizeForSend(buf);
896
3183
  if (!chk.ok) { B.current = { ...chk.buffer, undo: buf.undo, redo: buf.redo }; say(chk.note, 'warn'); bump(n => n + 1); return; }
897
- const text = chk.text.trim();
3184
+ let text = chk.text.trim();
3185
+ const wrapped = joinWrappedSlash(text); // a wrapped paste of "/say slot long text": one command, not a refusal
3186
+ if (wrapped) { text = wrapped.text; say(`joined ${wrapped.lines} lines`, 'dim'); }
898
3187
  const keyHit = looksLikeKey(text);
899
3188
  if (keyHit) { B.current = initialBuffer(); R.current.confirmMulti = null; say(keyWarning(keyHit), 'warn'); return; } // never sent, never in history
3189
+ if (text.startsWith('!') && text.includes('\n')) { // owner 2026-10-15: a wrapped `!git ... push origin\nHEAD:main` ran as two commands
3190
+ const joined = text.split('\n').map(l => l.trim()).filter(Boolean).join(' ');
3191
+ B.current = { ...initialBuffer(), text: joined, cursor: joined.length }; R.current.confirmMulti = null;
3192
+ say(`That ! command arrived on ${text.split('\n').filter(l => l.trim()).length} lines (a wrapped paste?). Joined into one line in the composer: check it, then Enter runs it.`, 'warn');
3193
+ bump(n => n + 1); return;
3194
+ }
3195
+ const bang = parseBang(text);
3196
+ if (bang) { // QUOXON-FW8: the user's own command on their own machine; never a turn, never the server
3197
+ if (bang.kind !== 'reserved' && bang.kind !== 'empty' && R.current.bang) { say('A command is still running. Ctrl+C stops it; your draft is kept.', 'warn'); return; }
3198
+ B.current = initialBuffer(); R.current.confirmMulti = null; remember(text); R.current.histIdx = -1;
3199
+ echoCmd(text); bangCommand(bang, { echoed: true }); bump(n => n + 1); return;
3200
+ }
900
3201
  const s = S.current;
901
3202
  const isCommand = text.startsWith('/') && !text.includes('\n');
902
- if (blocked && !isCommand) { say(blocked, 'warn'); return; }
903
- if (text.startsWith('/') && text.includes('\n') && R.current.confirmMulti !== text) {
904
- // commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter
905
- R.current.confirmMulti = text;
3203
+ const ws = R.current.wokeSwallowed; R.current.wokeSwallowed = null;
3204
+ if (ws && !isCommand && !text.startsWith('!') && now - ws.at < 15000 && !askOpen() && !R.current.pendingWalk) { // the key that woke the screen was not typed: this line may be missing its first letter, so nothing goes to the model unless the user means it
3205
+ B.current = { ...initialBuffer(), text, cursor: text.length };
3206
+ say('Not sent: the key that woke the screen was not typed, so this line may be missing its first letter. Check it, then press Enter to send.', 'warn'); bump(n => n + 1); return;
3207
+ }
3208
+ if (blocked && !isCommand) { // Z2H-R4: clear the box (else the next /login is appended and refused); Up recalls the message
3209
+ remember(text); R.current.histIdx = -1; B.current = initialBuffer(); say(blocked, 'warn'); bump(n => n + 1); return;
3210
+ }
3211
+ if (text.startsWith('/') && text.includes('\n') && !R.current.confirmMulti) {
3212
+ // commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter.
3213
+ // The second Enter sends it even after edits (R3 D3: requiring a byte-identical draft swallowed every later Enter).
3214
+ R.current.confirmMulti = true;
906
3215
  if (!R.current.multiWarned) {
907
3216
  R.current.multiWarned = true;
908
3217
  say('That draft starts with / but has several lines, so it is not a command. Press Enter again to send it to Quox as chat, or edit it (Esc Esc clears it).', 'warn');
@@ -911,7 +3220,204 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
911
3220
  }
912
3221
  R.current.confirmMulti = null; R.current.multiWarned = false;
913
3222
  B.current = initialBuffer();
914
- if (text.startsWith('/') && !text.includes('\n')) { command(text); return; }
3223
+ K.current.composerClicked = false;
3224
+ if (R.current.handoffAsk?.bigger && /^(y(es)?|no?|c|keep)$/i.test(text.trim())) { // "Switch this window back to CommanderQ?" y switch / n just this one / c keep asking
3225
+ const a = R.current.handoffAsk; R.current.handoffAsk = null;
3226
+ const t = text.trim().toLowerCase();
3227
+ const kind = t[0] === 'y' ? 'yes' : t[0] === 'n' ? 'no' : 'dismissed';
3228
+ R.current.askAnswer = { kind, at: Date.now() }; queueMicrotask(() => reconcileAsks());
3229
+ const hs = R.current.handoffStreak;
3230
+ if (Date.now() - a.at >= 120000) { say('That offer expired. Nothing changed.', 'dim'); return; }
3231
+ if (kind === 'yes') { hs?.clear(); echoCmd('/detach'); command('/detach'); return; }
3232
+ if (kind === 'no') { hs?.restart(); runHandoff(a); return; }
3233
+ hs?.mute(); say(`Staying with ${a.h.fromName}.`, 'dim'); return;
3234
+ }
3235
+ // ASK-SETTLE: an answer is remembered; the card it closes settles to one line once this line is handled.
3236
+ let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
3237
+ if (askOpen() && !R.current.noteAsk) {
3238
+ const o = askOutcomeOf(text, { runWord: runAskOpen() }) || (askAnswer(text) === 'chat' && !S.current.turn && !R.current.pendingWalk && !isAskClarification(text) ? { kind: 'dismissed' } : null);
3239
+ const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'profileAsk', 'cliAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
3240
+ const foreign = KC.current.pending?.() || UW.current.pending?.() || HW.current.card() || SC.current.pendingFile || SC.current.reattach || CH.current.pending || SPN.current.pending || runAskOpen();
3241
+ if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
3242
+ else if (o) {
3243
+ askDismissed = o.kind === 'dismissed';
3244
+ R.current.askAnswer = { ...o, at: Date.now() }; queueMicrotask(() => reconcileAsks());
3245
+ if (askDismissed) for (const k of OWN) R.current[k] = null; // the offer is gone: the line below is plain chat, not an answer to anything
3246
+ }
3247
+ }
3248
+ // y/N/c: c or any other free text keeps the open ask and asks Quox about it first (never a silent cancel).
3249
+ if (R.current.noteAsk) { // NOTE-CONFIRM: the line is the pick (1, 2, 3); anything else leaves the card and nothing was sent
3250
+ const st = R.current.noteAsk; R.current.noteAsk = null;
3251
+ const pick = ({ 1: '1', 2: '2', 3: '3', y: '1', yes: '1', n: '2', no: '2', e: '3', edit: '3' })[text.trim().toLowerCase()];
3252
+ if (Date.now() - st.at < 300000 && pick) { noteResolve(pick, st); return; }
3253
+ noteResolve('3', st); // anything else backs out: the whole answer is put back in the composer, never dropped
3254
+ say('Not sent. Your answer and note are back in the composer; press Enter to send it, or edit it first.', 'warn');
3255
+ return;
3256
+ }
3257
+ if (R.current.pendingWalk && !text.startsWith('/')) { walkHandle(text); return; } if (R.current.pendingWalk) walkEnd(`you ran ${text.split(/\s+/)[0]}`); // WALKTHROUGH: the line is the answer to the card on screen; a command ends the walk with its summary
3258
+ if (!askDismissed && !S.current.turn && !noAiNow() && askAnswer(text) === 'chat' && askOpen() && !(runAskOpen() && /^run$/i.test(text.trim()))) { chatAboutAsk(text); return; }
3259
+ if (KC.current.answer(text)) return; // y/n after a /watch, /automate or /crystallize preview card
3260
+ if (UW.current.answer(text)) return; // y/n after a /undo or /wish card
3261
+ if (HW.current.active() && HW.current.answer(text)) return; // CUSTOM-HATS: the next line of a /hat new|edit|delete walk
3262
+ if (RW.current.walk.active() && RW.current.walk.answer(text)) return; // ROWS: the next line of a /row new|edit walk
3263
+ if (R.current.denyReason) { // the n card asked "Reason? (Enter to skip)": this one line is the reason
3264
+ const d = R.current.denyReason; R.current.denyReason = null;
3265
+ if (Date.now() - d.at < 120000 && !text.startsWith('/')) { answer(d.card, 'reject', { reason: text.trim() || null }); return; }
3266
+ }
3267
+ if (R.current.pendingGrant) { // /approve N on a privileged item asked "y/N": this one line is the answer
3268
+ const g = R.current.pendingGrant; R.current.pendingGrant = null;
3269
+ if (Date.now() - g.at < 120000 && !text.startsWith('/')) {
3270
+ if (/^y(es)?$/i.test(text.trim())) {
3271
+ (async () => {
3272
+ if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it)); // defence: a risky ask never rides the bulk card
3273
+ if (g.decisions && g.hi) { // one PIN for the whole batch; the token then rides on every item
3274
+ const r = SU.current.token('approve') ? { ok: true } : await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject: { line1: `answer yes to ${g.items.length} decisions (${g.hi} high risk)`, raisedBy: null, risk: 'high' } });
3275
+ if (!r.ok) return say(`Not answered: ${declinedText(r.reason)}. Nothing was sent.`, 'warn');
3276
+ }
3277
+ for (const it of g.items) {
3278
+ if (g.decisions) {
3279
+ try {
3280
+ const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: null, tool: clip(it.title || it.id, cols - 40), target: null, raisedBy: it.payload.session || null, risk: it.payload.high_risk ? 'high' : null }, 'approve', { batch: true, bulk: true });
3281
+ walkAnsweredIds().add(it.id); // answered (or already handled): a walk never shows it again
3282
+ if (r?.gone) say(`already handled: ${clip(it.title || it.id, cols - 30)}`, 'accent'); else say(`✔ answered yes: ${clip(it.title || it.id, cols - 24)}`, 'ok');
3283
+ if (r?.ok && it.payload.slot && api.slotSay) await api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was answered: y. Read it with: quox ask status ${it.id}`, { from: myLabel() });
3284
+ } catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); if (e?.stepUpDeclined) return; }
3285
+ continue;
3286
+ }
3287
+ try { await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: clip(it.title || it.id, cols - 40), target: null }, g.verb); say(`✔ Granted ${clip(it.title || it.id, cols - 20)}`, 'ok'); }
3288
+ catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); }
3289
+ }
3290
+ if (g.thenWalk) walkStart(g.thenWalk); // WALK-FIXES: the ONE start, once the yeses are in (it used to start here AND straight away: two cards)
3291
+ })();
3292
+ } else { say('Cancelled; nothing granted.', 'dim'); if (g.thenWalk) { const w = g.thenWalk; setTimeout(() => walkStart(w), 0); } }
3293
+ return;
3294
+ }
3295
+ }
3296
+ if (R.current.feedbackAsk) { // /feedback showed the exact payload and asked "Send it? y/N"
3297
+ const a = R.current.feedbackAsk; R.current.feedbackAsk = null;
3298
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
3299
+ say('Sending to quox.ai…', 'dim');
3300
+ (async () => {
3301
+ const r = await api.feedback(a.payload, { version });
3302
+ if (!r?.ok) say(`Feedback not sent: ${r?.message || 'failed'}`, 'warn');
3303
+ else say(`✔ Sent to the Quox team at quox.ai${r.id != null ? ` (feedback #${r.id})` : ''}. Thank you.`, 'ok');
3304
+ })();
3305
+ return;
3306
+ }
3307
+ if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3308
+ }
3309
+ if (R.current.wakeAsk) { // "<slot> hasn't replied ... Wake it now? y/N/c": y pokes it (/poke = an urgent page that wakes it)
3310
+ const a = R.current.wakeAsk; R.current.wakeAsk = null;
3311
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { echoCmd(`/poke ${a.slot}`); command(`/poke ${a.slot}`); return; }
3312
+ if (!text.startsWith('/')) { say('Not woken.', 'dim'); return; }
3313
+ }
3314
+ if (R.current.profileAsk) { // PROFILE-SWITCH "Switch? y/N/c": only y switches
3315
+ const a = R.current.profileAsk; R.current.profileAsk = null;
3316
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
3317
+ if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
3318
+ }
3319
+ if (R.current.slotCreateAsk) { // "No slot X. Create it? y/N/c" from a /slot near miss: y runs /slot X --create
3320
+ const a = R.current.slotCreateAsk; R.current.slotCreateAsk = null;
3321
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { const l = `/slot ${a.nm} --create`; echoCmd(l); command(l); return; }
3322
+ if (!text.startsWith('/')) { say('No slot created.', 'dim'); return; }
3323
+ }
3324
+ if (R.current.slotAsk) { // "Take it over? y/N" from /slot
3325
+ const a = R.current.slotAsk; R.current.slotAsk = null;
3326
+ if (/^y(es)?$/i.test(text.trim())) { takeSlot(a.nm, true); return; }
3327
+ if (!text.startsWith('/')) { say('Left it alone.', 'dim'); return; }
3328
+ }
3329
+ if (CH.current.answer(text) || SPN.current.answer(text)) return; // /challenge: "Resolve these? y/N"
3330
+ if (R.current.updateAsk) { // /update: "Update now and restart? y/N/c"; y hands the terminal to the update, then restarts
3331
+ const a = R.current.updateAsk; R.current.updateAsk = null;
3332
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
3333
+ if (S.current.turn) { say('A turn is running. Wait for it, then /update.', 'warn'); return; }
3334
+ say(`Updating: ${a.plan.command.display} runs in this terminal, then quox restarts into this conversation.`, 'dim');
3335
+ doRestart({ update: a.plan }); return;
3336
+ }
3337
+ if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
3338
+ }
3339
+ if (R.current.cliAsk) { // QUOXON-FW: a slash word named a WRITE quox command: "Run it? y/N" (typing y is the authorization)
3340
+ const a = R.current.cliAsk; R.current.cliAsk = null;
3341
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { if (R.current.bang) say('A command is still running. Ctrl+C stops it.', 'warn'); else bangCommand(a.bang); bump(n => n + 1); return; }
3342
+ if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
3343
+ }
3344
+ if (R.current.hatPending && !text.startsWith('/')) { // "Put on the NSOC hat? y/N" from /hat: y switches (/hat yes still works)
3345
+ const a = R.current.hatPending;
3346
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/hat yes'); return; }
3347
+ R.current.hatPending = null; say('Hat not changed.', 'dim'); return;
3348
+ }
3349
+ if (R.current.orgPending && !text.startsWith('/')) { // "Switch to X? y/N" from /org switch: y switches (/org yes still works)
3350
+ const a = R.current.orgPending;
3351
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/org yes'); return; }
3352
+ R.current.orgPending = null; say(`Not switched. Still in ${R.current.orgName ?? org ?? 'the same org'}.`, 'dim'); return;
3353
+ }
3354
+ if (R.current.suggestAsk) { // "Run /slot hitl? y/N" from terminal_suggest_command: y runs it as if typed (normal command path)
3355
+ const a = R.current.suggestAsk; R.current.suggestAsk = null;
3356
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { const v = vetSuggestion(a.line); if (v.ok) { R.current.preConfirmed = v.id === 'org' ? 'org' : null; echoCmd(v.line); command(v.line); bump(n => n + 1); } return; }
3357
+ if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
3358
+ }
3359
+ if (R.current.handoffAsk) { // "Hand this question to CommanderQ?" (yes or no): yes asks it once, the attach stays
3360
+ const a = R.current.handoffAsk; R.current.handoffAsk = null;
3361
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runHandoff(a); return; }
3362
+ if (!text.startsWith('/')) { say('Not handed over.', 'dim'); return; }
3363
+ }
3364
+ if (R.current.planAsk) { // PLAN MODE: "Run this plan now? y/N/c": y switches back to ask and sends the go-ahead
3365
+ const a = R.current.planAsk; R.current.planAsk = null;
3366
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { MD.current = 'ask'; bump(n => n + 1); say('mode ask first: running the plan.', 'dim'); submit(PLAN_GO); return; }
3367
+ if (!text.startsWith('/')) { say('Not run. Still plan only (/mode ask to switch).', 'dim'); return; }
3368
+ }
3369
+ if (R.current.strayAsk) { // TUI-INPUT: "Did you mean /help? y/N/c" before a queued "n/help" is sent: y runs the command, n sends it as typed
3370
+ const a = R.current.strayAsk; R.current.strayAsk = null;
3371
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { echoCmd(a.command); command(a.command); return; }
3372
+ if (/^no?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runQueued(a.text, { checked: true }); return; }
3373
+ }
3374
+ if (R.current.askQuox) { // an unknown "/words ..." offered to ask Quox: "y/N"
3375
+ const a = R.current.askQuox; R.current.askQuox = null;
3376
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { submit(a.text); return; }
3377
+ if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3378
+ }
3379
+ if (SC.current.pendingFile) { // /send found secret-shaped values: "y/N" (send redacted)
3380
+ const pf = SC.current.pendingFile; SC.current.pendingFile = null;
3381
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - pf.at < 120000) { sendToSlot(pf.slot, fileMessage({ name: pf.f.name, body: pf.f.redacted, size: Buffer.byteLength(pf.f.redacted) })); return; }
3382
+ if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3383
+ }
3384
+ if (SC.current.reattach) { // startup "Reattach to slot X? y/N"
3385
+ const ra = SC.current.reattach; SC.current.reattach = null;
3386
+ if (/^y(es)?$/i.test(text.trim())) { takeSlot(ra.slot, ra.take); return; }
3387
+ if (/^no?$/i.test(text.trim())) { say('Left it.', 'dim'); return; }
3388
+ }
3389
+ if (SC.current.runAsk) { // SLOT-RUN: "Run it? y/N/c" (or "run" for a dangerous one) on a slot sender's command
3390
+ const ra = SC.current.runAsk;
3391
+ const v = runAnswer(text, ra);
3392
+ if (runNow() >= ra.expiresAt) { // runNow: the run-ask clock (a prop so a test can move it 10 minutes without freezing Date.now)
3393
+ SC.current.runAsk = null;
3394
+ if (v === 'run' || v === 'needs-run' || v === 'no') { say(`That run request from ${ra.who} expired (run requests last 10 minutes). Not run; ask them to send it again.`, 'warn'); return; }
3395
+ } else if (v === 'run') { SC.current.runAsk = null; runSlotCommand(ra); return; }
3396
+ else if (v === 'needs-run') { say(`Not run yet. ${ra.danger}, so type run to run it, or n to skip.`, 'warn'); return; }
3397
+ else if (v === 'no') { SC.current.runAsk = null; transcript?.bang?.(ra.cmd, 'declined'); say('Not run.', 'dim'); return; }
3398
+ }
3399
+ if (text.startsWith('/') && !text.includes('\n')) { remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
3400
+ const roomSend = api.slotSay ? parseRoomSend(text) : null;
3401
+ if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
3402
+ remember(text); R.current.histIdx = -1;
3403
+ if (roomSend.text) postRoom(roomSend.room, roomSend.text); else setActiveRoom(roomSend.room);
3404
+ return;
3405
+ }
3406
+ if (SC.current.active && text.trim() === '#') { SC.current.active = null; say('Back to Quox: plain lines go to the model again.', 'ok'); return; }
3407
+ if (SC.current.active && api.slotSay && !text.startsWith('!') && !text.includes('\n')) { // default room target
3408
+ remember(text); R.current.histIdx = -1;
3409
+ postRoom(SC.current.active, text.trim()); return;
3410
+ }
3411
+ const atSend = parseAtSend(text);
3412
+ if (atSend && api.slotSay) { // QUOXON-MESH: @slot text goes straight to the slot, no model turn, even mid-turn
3413
+ remember(text); R.current.histIdx = -1;
3414
+ sendToSlot(atSend.slot, atSend.text); return;
3415
+ }
3416
+ if (R.current.askArmed && !s.turn) { // /dragon ask with no question: this one line goes to the dragon
3417
+ R.current.askArmed = false; R.current.dragonTalk = null; touchSeen(); dispatch({ type: 'user', text });
3418
+ remember(text); R.current.histIdx = -1; dragonTurn(DRAGON_ANSWERS, askPrompt(text)); return;
3419
+ }
3420
+ if (s.turn && alreadyQueued(text, { pending: s.pending, steering: s.steering, waiting: R.current.waitingServer })) { say('Already queued: that exact message is waiting to send.', 'dim'); return; } // TUI-INPUT: Enter again on the same queued text adds no copy
915
3421
  if (s.turn && !R.current.caps.steer) { // steering already refused for this turn: queue, say so once
916
3422
  dispatch({ type: 'queue.add', text });
917
3423
  if (!R.current.steerNoted) { R.current.steerNoted = true; say(STEER_OFF_NOTE, 'warn'); }
@@ -921,28 +3427,157 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
921
3427
  submit(text);
922
3428
  }
923
3429
 
924
- /** Returns true when the text was consumed as an approval key, false when it is ordinary typing. */
3430
+ /**
3431
+ * QUOXON-FW15: y/n answer the shown card, but ONLY while the composer is empty (no draft, not mid-command). With a draft
3432
+ * they are ordinary text, which is what stops a stray keystroke approving. A privileged (or undescribable) card needs
3433
+ * two deliberate presses: y, then y again; any other key cancels. Returns true when the key was consumed.
3434
+ */
3435
+ // ---- QUOXON-MOUSE ------------------------------------------------------------------------------
3436
+ /** Turn tracking on or off at the terminal (TTY only). On also arms an exit hook so a crash never leaves it on. */
3437
+ function mouseTerm(on) {
3438
+ const M = MO.current;
3439
+ if (!stdout?.isTTY || Boolean(on) === M.tty) return;
3440
+ try { stdout.write(on ? MOUSE_ON : MOUSE_OFF); } catch { /* closed */ }
3441
+ M.tty = Boolean(on);
3442
+ if (on && !M.exitFn) { M.exitFn = () => { try { writeSync(process.stdout.fd ?? 1, MOUSE_OFF); } catch { /* closed */ } }; process.once('exit', M.exitFn); }
3443
+ if (!on && M.exitFn) { process.removeListener('exit', M.exitFn); M.exitFn = null; }
3444
+ }
3445
+ /** What a run hand-over writes when it takes the terminal back (tracking was turned off for the child). */
3446
+ function mouseResume() { return MO.current.tty ? MOUSE_ON : ''; }
3447
+ /** The terminal's cursor row (DSR), or null when it does not answer in time. */
3448
+ function cursorRow() {
3449
+ return new Promise((res) => {
3450
+ const t = setTimeout(() => { MO.current.onCursor = null; res(null); }, DSR_TIMEOUT_MS);
3451
+ MO.current.onCursor = (row) => { clearTimeout(t); MO.current.onCursor = null; res(row); };
3452
+ try { stdout.write(DSR); } catch { clearTimeout(t); MO.current.onCursor = null; res(null); }
3453
+ });
3454
+ }
3455
+ /** -> what Ink drew under screen cell (x, y): { text, col, inFrame, frameRow, frameLines, rowsBelow } | null. */
3456
+ async function locate(x, y, row = null) {
3457
+ if (mouseScreen) return mouseScreen.at(x, y);
3458
+ const scr = stdout?.screen;
3459
+ if (!scr) return null;
3460
+ const cr = row ?? await cursorRow();
3461
+ if (cr == null) return null;
3462
+ const hit = screenHit(scr.snapshot(), cr, x, y);
3463
+ return hit ? { ...hit, cursorRow: cr } : null;
3464
+ }
3465
+ function onMouse(e) {
3466
+ if (!MO.current.on || e.button !== 'left') return;
3467
+ if (e.action !== 'press' && e.action !== 'drag' && e.action !== 'release') return;
3468
+ MO.current.chain = MO.current.chain.then(() => mouseEvent(e)).catch(() => {}); // in order: a drag never overtakes its press
3469
+ }
3470
+ async function mouseEvent(e) {
3471
+ const M = MO.current;
3472
+ if (R.current.closed || R.current.sleep) return;
3473
+ if (e.action === 'release') { M.drag = null; copySelection(); return; }
3474
+ if (e.action === 'drag') {
3475
+ if (!M.drag) return;
3476
+ const hit = await locate(e.x, e.y, M.drag.cursorRow);
3477
+ const idx = hit?.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
3478
+ if (idx != null) edit({ type: 'select', anchor: M.drag.anchor, cursor: idx });
3479
+ return;
3480
+ }
3481
+ // a modal card (PIN, sign-in, secure connect) takes keys only: a click never answers it
3482
+ if (SU.current.isOpen() || LI.current.isOpen() || CN.current.isOpen()) return;
3483
+ const hit = await locate(e.x, e.y);
3484
+ if (!hit) return;
3485
+ const now = Date.now();
3486
+ const idx = hit.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
3487
+ if (idx != null) {
3488
+ const dbl = M.last && now - M.last.at < DOUBLE_MS && M.last.x === e.x && M.last.y === e.y;
3489
+ M.last = dbl ? null : { at: now, x: e.x, y: e.y };
3490
+ if (dbl) { const w = wordAt(B.current.text, idx); edit({ type: 'select', anchor: w.from, cursor: w.to }); copySelection(); return; }
3491
+ edit({ type: 'place', index: idx }); K.current.composerClicked = true;
3492
+ M.drag = { anchor: B.current.cursor, cursorRow: hit.cursorRow ?? null };
3493
+ return;
3494
+ }
3495
+ M.last = null;
3496
+ const what = classifyClick(hit);
3497
+ if (!what) return;
3498
+ if (what.kind === 'sender') {
3499
+ if (!what.slot) return say(`${what.who} has no reply address (it did not send from a slot), so there is nothing to /say to.`, 'warn');
3500
+ const draft = B.current.text;
3501
+ const body = draft.startsWith('/say ') ? draft.replace(/^\/say\s+\S*\s?/, '') : draft;
3502
+ edit({ type: 'set', text: `/say ${what.slot} ${body}`, keepPastes: true });
3503
+ return;
3504
+ }
3505
+ if (what.kind === 'key') {
3506
+ if (B.current.text) return say('Your draft is not empty, so a click does not answer. Clear it, or press the key.', 'warn');
3507
+ if (what.source === 'approval') { handleEvents([{ type: 'text', text: what.key }], now, false); return; }
3508
+ if (!askOpen()) return;
3509
+ if (what.key === 'run' || (what.key === 'y' && runAskOpen())) return say('A click never runs a command. Press y or type run.', 'warn');
3510
+ handleEvents([{ type: 'text', text: what.key }, { type: 'key', name: 'enter' }], now, false);
3511
+ }
3512
+ return undefined;
3513
+ }
3514
+
3515
+ /**
3516
+ * TUI-INPUT: a single y, n or c answers the open y/N/c ask (the "Reattach to slot X?" card and friends) exactly as
3517
+ * typing it and Enter does, and never reaches the composer. Only while the composer is empty and was not clicked;
3518
+ * a draft in progress keeps its keys (same rule as approval cards). Returns true when the key was consumed.
3519
+ */
3520
+ function askKey(ch, now) {
3521
+ if (!ch || ch.length !== 1 || !/^[ync]$/i.test(ch)) return false;
3522
+ const k = K.current;
3523
+ if (B.current.text || k.composerClicked || R.current.sleep) return false;
3524
+ if (k.walkEndAt && now - k.walkEndAt < ASK_ECHO_MS && !askOpen() && !R.current.pendingWalk) { // a walk card just closed: a late y/n/c is its answer arriving after the walk, never a chat message
3525
+ B.current = { ...initialBuffer(), text: ch, cursor: 1 };
3526
+ k.askEcho = { ch, at: now }; clearTimeout(k.askEchoTimer);
3527
+ k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50); k.askEchoTimer.unref?.();
3528
+ bump(n => n + 1); return true;
3529
+ }
3530
+ if (!askOpen() || R.current.pendingWalk || R.current.denyReason || HW.current.active?.()) return false;
3531
+ if (/c/i.test(ch) && (S.current.turn || noAiNow())) { say('Quox is busy or off; c chats about this question once it can. y or n answers it now.', 'dim'); return true; }
3532
+ B.current = { ...initialBuffer(), text: ch, cursor: 1 };
3533
+ onEnter(now);
3534
+ // The answer is already given. The key stays on screen for ASK_ECHO_MS as its echo; an Enter in that window is the
3535
+ // old "type y, press Enter" habit and is swallowed, and ANY other key wipes the echo first (so "n" + "/help" is never "n/help").
3536
+ B.current = { ...initialBuffer(), text: ch, cursor: 1 };
3537
+ k.askEcho = { ch, at: now };
3538
+ clearTimeout(k.askEchoTimer);
3539
+ k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50);
3540
+ k.askEchoTimer.unref?.();
3541
+ bump(n => n + 1);
3542
+ return true;
3543
+ }
3544
+ function endAskEcho() {
3545
+ const k = K.current; const ae = k.askEcho;
3546
+ k.askEcho = null; clearTimeout(k.askEchoTimer);
3547
+ if (ae && B.current.text === ae.ch) B.current = initialBuffer();
3548
+ }
3549
+
925
3550
  function approvalKey(name, text, now) {
926
- const card = S.current.approvals[0];
3551
+ const card = headCard(S.current);
927
3552
  if (!card) return false;
928
3553
  const ch = text || '';
929
- const g = K.current.guard;
930
- const armedAt = card.displayedAt ?? card.shownAt;
931
- // With a draft in progress, a/d/o are ordinary text unless the card has focus (Tab).
932
- if (B.current.text && !K.current.cardFocus) return false;
933
- if (card.undescribable) {
934
- // no one-key approve for an action Quox cannot describe: the word must be typed, fresh
935
- if (ch === 'd' && approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: K.current.lastKeyAt, heldKey: g?.heldKey, ch })) { answer(card, 'reject'); return true; }
936
- if (now - armedAt < 300) return true;
937
- K.current.typed = (K.current.typed + ch.toLowerCase()).slice(-APPROVE_WORD.length);
938
- if (K.current.typed === APPROVE_WORD) { K.current.typed = ''; answer(card, 'approve'); }
939
- return true;
3554
+ const k = K.current;
3555
+ const g = k.guard;
3556
+ if (R.current.denyReason && Date.now() - R.current.denyReason.at < 120000) return false; // typing the deny reason: y/n are text
3557
+ if (B.current.text && !k.cardFocus) { k.confirm = false; return false; } // a draft in progress: the key is text (Tab focuses the card, draft kept)
3558
+ const armedAt = Math.max(card.displayedAt ?? card.shownAt ?? 0, g?.since ?? 0);
3559
+ const ok = () => approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: k.lastKeyAt, heldKey: g?.heldKey, ch });
3560
+ if (k.confirm) { // second press of a two-press grant
3561
+ k.confirm = false;
3562
+ if (ch.toLowerCase() === 'y' && ok()) { answer(card, 'approve'); return true; }
3563
+ bump(n => n + 1);
3564
+ return true; // any other key cancels
3565
+ }
3566
+ const key = ch.toLowerCase();
3567
+ if (key === 'c' && !S.current.turn && !noAiNow()) { // y/N/c: chat about the approval first; the card stays
3568
+ if (!ok()) return true;
3569
+ const what = card.what || [card.tool, card.target].filter(Boolean).join(' ') || 'this approval';
3570
+ const msg = `Tell me more about: approve ${what}, before I decide`;
3571
+ touchSeen(); dispatch({ type: 'user', text: msg }); runTurn(msg); bump(n => n + 1); return true;
940
3572
  }
941
- if (!['a', 'd', 'o'].includes(ch)) return false; // ordinary typing: the draft keeps it
942
- if (!approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: K.current.lastKeyAt, heldKey: g?.heldKey, ch })) return true;
943
- if (ch === 'o') { say(`open: ${card.url || '(no link)'}`); return true; }
944
- const action = ch === 'a' ? 'approve' : 'reject';
945
- answer(card, action);
3573
+ if (key !== 'y' && key !== 'n') return false; // ordinary typing: the composer keeps it
3574
+ if (!ok()) return true; // too early / key repeat: swallowed, never answered
3575
+ if (key === 'n' && k.cardFocus) { k.cardFocus = false; answer(card, 'reject'); return true; } // focused over a draft: the composer holds the draft, so no reason prompt
3576
+ if (key === 'n') { // ask why in one line; Enter skips. The card stays until the answer is sent.
3577
+ R.current.denyReason = { card, at: Date.now() }; say('Reason? (Enter to skip)', 'warn'); bump(n => n + 1); return true;
3578
+ }
3579
+ if (card.privileged || card.undescribable) { k.confirm = true; bump(n => n + 1); return true; }
3580
+ answer(card, 'approve');
946
3581
  return true;
947
3582
  }
948
3583
 
@@ -977,11 +3612,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
977
3612
  return ro;
978
3613
  }
979
3614
 
3615
+ /** COMPOSER-EDIT: cells the draft has per row (the composer box is `cols` wide, less the "› " prompt). */
3616
+ function composerWidth() { return Math.max(1, cols - 2); }
3617
+ /** COMPOSER-EDIT: copy the composer selection to the clipboard with OSC 52 (works over SSH; needs a terminal that allows it). */
3618
+ function copySelection() {
3619
+ const t = selectedText(B.current);
3620
+ if (!t || !stdout?.isTTY) return;
3621
+ try { stdout.write(osc52(t)); } catch { /* closed */ }
3622
+ }
980
3623
  function onKey(e, now) {
981
3624
  const s = S.current;
982
3625
  const busy = Boolean(s.turn);
983
3626
  const name = e.name;
984
3627
  if (name === 'ctrl-c') {
3628
+ if (R.current.bang) { R.current.bang.run.kill(); return; } // QUOXON-FW8: stops the running ! command, never quits
985
3629
  if (B.current.text) { edit({ type: 'clear' }); return; }
986
3630
  if (busy) { interrupt(); return; }
987
3631
  if (K.current.ctrlC) return exit();
@@ -992,17 +3636,23 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
992
3636
  }
993
3637
  if (name === 'ctrl-d') { if (!B.current.text) exit(); return; }
994
3638
  if (R.current.roster.focus && !s.approvals.length && rosterKey(name)) return;
995
- if (!s.approvals.length && PL.current.onKey(name)) { bump(n => n + 1); return; }
3639
+ // quick reply (owner 2026-10-15): Tab in an EMPTY composer pre-fills "/say <last sender> " (before the palette, which owns Tab otherwise)
3640
+ if (name === 'tab' && !B.current.text && R.current.lastSender && !headCard(s)) { edit({ type: 'set', text: `/say ${R.current.lastSender.toLowerCase()} ` }); return; }
3641
+ if (!headCard(s) && PL.current.onKey(name)) { bump(n => n + 1); return; } // gate on the card actually SHOWN: an approval put away with Tab (or still queued) must not swallow Tab/Up/Down while the palette is on screen
996
3642
  if (name === 'escape') {
3643
+ if (selection(B.current)) { edit({ type: 'place', index: B.current.cursor }); return; } // COMPOSER-EDIT: Esc drops a selection, nothing else
3644
+ if (flyActive()) { R.current.fly = null; bump(n => n + 1); return; } // Esc ends the dragon's pass
997
3645
  if (busy) { interrupt(); return; }
3646
+ if (R.current.askArmed) { R.current.askArmed = false; R.current.dragonTalk = null; say('Dragon ask cancelled.', 'dim'); return; }
3647
+ if (HW.current.cancel()) return; // CUSTOM-HATS: Esc ends the hat walk, nothing sent
3648
+ if (RW.current.walk.cancel()) return; // ROWS: Esc ends the row walk, nothing saved
3649
+ if (R.current.pendingWalk) { walkEnd('Esc'); K.current.lastEscAt = now; return; } // WALK-FIXES: Esc leaves the walkthrough with a summary; the draft stays
998
3650
  if (now - K.current.lastEscAt < ESC_ESC_MS && B.current.text) {
999
3651
  if (looksLikeKey(B.current.text)) { edit({ type: 'clear' }); say('Draft cleared. It looked like a key, so it was not kept.'); }
1000
- else { R.current.history.push(B.current.text); edit({ type: 'clear' }); say('Draft cleared (Up recalls it).'); }
3652
+ else { remember(B.current.text); edit({ type: 'clear' }); say('Draft cleared (Up recalls it).'); }
1001
3653
  }
1002
3654
  K.current.lastEscAt = now; return;
1003
3655
  }
1004
- // with a card up only draft-editing keys work (so the draft can be cleared); nothing submits or recalls
1005
- if (s.approvals.length && !CARD_EDIT_KEYS.includes(name)) return;
1006
3656
  switch (name) {
1007
3657
  case 'enter': return onEnter(now);
1008
3658
  case 'shift-enter': case 'newline': return edit({ type: 'newline' });
@@ -1014,12 +3664,21 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1014
3664
  case 'delete-word': return edit({ type: 'deleteWordBack' });
1015
3665
  case 'kill-line': return edit({ type: 'killLineBack' });
1016
3666
  case 'left': case 'right': case 'home': case 'end': return edit({ type: name });
1017
- case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'left' : 'right' });
3667
+ case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'wordLeft' : 'wordRight' });
3668
+ case 'kill-line-forward': return edit({ type: 'killLineForward' }); // Ctrl+K
3669
+ case 'delete-word-forward': return edit({ type: 'deleteWordForward' }); // Alt+D, Ctrl+Delete
3670
+ case 'select-left': case 'select-right': case 'select-up': case 'select-down': case 'select-home': case 'select-end':
3671
+ case 'select-word-left': case 'select-word-right': { // COMPOSER-EDIT: Shift selection, copied to the clipboard (OSC 52)
3672
+ const move = { 'select-word-left': 'wordLeft', 'select-word-right': 'wordRight' }[name] || name.slice(7);
3673
+ edit({ type: 'extend', move, width: composerWidth() });
3674
+ copySelection();
3675
+ return;
3676
+ }
1018
3677
  case 'undo': return edit({ type: 'undo' });
1019
3678
  case 'redo': return edit({ type: 'redo' });
1020
3679
  case 'up': {
1021
3680
  if (B.current.text === '' && s.pending.length) { const last = s.pending[s.pending.length - 1]; dispatch({ type: 'queue.pop' }); edit({ type: 'set', text: last.text }); return; }
1022
- if (!B.current.text.slice(0, B.current.cursor).includes('\n')) {
3681
+ if (atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
1023
3682
  const hist = R.current.history;
1024
3683
  if (B.current.text === '' || R.current.histIdx >= 0) {
1025
3684
  const i = R.current.histIdx < 0 ? hist.length - 1 : Math.max(0, R.current.histIdx - 1);
@@ -1027,22 +3686,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1027
3686
  }
1028
3687
  return;
1029
3688
  }
1030
- return edit({ type: 'up' });
3689
+ return edit({ type: 'up', width: composerWidth() });
1031
3690
  }
1032
3691
  case 'down': {
1033
3692
  if (B.current.text === '' && R.current.histIdx < 0 && rosterOn()) { focusRoster(); return; }
1034
- if (!B.current.text.slice(B.current.cursor).includes('\n') && R.current.histIdx >= 0) {
3693
+ if (atVisualEdge(B.current, 'down', composerWidth()) && R.current.histIdx >= 0) {
1035
3694
  const i = R.current.histIdx + 1;
1036
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] }); }
1037
3696
  return;
1038
3697
  }
1039
- return edit({ type: 'down' });
3698
+ return edit({ type: 'down', width: composerWidth() });
1040
3699
  }
1041
3700
  case 'ctrl-g': {
1042
3701
  const full = sanitizeForSend({ ...B.current }).text ?? B.current.text;
3702
+ mouseTerm(false); // the editor gets a plain terminal (QUOXON-MOUSE), and tracking comes back after
1043
3703
  if (isRawModeSupported) setRawMode(false);
1044
3704
  const out = editor(full);
1045
3705
  if (isRawModeSupported) setRawMode(true);
3706
+ mouseTerm(MO.current.on);
1046
3707
  if (out !== null && out !== undefined) edit({ type: 'set', text: out });
1047
3708
  return;
1048
3709
  }
@@ -1059,24 +3720,52 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1059
3720
  const handleEvents = (events, now, burst) => {
1060
3721
  for (const e of events) {
1061
3722
  if (e.type === 'focus') { SND.current.focus(e.focused); continue; } // CSI I / CSI O, never a key
3723
+ if (e.type === 'cursor') { MO.current.onCursor?.(e.row); continue; } // the answer to a click's DSR, never a key
3724
+ if (e.type === 'mouse') { onMouse(e); continue; } // QUOXON-MOUSE: never text, never a key
3725
+ if (K.current.askEcho) { // TUI-INPUT: see askKey
3726
+ const stale = now - K.current.askEcho.at > ASK_ECHO_MS;
3727
+ const enter = e.type === 'key' && e.name === 'enter';
3728
+ endAskEcho();
3729
+ if (enter && !stale) { bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // swallowed: no empty or stray message
3730
+ }
3731
+ if (AR.current.timer && e.type === 'key') { // the countdown owns two keys: Esc stays on this version, Enter restarts now; anything else is typing (a draft holds the restart)
3732
+ if (e.name === 'escape') { stopCountdown(); cdLog('cancelled'); AR.current.cancelledId = AR.current.newId; say("Restart cancelled. /restart when you're ready.", 'accent'); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3733
+ if (e.name === 'enter' && autoIdle()) { stopCountdown(); cdLog('restart-now'); doRestart(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3734
+ }
3735
+ if (R.current.sleep) { // the sleep screen eats the waking key (nothing is lost), except "/": slash commands always work asleep
3736
+ if (e.type === 'key' && e.name === 'ctrl-c') { exit(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3737
+ if (R.current.sleep.wakeAt != null) { /* already waking: later keys are typed as normal, so "/back" typed in one go is not cut short */ }
3738
+ else {
3739
+ wakeDragon();
3740
+ if (e.type === 'text' && e.text === '/') edit({ type: 'insert', text: '/' });
3741
+ else if (e.type === 'text') R.current.wokeSwallowed = { at: now };
3742
+ K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue;
3743
+ }
3744
+ }
3745
+ if (SU.current.isOpen()) { SU.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the PIN card takes every key: nothing reaches the composer, history or undo
3746
+ if (LI.current.isOpen()) { LI.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the sign-in card takes every key
1062
3747
  if (CN.current.isOpen()) { CN.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the secure card takes every key
1063
- if (PK.current.isOpen() && !S.current.approvals.length) { PK.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // /resume, /config
1064
- const card = S.current.approvals[0];
3748
+ if (PK.current.isOpen() && !S.current.approvals.length && PK.current.onEvent(e) !== false) { K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // /resume, /config (typing closes it and falls through)
3749
+ const card = headCard(S.current);
1065
3750
  const g = K.current.guard;
1066
3751
  // any different key ends the "this key was already down" suppression
1067
3752
  if (g?.heldKey && !(e.type === 'text' && e.text === g.heldKey)) g.heldKey = null;
1068
- if (card && e.type === 'key' && e.name === 'tab') { if (B.current.text) { K.current.cardFocus = true; bump(n => n + 1); } K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
1069
- if (card && e.type === 'key' && e.name === 'escape' && K.current.cardFocus) { K.current.cardFocus = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3753
+ // Tab (composer empty) puts the card away into the status line until a NEW approval arrives
3754
+ // RESTORE-TUI: Tab with a draft focuses the card (the draft is kept, never queued or sent); Esc returns to the draft
3755
+ if (card && e.type === 'key' && e.name === 'tab' && B.current.text) { K.current.cardFocus = true; K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3756
+ if (card && e.type === 'key' && e.name === 'escape' && K.current.cardFocus) { K.current.cardFocus = false; K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3757
+ if (card && e.type === 'key' && e.name === 'tab' && !B.current.text) { for (const x of queueOf(S.current)) K.current.dismissed.add(x.callId); K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3758
+ if (card && K.current.confirm && e.type === 'key') { K.current.confirm = false; bump(n => n + 1); } // any other key cancels a pending privileged confirm
1070
3759
  if (e.type === 'paste') {
1071
3760
  K.current.lastPasteAt = now;
1072
3761
  edit({ type: 'paste', text: e.text }); // a card never discards a paste
1073
3762
  } else if (e.type === 'text') {
1074
3763
  if (card && approvalKey('text', e.text, now)) { /* consumed by the approval dialog */ }
1075
3764
  else if (!card && R.current.roster.focus && rosterKey(null, e.text)) { /* consumed by the roster */ }
3765
+ else if (!card && askKey(e.text, now)) { /* consumed by the open ask */ }
1076
3766
  else edit({ type: 'insert', text: e.text });
1077
3767
  } else if (e.type === 'key') {
1078
- if (card && !['escape', 'ctrl-c', ...CARD_EDIT_KEYS].includes(e.name)) { /* swallowed while approving */ }
1079
- else if (e.name === 'enter' && burst) edit({ type: 'newline' }); // startup keys only: never submitted
3768
+ if (e.name === 'enter' && burst) edit({ type: 'newline' }); // startup keys only: never submitted
1080
3769
  else onKey(e, now);
1081
3770
  }
1082
3771
  K.current.lastKeyAt = now;
@@ -1090,7 +3779,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1090
3779
  // Its timing relative to the previous chunk says nothing: tmux, expect and SSH batch segments.
1091
3780
  const burst = false;
1092
3781
  K.current.lastDataAt = now;
1093
- const parsed = parseChunk(typeof chunk === 'string' ? chunk : chunk.toString('utf8'), K.current.keys);
3782
+ const parsed = parseChunk(typeof chunk === 'string' ? chunk : chunk.toString('utf8'), K.current.keys, B.current.text);
1094
3783
  K.current.keys = parsed.state;
1095
3784
  clearTimeout(K.current.danglingTimer);
1096
3785
  if (hasDangling(parsed.state)) {
@@ -1108,7 +3797,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1108
3797
  React.useEffect(() => {
1109
3798
  if (!resumeConversation || !api.conversationTurns) return undefined;
1110
3799
  let live = true;
1111
- api.conversationTurns(resumeConversation).then((turns) => {
3800
+ reconnecting(() => api.conversationTurns(resumeConversation)).then((turns) => {
1112
3801
  if (!live) return;
1113
3802
  if (!turns.length) {
1114
3803
  // C5: the conversation's first turn may still be running (nothing is stored until it ends).
@@ -1120,14 +3809,66 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1120
3809
  }
1121
3810
  dispatch({ type: 'resume', conversationId: resumeConversation, turns });
1122
3811
  return reattachIfRunning(resumeConversation);
1123
- }, (e) => { if (live) dispatch({ type: 'error', text: `could not load conversation ${resumeConversation}: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); });
3812
+ }, (e) => { if (!live || R.current.closed) return; if (e?.reconnectGaveUp) { resumeGaveUp(resumeConversation); return; } dispatch({ type: 'error', text: `could not load conversation ${resumeConversation}: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); });
1124
3813
  return () => { live = false; };
1125
3814
  }, []);
1126
3815
  React.useEffect(() => {
1127
3816
  if (!startup) return undefined;
1128
3817
  let live = true;
1129
- Promise.resolve().then(startup).then((note) => { if (live && note) say(note, 'warn'); }, () => {});
1130
- return () => { live = false; };
3818
+ let timer = null;
3819
+ const slow = (note) => /is not answering/.test(note);
3820
+ // A slow collector keeps being probed (capped backoff, one timer, none once it answers) so the notice can settle itself.
3821
+ const recheck = (i) => {
3822
+ const delays = healthRecheckDelays; if (!healthRecheck || !delays?.length) return;
3823
+ timer = setTimeout(async () => {
3824
+ timer = null;
3825
+ let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
3826
+ if (!live) return;
3827
+ if (hl?.ok) { say('✔ QuoxCORE is answering again', 'accent'); return; }
3828
+ recheck(Math.min(i + 1, delays.length - 1));
3829
+ }, delays[Math.min(i, delays.length - 1)]);
3830
+ };
3831
+ Promise.resolve().then(startup).then((note) => {
3832
+ if (!live || !note) return;
3833
+ if (slow(note)) { if (startup.said) return; startup.said = true; } // once per session, even if the probe is mounted twice
3834
+ say(note, 'warn');
3835
+ if (slow(note)) recheck(0);
3836
+ }, () => {});
3837
+ return () => { live = false; clearTimeout(timer); };
3838
+ }, []);
3839
+ React.useEffect(() => { // E5: "While you were away" under the intro, after more than 2 h away
3840
+ if (!away) return undefined;
3841
+ let live = true;
3842
+ try { AWAY.current.last = away.read(profile); } catch { AWAY.current.last = null; }
3843
+ const last = AWAY.current.last;
3844
+ touchSeen(true);
3845
+ if (!BL.current && api.away && awayDue(last)) {
3846
+ api.away(new Date(last).toISOString()).then((d) => { if (live) dispatch({ type: 'object', obj: { type: 'away', data: { ...d, compact: true } } }); },
3847
+ (e) => { if (live && !BL.current) say(`While you were away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). /away to retry.`, 'warn'); });
3848
+ }
3849
+ return () => { live = false; touchSeen(true); };
3850
+ }, []);
3851
+ React.useEffect(() => { // --demo --autoplay: the whole journey with no input, on a fixed script (demo.autoplay.js)
3852
+ if (!demoAuto) return undefined;
3853
+ const P = { ...AUTOPLAY, ...demoAuto };
3854
+ const k = demoAuto.scale || 1; // tests shrink every wait by the same factor; the order never changes
3855
+ const wait = (ms) => new Promise((res) => setTimeout(res, ms / k));
3856
+ const until = async (pred, limit = 120000) => { for (let t = 0; t < limit && !R.current.closed; t += 25) { if (pred()) return true; await new Promise((res) => setTimeout(res, 25)); } return false; };
3857
+ const type = async (text, seed) => { for (const [i, d] of typingPlan(text, seed, { min: P.typeMinMs, max: P.typeMaxMs }).entries()) { if (R.current.closed) return; onData.current([...text][i]); await wait(d); } };
3858
+ (async () => {
3859
+ await wait(P.startMs);
3860
+ await type(AUTOPLAY_REQUEST, P.seed); await wait(P.afterTypeMs); onData.current('\r');
3861
+ if (!await until(() => S.current.approvals[0]?.displayedAt != null)) return;
3862
+ await wait(Math.max(P.approvalHoldMs, 450 * k)); // never shorter than the card's arming window + key-repeat guard
3863
+ say('autoplay presses y: approve', 'ok');
3864
+ onData.current('y'); await wait(P.afterKeyMs);
3865
+ if (!await until(() => !S.current.turn && !S.current.approvals.length && !S.current.running.length)) return;
3866
+ await wait(Math.max(P.afterTurnMs, 450 * k)); // never shorter than CARD_GONE_MS: an Enter right after the card closes is swallowed (matters only when tests shrink the waits)
3867
+ await type(AUTOPLAY_PROOF, P.seed + 1); await wait(P.afterTypeMs); onData.current('\r');
3868
+ await wait(P.finalHoldMs);
3869
+ if (P.exitAfterMs != null) { await wait(P.exitAfterMs); if (!R.current.closed) exit(); }
3870
+ })();
3871
+ return undefined;
1131
3872
  }, []);
1132
3873
  React.useEffect(() => { // --demo plays by itself
1133
3874
  if (!autoplay) return undefined;
@@ -1138,14 +3879,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1138
3879
  if (!stdin) return undefined;
1139
3880
  if (isRawModeSupported) setRawMode(true);
1140
3881
  if (stdout?.isTTY) stdout.write(PASTE_ON);
3882
+ if (MO.current.on) mouseTerm(true); // /mouse on, saved for this profile
1141
3883
  const fn = (d) => onData.current(d);
1142
- if (earlyInput) { // keys typed during startup: into the draft as typed, never submitted, never a paste-submit
3884
+ if (earlyInput) { // keys typed during startup: replayed exactly as if typed now (R3 D4: an Enter submits, it is not dropped)
1143
3885
  earlyInput.detach();
1144
- for (const chunk of earlyInput.chunks.splice(0)) {
1145
- const parsed = parseChunk(chunk, K.current.keys);
1146
- K.current.keys = parsed.state;
1147
- handleEvents(parsed.events, Date.now(), true);
1148
- }
3886
+ for (const chunk of earlyInput.chunks.splice(0)) onData.current(chunk);
1149
3887
  }
1150
3888
  stdin.on('data', fn);
1151
3889
  return () => {
@@ -1158,6 +3896,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1158
3896
  R.current.pollWake?.();
1159
3897
  stdin.off('data', fn);
1160
3898
  if (stdout?.isTTY) stdout.write(PASTE_OFF);
3899
+ mouseTerm(false); // mouse tracking never outlives the client (or leaks into a /restart child)
1161
3900
  SND.current?.dispose?.(); // focus reporting off: it must not outlive the client (or leak into a /restart child)
1162
3901
  if (isRawModeSupported) setRawMode(false);
1163
3902
  };
@@ -1177,28 +3916,33 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1177
3916
  // Reveals: register what just landed, then give <Static> every leading item whose reveal has ended.
1178
3917
  const nowR = Date.now();
1179
3918
  let pendingHist = [];
1180
- if (introDone.current) {
1181
- if (!motionOn) committedRef.current = s.history.length;
3919
+ const sleeping = Boolean(R.current.sleep); // asleep: nothing is committed to scrollback, so waking loses nothing
3920
+ if (introDone.current && !sleeping) {
3921
+ const lim = commitLimit(s); // ASK-SETTLE: an armed run card stays live until it settles
3922
+ if (!motionOn) committedRef.current = Math.max(committedRef.current, lim);
1182
3923
  else {
1183
3924
  for (let i = committedRef.current; i < s.history.length; i++) registerFx(s.history[i], nowR);
1184
3925
  for (const b of s.blocks) registerFx(b, nowR);
1185
3926
  // The newest protocol stage stays live (its icon pulses) while it is the last thing that happened in the turn.
1186
3927
  const activeStage = (i) => isActiveStage(s, i);
1187
- while (committedRef.current < s.history.length && !fxActive(s.history[committedRef.current], nowR) && !activeStage(committedRef.current)) fxRef.current.delete(s.history[committedRef.current++].id);
1188
- pendingHist = s.history.slice(committedRef.current);
3928
+ while (committedRef.current < lim && !fxActive(s.history[committedRef.current], nowR) && !activeStage(committedRef.current)) fxRef.current.delete(s.history[committedRef.current++].id);
1189
3929
  for (const r of R.current.roster.rows) if (!rosterSeen.current.has(r.key)) rosterSeen.current.set(r.key, nowR);
1190
3930
  }
3931
+ pendingHist = s.history.slice(committedRef.current);
1191
3932
  }
1192
3933
  lastAppRender.current = nowR;
1193
3934
  // Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
1194
3935
  const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
1195
- const card = s.approvals[0];
1196
- const armed = card ? Date.now() - (card.displayedAt ?? card.shownAt) >= 300 : false;
3936
+ const queue = queueOf(s);
3937
+ const card = queue[0] || null;
3938
+ const armed = card ? Date.now() - Math.max(card.displayedAt ?? card.shownAt ?? 0, K.current.guard?.since ?? 0) >= 300 : false;
3939
+ R.current.cardMidAnim = Boolean(card && motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000); // this render draws the card mid-animation (appDue keeps it due until a settled draw)
3940
+ if (motionStats && motionOn && card && card.displayedAt != null && !(motionStats.cards ||= []).some(x => x.card.callId === card.callId)) motionStats.cards.push({ card, org: orgShown() || null }); // the card took the draw-in path (a test renders it at fixed motionE)
1197
3941
  R.current.armedShown = armed;
1198
- const ctxParts = [profile, org, modelLabel(s.ctx, MC.current), demoLabel ? 'demo' : mode, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules)];
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
1199
3943
  const caps = R.current.caps;
1200
- const hint = busy ? `${caps.steer ? 'Enter steer' : 'Enter queue'} · Tab queue · ${caps.interrupt ? 'Esc stop' : 'Esc disconnects'}` : 'Enter send · /help';
1201
- const placeholder = busy ? (caps.steer ? 'Type to steer (Enter) or queue (Tab)' : 'Type to queue (Enter or Tab)') : 'Ask Quox anything…';
3944
+ const hint = (busy ? `${caps.steer ? 'Enter steer' : 'Enter queue'} · Tab queue · ${caps.interrupt ? 'Esc stop' : 'Esc disconnects'}` : ''); // owner 22:12 "Lose this: Enter send · /help": only the hint a running turn needs is shown
3945
+ const placeholder = busy ? (caps.steer ? 'Type to steer (Enter) or queue (Tab)' : 'Type to queue (Enter or Tab)') : R.current.askArmed ? ASK_PROMPT : 'Ask Quox anything…';
1202
3946
 
1203
3947
  // The live region (unsettled blocks + chrome) must stay SHORTER than the screen: log-update can only
1204
3948
  // erase rows that are still on screen, and Ink's own tall-frame path loses a line of history (term.js).
@@ -1215,9 +3959,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1215
3959
  ];
1216
3960
  }
1217
3961
 
3962
+ // The ⏸ approval row under the status line already says how many wait: the "▶ N approvals waiting" line is then a duplicate.
3963
+ function approvalRowShown() {
3964
+ const ro = R.current.roster;
3965
+ if (!ro.on || !ro.rows.some(r => r.kind === 'approval')) return false;
3966
+ const v = rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40, max: MAX_ROSTER, on: ro.on, sel: ro.sel, focus: ro.focus, now: Date.now() });
3967
+ return v.mode === 'full' && v.lines.some(l => l.kind === 'approval');
3968
+ }
3969
+
1218
3970
  function rosterNode() {
1219
3971
  const ro = R.current.roster;
1220
- 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() });
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() });
1221
3973
  const row = ro.rows[ro.sel];
1222
3974
  return h(Roster, {
1223
3975
  view, cols,
@@ -1228,35 +3980,53 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1228
3980
  });
1229
3981
  }
1230
3982
 
3983
+ if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
3984
+ const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
3985
+ return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
3986
+ h(Static, { key: `static-${epoch}`, items: s.history.slice(0, committedRef.current) }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(s.history[i - 1], it, termRows(stdout)) }))),
3987
+ h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
3988
+ }
3989
+ const ruleAbove = endsInRule({ card, asks: s.asks || [], background: s.background, pending: s.pending, steering: s.steering, countdown: Boolean(AR.current.timer && AR.current.endsAt) }) && !(SU.current.isOpen() || LI.current.isOpen() || CN.current.isOpen());
1231
3990
  return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
1232
3991
  // Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
1233
3992
  // already-printed items would drop it (and reprint the rest).
1234
- h(Static, { key: `static-${epoch}`, items: introDone.current ? s.history.slice(0, committedRef.current) : [] }, (it) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols }))),
1235
- introDone.current ? null : h(Box, { key: 'intro-live', flexDirection: 'column' }, h(Intro, { item: introItem, cols, eye, progress: Math.min(1, (Date.now() - introStart.current) / introMs) })),
3993
+ h(Static, { key: `static-${epoch}`, items: introDone.current ? s.history.slice(0, committedRef.current) : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(s.history[i - 1], it, termRows(stdout)) }))),
3994
+ introDone.current ? null : h(Box, { key: 'intro-live', flexDirection: 'column' }, h(Intro, { item: introItem, cols, dragon: introDragon, progress: Math.min(1, (Date.now() - introStart.current) / introMs) })),
1236
3995
  liveRegion([
1237
- ...pendingHist.map((it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(FxItem, { item: it, cols, fxFor, active: busy && it.kind === 'trace' && i === pendingHist.length - 1 && !s.blocks.length }))),
1238
- ...s.blocks.map(b => h(Box, { key: b.id, flexDirection: 'column' }, motionOn && revealTimingFor(b) ? h(FxItem, { item: b, cols, live: true, fxFor }) : h(HistoryItem, { item: b, cols, live: true })))]),
1239
- h(EyeLive, { key: 'eye', cols }),
3996
+ ...pendingHist.map((it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(FxItem, { item: it, cols, fxFor, gap: historyGap(i ? pendingHist[i - 1] : s.history[committedRef.current - 1], it, termRows(stdout)), active: busy && it.kind === 'trace' && i === pendingHist.length - 1 && !s.blocks.length }))),
3997
+ ...s.blocks.map((b, i) => { const gap = historyGap(i ? s.blocks[i - 1] : (pendingHist[pendingHist.length - 1] ?? s.history[committedRef.current - 1]), b, termRows(stdout)); return h(Box, { key: b.id, flexDirection: 'column' }, motionOn && revealTimingFor(b) ? h(FxItem, { item: b, cols, live: true, fxFor, ignition: feats().ignition, gap }) : h(HistoryItem, { item: b, cols, live: true, gap })); })]),
3998
+ h(DragonLive, { key: 'dragon', cols }),
3999
+ flyActive(nowR) ? h(FlyLive, { key: 'dragon-fly', cols, rows: termRows(stdout), startedAt: R.current.fly.startedAt, still: R.current.fly.still }) : null,
4000
+ dragonTalkVisible(nowR) ? h(DragonTalk, { key: 'dragon-talk', cols, label: R.current.dragonTalk.label, talking: !R.current.dragonTalk.armed && !R.current.dragonTalk.endedAt, since: R.current.dragonTalk.since, still: !motionOn || mono() }) : null,
1240
4001
  h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
1241
- busy ? h(Working, { key: 'working', motion: motionOn, 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,
1242
- card ? h(ApprovalCard, { card, org: org || api.orgId?.() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, draftHint: B.current.text ? (K.current.cardFocus ? 'focus' : 'draft') : null }) : null,
4002
+ busy ? h(Working, { key: 'working', motion: motionOn && !R.current.exiting, handoff: feats().handoff, tokens: turnTokens(s.turn), act: s.turn.act, activity: describeActivity(s), since: s.turn.startedAt, interrupting: s.turn.status === 'interrupting', escLabel: caps.interrupt ? null : 'Esc disconnects' }) : null,
4003
+ card ? h(ApprovalCard, { card, org: orgShown() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, draftHint: K.current.cardFocus ? 'focus' : (B.current.text ? 'draft' : null), confirm: K.current.confirm, position: { i: 1, n: queue.length } }) : null,
4004
+ ...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
1243
4005
  // TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
1244
4006
  s.background > 0 ? h(Box, { paddingLeft: 2 }, h(Text, { color: c('dim') }, `Waiting for ${s.background} background agent${s.background === 1 ? '' : 's'} to finish`)) : null,
1245
4007
  h(Pending, { pending: s.pending, steering: s.steering, cols }),
1246
- h(Box, { marginTop: 1 }),
1247
- CN.current.isOpen() ? h(ConnectCard, { ...CN.current.view(), cols })
1248
- : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : mode, busy, placeholder, name: SN.current.name, ring })
1249
- : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : mode, busy, placeholder, name: SN.current.name }),
4008
+ AR.current.timer && AR.current.endsAt ? h(Box, { key: 'restart-countdown', paddingLeft: 2 }, h(Text, { color: c('accent'), bold: true }, countdownLine(Math.max(1, Math.ceil((AR.current.endsAt - Date.now()) / 1000)), cols))) : null,
4009
+ ruleAbove ? null : h(Box, { marginTop: 1 }),
4010
+ SU.current.isOpen() ? h(StepUpCard, { view: SU.current.view(), cols }) : LI.current.isOpen() ? h(LoginCard, { ...LI.current.view(), cols, profile }) : CN.current.isOpen() ? h(ConnectCard, { ...CN.current.view(), cols })
4011
+ : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
4012
+ : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove }),
1250
4013
  !CN.current.isOpen() && PK.current.isOpen() && !card ? h(ListView, { ...PK.current.view(), cols }) : !CN.current.isOpen() && PL.current.isOpen() && !card ? h(ListView, { ...PL.current.view(), cols }) : null,
1251
- h(StatusLine, { cols, slot: where.current.slot, agent: where.current.agent, parts: ctxParts, right: hint }),
1252
- h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), alerts: R.current.roster.all ? [] : R.current.roster.alerts }),
4014
+ h(RowsBar, { rows: RW.current.view(), cols }),
4015
+ h(PresentBar, { stepUp: SU.current, cols }),
4016
+ h(StatusLine, { cols, slot: where.current.slot, agent: where.current.agent, groups: [...SC.current.rooms].map(([name, e]) => ({ name, unread: e.unread })), parts: ctxParts, right: hint }),
4017
+ h(WaitingNotice, { items: s.waiting.filter(x => K.current.dismissed.has(x.callId)), cols }),
4018
+ h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
1253
4019
  rosterNode())));
1254
4020
  }
1255
4021
 
1256
4022
  /** A live item that may be revealing: re-renders on every clock frame by itself (not the app). */
1257
- function FxItem({ item, cols, live = false, fxFor, active = false }) {
4023
+ function FxItem({ item, cols, live = false, fxFor, active = false, ignition = false, gap }) {
1258
4024
  const now = useFrame();
1259
- const pulse = (item.kind === 'tool' && item.status === 'running') || active ? now : null;
1260
- return h(HistoryItem, { item, cols, live, fx: fxFor(item, now), pulse });
4025
+ const running = item.kind === 'tool' && item.status === 'running';
4026
+ const pulse = running || active ? now : null;
4027
+ const fx = fxFor(item, now);
4028
+ // expressive: a running tool row ignites as it lands (a short warm flare, motion/level.js ignitionGlow)
4029
+ const ignite = ignition && running && fx ? ignitionGlow(fx.e * 1000) : 0;
4030
+ return h(HistoryItem, { item, cols, live, fx, pulse, ignite, gap });
1261
4031
  }
1262
4032
  export { COLORS };