quoxcli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/quoxon/app.js CHANGED
@@ -14,16 +14,19 @@ import { bufferReducer, initialBuffer, sanitizeForSend, wordAt, selection, selec
14
14
  import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, lineAt, MOUSE_HINT, screenHit, classifyClick, composerIndexAt } from './mouse.js';
15
15
  import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
16
16
  import { isHeldFromBulk, heldReason } from './bulkRisk.js';
17
- import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, approvalAsWalkItem, parseAnswer, parsePairs } from './approvalsView.js';
17
+ import { isDestructive, destructiveObject } from './destructive.js';
18
+ import { batchFor, batchModel, batchLabel, collapseArrivals } from './approvalBatch.js';
19
+ import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, approvalAsWalkItem, parseAnswer, parsePairs, needsPin } from './approvalsView.js';
20
+ import { createAutoOpen, noteArrivals, openDecision, takeQueue, strayAnswer, parseApprovalsArg, autoOn, APPROVALS_OPTIONS } from './autoOpen.js';
18
21
  import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
19
- import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
22
+ import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate, consentOf } from './events.js';
20
23
  import { turnTokens } from './indicator.js';
21
24
  import {
22
- Composer, RingComposer, Pending, ApprovalCard, LoopCardView, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
23
- , endsInRule } from './render.js';
24
- import { c, mono } from './theme.js';
25
+ Composer, RingComposer, Pending, ApprovalCard, BatchCard, LoopCardView, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
26
+ , endsInRule, approvalsPollStale } from './render.js';
27
+ import { c, mono, isLight } from './theme.js';
25
28
  import { strayCardAnswer, strayQuestion, alreadyQueued } from './stray.js';
26
- import { findEntry, helpModel, nearestCommands, joinWrappedSlash, commandNames, REGISTRY, visibleRegistry } from './commands.js';
29
+ import { findEntry, helpModel, nearestCommands, joinWrappedSlash, commandNames, REGISTRY, visibleRegistry, setExtEntries } from './commands.js';
27
30
  import { osc52, pickCopy } from './copy.js';
28
31
  import { detectSlotAgent } from './context.js';
29
32
  import { rulesContext, rulesLabel, rulesLines } from './rules.js';
@@ -33,14 +36,16 @@ import { startupWhatsNew, listWhatsNew, unseenEntries, seenRecord, tryText, LIST
33
36
  import { nextTip, normaliseMode, tipSettingsReply, tipsReply, DEFAULT_MODE, TIPS, tryCommand, tipText } from './tips.js';
34
37
  import { findSkill, skillRows, skillDetail, skillPrompt } from './skills.js';
35
38
  import { createScreenContext } from './screenContext.js';
39
+ import { parseSlotAttach } from './slotAttach.js';
36
40
  import { askText, askQuestion, askAnswer, askChatMessage, settledAskLine, settledAskTone, askOutcomeOf, isAskClarification, askClock } from './ask.js';
37
41
  import { objectiveFromEvent, objectiveAskText, objectiveStartedNote, objectiveWatchLine, objectiveFailedNote, watchObjective } from './objectiveOffer.js';
38
42
  import { createHandoffStreak, handoffBiggerAskText, handoffFromEvent, handoffAskText, handoffAnsweredNote, handoffSupersededNote, handoffSkippedNote } from './handoffOffer.js';
39
43
  import * as selfUpdate from '../core/selfUpdate.js';
40
44
  import { parseDone, todoDoneLine, parseTodo, todoAddedLine, todoBoardText, numberBoard, resolveRef, todoMovedLine } from './slash/todoCmd.js';
41
45
  import { runRequest, runAnswer, handOverRun, runFooter } from './slotRun.js';
42
- import { profileChooserRows, liveApi, switchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
43
- import { rosterView, peekLines, selectRows, backgroundAlerts, isExpired, applyProgress, SPIN_MS } from './roster.js';
46
+ import { profileChooserRows, liveApi, switchAskLine, startupSwitchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
47
+ import { attachLines, sessionLines } from './rosterSessions.js';
48
+ import { rosterView, peekLines, selectRows, fromApproval, backgroundAlerts, isExpired, applyProgress, SPIN_MS } from './roster.js';
44
49
  import { runObjectCommand } from './objects/index.js';
45
50
  import { runEvidenceCommand } from './slash/evidenceCmd.js';
46
51
  import { peekLines as peekIdLines } from '../core/peek.js';
@@ -49,8 +54,11 @@ import { createMentionCache, loadMentions, receiptMentions } from './mentions.js
49
54
  const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile', '/fleet', '/prove', '/replay', '/health', '/core']);
50
55
  import { intentHint } from './intent.js';
51
56
  import { observerLines } from './observers.js';
52
- import { inboxBarText, arrivals, arrivalLine, inboxTable, inboxDetail, parseInbox, INBOX_USAGE, newestFirst, isApprovalItem } from './inboxView.js';
57
+ import { runWithSpinner, spinnerVerb } from './slashSpinner.js';
58
+ import { parseMail, mailTable, mailDetail, noMailboxLines, mailNewestFirst, MAIL_USAGE } from './mailView.js';
59
+ import { inboxBarText, arrivals, loadSeen, withSeen, arrivalLine, inboxTable, announceFresh, activeMutes, mutesLines, inboxDetail, parseInbox, INBOX_USAGE, newestFirst, unreadItems, readItems, isApprovalItem, isMuted } from './inboxView.js';
53
60
  import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
61
+ import { isWebPty, SHELL_REFUSED } from './webPty.js';
54
62
  import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
55
63
  import { packageVersion } from './welcome.js';
56
64
  import { feedbackPayload, feedbackPreview } from './feedback.js';
@@ -59,7 +67,8 @@ import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // P
59
67
  import { echoLine } from './echo.js';
60
68
  import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, looksLikeAddress, sayWhoText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
61
69
  import { pushHistory } from './promptHistory.js';
62
- import { termRows } from './term.js';
70
+ import { secretKinds, redactSecrets } from './secretShape.js';
71
+ import { termRows, liveCols } from './term.js';
63
72
  import { cardTallerThanScreen } from './objects/view.js';
64
73
  import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows, confirmLanAllow } from './connect.js';
65
74
  import { ConnectCard } from './connectCard.js';
@@ -68,9 +77,11 @@ import { createLoginController, LoginCard, SIGNED_OUT_LINE, signedOutCard, isSes
68
77
  import { createStepUp } from './slash/stepUp.js';
69
78
  import { StepUpCard, PresentBar } from './slash/stepUpCard.js';
70
79
  import { createStepUpCmds, declinedText } from './slash/stepUpCmds.js';
80
+ import { createConfirm } from './slash/confirm.js';
71
81
  import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
72
- import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
82
+ import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, limitedOffer, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
73
83
  import { humanSessionLines } from '../core/sessionsOverview.js';
84
+ import { resolutionFromItem } from '../core/inboxResolution.js';
74
85
  import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
75
86
  import { capabilityLines, offerFor, findCapability } from './capabilities.js';
76
87
  import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
@@ -85,12 +96,12 @@ import { createSound } from './sound/index.js';
85
96
  import { createPalette } from './slash/paletteController.js';
86
97
  import { capabilityMap } from './slash/palette.js';
87
98
  import { createArgSources } from './slash/argSources.js';
88
- import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
99
+ import { runBroadcast, runAck, parsePokeArgs, slotNames } from './slash/broadcastRun.js';
89
100
  import { parseBroadcastText } from './slash/broadcast.js';
90
101
  import { isOwnBroadcast } from './ownBroadcast.js';
91
102
  import { orgLabel, isOrgId } from './orgLabel.js';
92
103
  import { enrichPlanItem } from './planSteps.js';
93
- import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine } from './slotChat.js';
104
+ import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, withSenderSlot, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine, reattachSlowLine } from './slotChat.js';
94
105
  import { homedir as osHome } from 'node:os';
95
106
  import { createRowCmd } from './slash/rowCmd.js';
96
107
  import { createKillerCmds } from './slash/killer.js';
@@ -98,11 +109,13 @@ import { createUndoWish } from './slash/undoWish.js';
98
109
  import { createHatWalk } from './slash/hatWalk.js';
99
110
  import { createRowEngine, runShell } from './rows.js';
100
111
  import { qpeCommand, qpeRowsSetting, QPE_GRID } from './slash/qpeCmd.js';
101
- import { createExtensionHost, pickDraft as deckPickDraft, readExtList, extSetEnabled, extAdd } from './extensions.js';
112
+ import { deckOffer, runDeckOffer, declineText } from './deckLazy.js';
113
+ import { createExtensionHost, pickDraft as deckPickDraft, readExtList, extSetEnabled, extAdd, panelKeyName } from './extensions.js';
102
114
  import { createLoopCards, rowsWithoutLoopCards } from './loopCards.js';
103
115
  import { initLoop, applySnapshot as applyLoopSnapshot, cardLines } from './loopCard.js';
104
116
  import { createCompanionController, companionCommand, DragonCompanionLive, takeGreeting, detachGreeting } from './dragonCompanion.js';
105
117
  import { dragonForAgent } from '../dragon/select.js';
118
+ import { delegationDragonOn, markDelegationDragon } from './delegationDragon.js';
106
119
  import { createDragonJobs } from '../dragon/jobs.js';
107
120
  import { createModelCmd } from './slash/modelCmd.js';
108
121
  import { createChallengeCmd } from './slash/challenge.js';
@@ -122,6 +135,8 @@ import { supportsHyperlinks } from './markdown.js';
122
135
  import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
123
136
  import { intentPanel } from './intent.js';
124
137
  import { awayDue, parseSince } from './away.js';
138
+ import { parseSlashArgs as parseCallMeSlashArgs, statusLines as callMeStatusLines } from '../core/callMe.js';
139
+ import { parseDndArgs, untilEpoch, sleepIntent, afkMeansSleep, newCollected, morningLines, sinceLines, isIncident, waitingCount, DND_NOTE } from './dnd.js';
125
140
  import { sitrepData } from './objects/sitrep.js';
126
141
  import { mirrorErrorNote } from './objects/mirror.js';
127
142
  import { launchEgg } from './egg.js';
@@ -130,12 +145,17 @@ import { Clock, FRAME_MS, SECOND_MS, motionAllowed, createFrames } from './motio
130
145
  import { FrameContext, useFrame } from './motion/frame.js';
131
146
  import { ringMode, ringAnimating } from './motion/ring.js';
132
147
  import { TIMING, revealSpan } from './motion/reveal.js';
133
- import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
148
+ import { matchOrg, orgLines, orgRows, orgCandidates } from './orgSwitch.js';
134
149
  import { INSTALL_TOOLS, SUGGEST_LINE, planLine, offerQuestion, runInstall, missingTools } from './installCli.js';
135
150
  import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
136
- import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
151
+ import { slotOutbox, isConnectivityFailure, queuedLine, sentLine, fullLine } from './slotOutbox.js';
152
+ import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING, isTurnIdTaken, HELD_NOT_SHOWN } from './resend.js';
153
+ import { gripeFiledLine, gripeFailedLine, gripeQueuedFiledLine } from '../core/quoxglyph.js';
154
+ import { crossOrgTuiLines } from '../core/crossOrgApprovals.js';
155
+ const gripeLead = '📣 GRIPE │';
137
156
 
138
157
  const h = React.createElement;
158
+ const BARE_COMMAND_WORDS = new Set(['clear', 'exit', 'quit', 'help', 'back', 'detach', 'restart', 'inbox', 'approvals']); // a message that is exactly one of these is offered as the command first
139
159
  const PASTE_GUARD_MS = 40;
140
160
  const ASK_ECHO_MS = 1500; // TUI-INPUT: how long an Enter after a single-key ask answer is treated as the confirm habit
141
161
  const MAX_ROSTER = 4;
@@ -150,7 +170,8 @@ const CARD_GONE_MS = 400;
150
170
  const STEER_OFF_NOTE = "This model can't take steering mid-turn; queued for next turn.";
151
171
  const newTurnId = () => `QXN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
152
172
 
153
- function openEditor(text) {
173
+ export function openEditor(text) {
174
+ if (isWebPty(process.env)) return null; // QUOXON web PTY: no $EDITOR on the server
154
175
  const dir = mkdtempSync(join(tmpdir(), 'quoxon-'));
155
176
  const file = join(dir, 'prompt.md');
156
177
  writeFileSync(file, text, 'utf8');
@@ -169,8 +190,15 @@ export const friendlyError = (e, profile) =>
169
190
  const byId = (id) => CHOICES.find(x => x.id === id) || null;
170
191
  // the status line shows the new ask until the server answers
171
192
  const NO_RAN = { provider: null, model: null, endpoint: null };
193
+ // /gripe keeps the newlines of what was pasted: the whole body goes into the issue, not a space-joined line.
194
+ const GRIPE_LINE = /^\/gripe!?(\s|$)/i;
195
+ const isOneLineCommand = (t) => t.startsWith('/') && (!t.includes('\n') || GRIPE_LINE.test(t));
172
196
 
173
- 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, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, 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 }) {
197
+ export const INBOX_BAR_MAX_MS = 300000; // the bar never waits longer than this between reads
198
+ export const INBOX_BAR_READ = 10; // the bar counts the latest 10 items, in the poll AND in /inbox (one basis: no '7 new' then '25 new')
199
+ export const INBOX_BAR_COST_X = 20; // wait 20x the last read's duration (a 3s read -> 60s; a fast read keeps the poll cadence)
200
+
201
+ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, restartDeclined = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], startupGraceMs = 60000, startupRetryMs = 3000, neverAnswered = false, onConnect = null, restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, restartCooloffMs = null, presenceRetryDelays = [2000, 5000, 10000, 20000, 30000, 60000], presencePollMs = 30000, autoIdleMs = null, autoTickMs = 1000, autoResumeMs = 3000, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, startupSwitchOffer = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, outboxPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined, walkTtlMs = 1800000 }) {
174
202
  // Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
175
203
  // one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
176
204
  // PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
@@ -196,12 +224,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
196
224
  const exit = (afterMs = 0) => {
197
225
  const R0 = R.current;
198
226
  if (R0.exiting) return undefined;
199
- try { DK.current?.unloadAll(); } catch { /* closing the deck never blocks exit */ }
227
+ let extClosing = null;
228
+ try { extClosing = DK.current?.unloadAll(); } catch { /* closing the deck never blocks exit */ } // async close() is awaited up to 500 ms
200
229
  R0.exiting = true; R0.sleep = null; R0.fly = null;
201
230
  R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
202
231
  resetDragon();
203
232
  bump(n => n + 1);
204
- return setTimeout(() => inkExit(), 60 + afterMs);
233
+ return setTimeout(() => { Promise.resolve(extClosing).then(() => inkExit(), () => inkExit()); }, 60 + afterMs);
205
234
  };
206
235
  const [epoch, setEpoch] = React.useState(0);
207
236
 
@@ -246,7 +275,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
246
275
  // Drop-in reveals: history items enter <Static> only once their reveal has ended (at most ~1 s); until then
247
276
  // they render in the live region. committedRef = how many history items <Static> has been given.
248
277
  const committedRef = React.useRef(introAnimating ? 0 : S.current.history.length);
249
- const staticRef = React.useRef({ list: [], seen: 0, deferred: [] }); // what <Static> was given: committed items in order, minus loop cards still running (they join when they settle)
278
+ const staticRef = React.useRef({ list: [], seen: 0, deferred: [], epoch: 0 }); // what <Static> was given: committed items in order, minus loop cards still running (they join when they settle)
250
279
  const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
251
280
  const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
252
281
  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 });
@@ -255,7 +284,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
255
284
  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 } });
256
285
  // QUOXON-HISTORY: Up recalls what was typed in earlier runs too (most recent first); main.js passes the file store.
257
286
  if (!R.current.histLoaded) { R.current.histLoaded = true; try { R.current.history = historyStore?.load?.() || []; } catch { R.current.history = []; } }
258
- const remember = (t) => { if (pushHistory(R.current.history, t)) { try { historyStore?.add?.(t); } catch { /* history is a convenience */ } } };
287
+ // remember: a /verify-email line with a 6-digit run (a PIN typed by mistake) is stored as the verb only
288
+ const remember = (t0) => { const t = /^\/verify-email(\s|$)/i.test(String(t0).trim()) && /(^|\D)\d{6}(\D|$)/.test(String(t0)) ? '/verify-email' : redactSecrets(t0); if (pushHistory(R.current.history, t)) { try { historyStore?.add?.(t); } catch { /* history is a convenience */ } } };
259
289
  // E5: when this profile was last seen (read once at launch, before it is refreshed), and a throttled touch.
260
290
  const AWAY = React.useRef({ last: null, touched: 0 });
261
291
  const touchSeen = (force = false) => {
@@ -267,10 +297,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
267
297
 
268
298
  const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
269
299
  /** QUOXON-FW15: the one card shown: the oldest waiting approval (this conversation first, then other sessions/plugins) not dismissed with Tab. */
270
- const queueOf = (st) => { const all = orderApprovals([...st.approvals, ...st.waiting], LC.current.all().map((c) => c.id)); const live = new Set(all.map(x => x.callId)); for (const id of K.current.dismissed) if (!live.has(id)) K.current.dismissed.delete(id); return all.filter(x => !K.current.dismissed.has(x.callId)); };
300
+ const queueOf = (st) => { const all = orderApprovals([...st.approvals, ...st.waiting], LC.current.all().map((c) => c.id)); const live = new Set(all.map(x => x.callId)); for (const id of K.current.dismissed) if (!live.has(id)) K.current.dismissed.delete(id); if (AWAY.current.afk) return []; /* AFK-QUIET-CARDS: away = no card, no armed answer keys; the count rides the notify bar until /back */ return all.filter(x => !K.current.dismissed.has(x.callId)); };
271
301
  const headCard = (st) => queueOf(st)[0] || null;
272
302
  const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
273
303
  const dispatch = (a) => {
304
+ // A slash command whose request failed for want of a user session: one signed-out line plus the single
305
+ // sign-in offer (never the plain-CLI service-key paragraph). Covers every command's error path.
306
+ if (a.type === 'error' && NO_SESSION_RE.test(String(a.text || ''))) { noSessionAnswer(); return; }
274
307
  if (a.type === 'note') SCX.current.notice(a.text, a.tone);
275
308
  else if (a.type === 'error') SCX.current.error(a.text);
276
309
  else if (a.type === 'event' && a.ev) {
@@ -278,8 +311,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
278
311
  else if (a.ev.type === 'turn.finished' && a.ev.status && !['ok', 'success', 'completed'].includes(a.ev.status)) SCX.current.failedTurn(a.ev.status);
279
312
  }
280
313
  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
281
- const prevS = S.current; S.current = reduce(prevS, { now: Date.now(), ...a }); SND.current.observe(prevS, S.current, a);
282
- if (motionOn && !mono() && feats().moods) { const m = DC.current.moodFor(prevS, a, S.current); if (m) playDragon(m); } // cinematic: the dragon reacts (one moment engine: an attached companion owns the moment instead, dragonMoments.js)
314
+ const prevS = S.current; S.current = reduce(prevS, { now: Date.now(), ...a }); if (!AWAY.current.afk) SND.current.observe(prevS, S.current, a); // DND: no sound while away
315
+ if (!AWAY.current.afk && motionOn && !mono() && feats().moods) { const m = DC.current.moodFor(prevS, a, S.current); if (m) playDragon(m); }
316
+ if (!AWAY.current.afk && (S.current.waiting?.length || 0) > (prevS.waiting?.length || 0)) DC.current.waitingCards(prevS, S.current).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); // another session's approval from a dragon agent: its dragon (the Q dragon only reacts to this conversation's cards, so never two) // cinematic: the dragon reacts (one moment engine: an attached companion owns the moment instead, dragonMoments.js)
283
317
  // Per shown card: remember the key that was already down when it appeared, and restart the typed word.
284
318
  const c0 = headCard(S.current);
285
319
  if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
@@ -297,6 +331,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
297
331
  // (never printed to scrollback while open); reconcileAsks settles it to one line once it is no longer open.
298
332
  const askSeq = React.useRef(0);
299
333
  const say = (text, tone) => {
334
+ R.current.spinStop?.(); // INSTANT-FEEDBACK: the first printed line replaces the spinner
300
335
  if (tone !== 'ask') return dispatch({ type: 'note', text, tone });
301
336
  const id = `ask-${++askSeq.current}`;
302
337
  R.current.lastAsk = { text, question: askQuestion(text), id };
@@ -349,10 +384,23 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
349
384
  const CN = React.useRef(null);
350
385
  CN.current ??= createConnectController({ api, note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), refresh: () => bump(n => n + 1) });
351
386
  // /login card: email (prefilled), masked password, MFA code if asked. Same login path as `quox login`.
352
- const markSignedOut = (reason = null) => { if (BL.current) return; setSignedOut({ reason }); say(signedOutCard(profile, reason), 'error'); };
387
+ // Signed out: offer the fix as y/N (y = the same flow as /login), not only instruct. Same 2-minute ask window as the other offers.
388
+ const offerSignIn = () => { R.current.loginAsk = { at: Date.now() }; say(askText('Sign in now?', { title: `Signed out of ${profile}.`, action: 'open the sign-in card (same as /login)' }), 'ask'); };
389
+ // Once per sign-out: a ref (not state) so concurrent polls in one tick and the stale-credentials retry cannot re-announce.
390
+ const soNoticed = React.useRef(Boolean(signedOutProp));
391
+ const NO_SESSION_RE = /No authentication token|QUOX_SERVICE_KEY|service key|valid jwt|session expired|not signed in/i;
392
+ const noSessionAnswer = () => {
393
+ const first = !soNoticed.current;
394
+ setSignedOut(signedOutRef.current || { reason: null }); soNoticed.current = true;
395
+ say(SIGNED_OUT_LINE, 'error');
396
+ const live = R.current.loginAsk && Date.now() - R.current.loginAsk.at < 120000;
397
+ if (first && !live) offerSignIn();
398
+ };
399
+ const markSignedOut = (reason = null) => { if (BL.current) return; setSignedOut({ reason }); if (soNoticed.current) return; soNoticed.current = true; say(signedOutCard(profile, reason), 'error'); offerSignIn(); };
400
+ React.useEffect(() => { if (signedOutProp) offerSignIn(); }, []); // a window that started signed out gets the same offer
353
401
  const LI = React.useRef(null);
354
402
  LI.current ??= createLoginController({ api, note: (t, tone) => say(t, tone), refresh: () => bump(n => n + 1), onSignedIn: () => {
355
- setSignedOut(null); BL.current = blockedProp || null;
403
+ R.current.loginAsk = null; soNoticed.current = false; setSignedOut(null); BL.current = blockedProp || null;
356
404
  R.current.notify = { fails: 0, authLost: false };
357
405
  dispatch({ type: 'notify', patch: { error: null } });
358
406
  refreshNotify(); refreshRoster();
@@ -361,9 +409,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
361
409
  // QUOXON-STEPUP: the PIN card. The digits live in its closure box only: never dispatched, buffered, in history,
362
410
  // undo, the transcript or any log. Agent logins never get a PIN field (slash/stepUp.js).
363
411
  const SU = React.useRef(null);
412
+ const AO = React.useRef(null); AO.current ??= { st: createAutoOpen({ enabled: autoOn(api.settings?.()) }), timer: null, noticeAt: 0, othersDone: false, otherCounts: new Map(), waitSaid: false, dead: false }; // APPROVALS-AUTO: new approvals open by themselves
364
413
  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; } } });
365
414
  const SUC = React.useRef(null);
366
- SUC.current ??= createStepUpCmds({ api, stepUp: SU.current, say: (t, tone) => say(t, tone), lastBlock: () => R.current.lastBlock || null, orgOf: () => api.orgId?.() || null });
415
+ SUC.current ??= createStepUpCmds({ api, stepUp: SU.current, say: (t, tone) => say(t, tone), confirm: createConfirm({ say: (t, tone) => say(t, tone) }), lastBlock: () => R.current.lastBlock || null, orgOf: () => api.orgId?.() || null });
367
416
  // L0 palette: opens under the composer while the draft is a bare /word (slash/paletteController.js).
368
417
  const PL = React.useRef(null);
369
418
  // QUOXON-FW6: model favourites + default (server-side user preferences; profile = cache).
@@ -397,6 +446,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
397
446
  const SPN = React.useRef(null);
398
447
  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 });
399
448
  React.useEffect(() => { if (!blocked && !demoLabel && api.slotSay) SPN.current.sweep().catch(() => {}); }, []); // release this session's overdue VMs
449
+ // GRIPE-QUEUE: gripes saved while Quox did not answer are filed on start and then at most once per 2 minutes (the queue itself enforces the gap).
450
+ const gripeSweep = React.useRef(null);
451
+ gripeSweep.current = async (force = false) => {
452
+ if (!api.gripeRetry) return;
453
+ let r; try { r = await api.gripeRetry({ force }); } catch { return; }
454
+ for (const it of r?.filed || []) say(gripeQueuedFiledLine(it.url));
455
+ for (const it of r?.expired || []) say(`Gripe not filed after 24 h, giving up: "${clip(it.text, Math.max(20, cols - 50))}" (re-file it with /gripe!)`, 'warn');
456
+ };
457
+ React.useEffect(() => {
458
+ if (blocked || demoLabel || !api.gripeRetry) return undefined;
459
+ gripeSweep.current();
460
+ const t = setInterval(() => gripeSweep.current(), 125000);
461
+ return () => clearInterval(t);
462
+ }, []);
400
463
  // QUOXON-FW17: custom rows above the status line (rows.js engine, slash/rowCmd.js).
401
464
  const RW = React.useRef(null);
402
465
  RW.current ??= createRowCmd({
@@ -414,15 +477,21 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
414
477
  env,
415
478
  getSettings: () => api.settings?.() || {},
416
479
  handlers: {
417
- openApproval: (n) => { command(`/approvals ${n}`); },
418
- preselect: (n, option) => {
480
+ openApproval: (n, id) => {
481
+ if (id != null) { const i = (R.current.deckItems || []).findIndex(x => String(x?.id) === id); if (i < 0) return false; n = i + 1; } // contract v1: the id decides, a gone id is refused
482
+ command(`/approvals ${n}`);
483
+ return true;
484
+ },
485
+ preselect: (n, option, id) => {
486
+ if (id != null) { const i = (R.current.deckItems || []).findIndex(x => String(x?.id) === id); if (i < 0) return false; n = i + 1; }
419
487
  const it = (R.current.deckItems || [])[n - 1];
420
488
  const draft = deckPickDraft(it, n, option);
421
489
  if (!draft) return say(`Extension: no such option "${option}" for [${n}].`, 'warn');
422
490
  edit({ type: 'set', text: draft }); // preselected only: the answer goes when the user presses Enter
423
491
  say(`Preselected: ${draft.trim()}. Press Enter to confirm.`, 'accent');
424
492
  },
425
- changed: () => bump(n => n + 1)
493
+ note: (t, tone) => say(t, tone),
494
+ changed: () => { setExtEntries(DK.current.commands()); bump(n => n + 1); }
426
495
  },
427
496
  log: () => {}
428
497
  });
@@ -454,6 +523,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
454
523
  loops: LC.current.all().map(c => ({ id: c.state.id, title: c.state.title, turn: c.state.iteration ?? c.state.turn, max: c.state.maxTurns, status: c.state.done ? (c.state.state === 'failed' ? 'failed' : 'done') : 'running' })),
455
524
  fleet: R.current.fleetHosts || null,
456
525
  evidence: api.evidenceChain?.() || null, // quoxon holds no chain status today: 'unknown' unless the api provides one
526
+ readOkAt: (R.current.readOkAt ??= Date.now()), // last good approvals read: older than 60 s makes state.stale
457
527
  sessions: { active: rows.filter(r => r.state === 'running').length }
458
528
  });
459
529
  });
@@ -461,7 +531,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
461
531
  getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
462
532
  run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
463
533
  ctx: () => ({ api, caps: R.current.contract, blocked: BL.current }),
464
- source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all() }),
534
+ source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all(), getInbox: () => ({ list: () => R.current.inboxList || [], mutes: () => (api.settings?.() || R.current.sessionSettings || {}).inboxMutes }), getExt: () => ({ listed: () => readExtList().map(x => x.name), loaded: () => DK.current.loadedNames(), panels: () => DK.current.panels(), keys: (n) => DK.current.settingsInfo(n).defs || [] }) }),
465
535
  refresh: () => bump(n => n + 1),
466
536
  // MENTIONS: agents, hosts and tasks fetched once per session on the first @; receipts are this session's own tool envelopes
467
537
  mentions: blocked ? null : { cache: createMentionCache({ load: () => loadMentions(api), refresh: () => bump(n => n + 1) }), local: () => receiptMentions([...S.current.history, ...S.current.blocks]) }
@@ -481,7 +551,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
481
551
  }, []);
482
552
  const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
483
553
  // /restart auto: saved per profile (profile.quoxon.restartAuto). `newId` = the newest installed build seen; `cancelledId` = the build whose countdown a key cancelled.
484
- const AR = React.useRef(null); AR.current ??= { on: (api.settings?.() || {}).restartAuto === true, newId: null, cancelledId: null, timer: null, endsAt: 0 };
554
+ const AR = React.useRef(null); AR.current ??= { on: (api.settings?.() || {}).restartAuto === true, newId: null, cancelledId: null, timer: null, endsAt: 0, busyAt: 0, lastRestartAt: Number((api.settings?.() || {}).lastRestartAt) || 0, quietId: null, mountedAt: Date.now() };
555
+ // COALESCE (owner 2026-10-27): after a restart, no auto-restart for a cool-off (prop, else setting restartCooloffMin, else 30 min); a build landing
556
+ // inside it shows one quiet line (Enter restarts); afterwards the countdown also waits for autoIdleMs (default 2 min) without a keypress.
557
+ const cooloffMs = () => { if (restartCooloffMs != null) return restartCooloffMs; const m = (api.settings?.() || {}).restartCooloffMin; return (m != null && Number.isFinite(Number(m)) && Number(m) >= 0 ? Number(m) : 30) * 60000; };
558
+ const idleNeededMs = () => autoIdleMs ?? 120000;
559
+ const inCooloff = () => AR.current.lastRestartAt > 0 && Date.now() - AR.current.lastRestartAt < cooloffMs();
485
560
  // Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
486
561
  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; };
487
562
  const cdLog = (phase, seconds = null) => transcript?.action({ type: 'restart.countdown', phase, seconds, build: AR.current.newId }); // lifecycle in the dev transcript
@@ -495,6 +570,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
495
570
  const token = {}; a.waiting = token; // set first: cancels and the idle check see a restart in flight
496
571
  Promise.resolve().then(() => serverUp()).catch(() => null).then((first) => {
497
572
  if (AR.current.waiting !== token || R.current.closed) return;
573
+ if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); return; } // a turn started while the health probe ran: never kill it
498
574
  if (first?.ok === true) { stopCountdown(); cdLog('timed-out'); doRestart(); return; } // healthy: no waiting line, as before
499
575
  beginWait(token);
500
576
  });
@@ -517,15 +593,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
517
593
  } }) : undefined), []);
518
594
  const autoIdle = () => { // nothing for a restart to interrupt
519
595
  const s = S.current;
520
- return !s.turn && !s.approvals.length && !s.pending.length && B.current.text === '' && !R.current.pendingWalk && !R.current.bang && !R.current.sleep
596
+ // AWAY-RESTART (2026-11-03): away/DND is the ideal time to restart (nothing to interrupt, DND restores from the server), so the dragon
597
+ // sleep screen (/sleep opens it) and approvals queued for the morning (cards are closed while away) do not count as busy.
598
+ const isAway = Boolean(AWAY.current.afk);
599
+ return !s.turn && (isAway || !s.approvals.length) && !s.pending.length && B.current.text === '' && !R.current.pendingWalk && !R.current.bang && (isAway || !R.current.sleep)
521
600
  && !R.current.restarting && !R.current.exiting && !LI.current.isOpen() && !SU.current.isOpen() && !CN.current.isOpen() && !PK.current.isOpen() && !blocked;
522
601
  };
523
602
  React.useEffect(() => {
524
603
  const id = setInterval(() => {
525
604
  const a = AR.current;
526
- if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting || (a.waiting && !a.timer)) return;
605
+ if (!autoIdle() && B.current.text === '') a.busyAt = Date.now(); // a turn, approval or modal is open: the restart resumes autoResumeMs after it clears (composer text just holds while present)
606
+ if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting) return;
607
+ if (!a.timer && !a.waiting && inCooloff()) { if (a.quietId !== a.newId) { a.quietId = a.newId; say('↻ new version ready · Enter on an empty composer to restart', 'dim'); } return; } // coalesced: one quiet line, no countdown
608
+ if (!a.timer && !a.waiting && a.lastRestartAt > 0 && Date.now() - (K.current.lastKeyAt ?? a.mountedAt) < idleNeededMs()) return; // cool-off over: wait for a quiet window
609
+ if (a.waiting && !a.timer) { if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); } return; } // health probe in flight: a turn that started meanwhile cancels the restart
527
610
  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
528
- if (!autoIdle()) return;
611
+ if (!autoIdle() || Date.now() - a.busyAt < autoResumeMs) return;
529
612
  const ms = countdownMs();
530
613
  if (ms <= 0) { cdLog('timed-out', 0); autoFire(); return; }
531
614
  a.endsAt = Date.now() + ms;
@@ -569,6 +652,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
569
652
  const att = SN.current.agent ? `agent:${SN.current.agent}` : null;
570
653
  if (t.id !== curId) R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
571
654
  R0.orgId = done.id; R0.orgName = done.name;
655
+ R0.inboxEpoch = (R0.inboxEpoch || 0) + 1; R0.inboxSeen = null; R0.inboxList = []; R0.inboxAt = 0; // re-baseline: the new org's existing unread are not arrivals
656
+ dispatch({ type: 'notify', patch: { inboxBar: '' } });
572
657
  dispatch({ type: 'ctx', ctx: { conversationId: null } });
573
658
  SN.current.name = null; SN.current.saved = null;
574
659
  dispatch({ type: 'history.reset' });
@@ -595,7 +680,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
595
680
  ask(`Reattach to slot ${nm}?`, { action: `/slot ${nm} --take` });
596
681
  return;
597
682
  }
598
- if (i === 0) say(`slot ${nm}: reattaching after the restart is slow (${r.message}); retrying.`, 'dim');
683
+ if (i === 0) say(reattachSlowLine({ slot: nm, message: r.message, restartSeenAt: R.current.restartSeenAt, lastGoodAt: R.current.lastGoodAt }), 'dim');
599
684
  await new Promise((res) => { const t = setTimeout(res, slotRetryDelays[i]); t.unref?.(); });
600
685
  }
601
686
  }
@@ -606,9 +691,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
606
691
  return () => clearInterval(id);
607
692
  }, []);
608
693
  // ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
609
- // Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher): attach state + real stream events; art and reducer live in dragonlab.
694
+ // Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): attach state + real stream events; art and reducer live in dragonlab.
610
695
  const DC = React.useRef(null); DC.current ??= createCompanionController({ settings: () => api.settings?.() || {}, isTTY: Boolean(stdout?.isTTY), typing: () => B.current.text !== '', jobs: createDragonJobs({ reads: api.dragonReads || {}, context: () => ({ envelopeId: receiptMentions([...S.current.history, ...S.current.blocks])[0]?.value || null }) }) }); // each dragon's one job: a real read (dragon/jobs.js)
611
- const dispatchEv = (a) => { DC.current.tap(a); dispatch(a); };
696
+ const dispatchEv = (a) => { DC.current.tap(a); dispatch(markDelegationDragon(a, a?.ev?.tool === 'delegate_to_agent' && delegationDragonOn(api.settings?.() || {}, env))); }; // dragons on: CommanderQ's hand-off gets a static dragon
612
697
  // QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
613
698
  const LC = React.useRef(null); LC.current ??= createLoopCards({ api, say: (t, tone) => say(t, tone), addItem: (it) => dispatch({ type: 'loopcard', card: it.card }), bump: () => { if (!R.current.closed) bump(n => n + 1); }, dragon: { summon: (d, cap) => DC.current.summon(d, cap) } });
614
699
  const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
@@ -625,9 +710,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
625
710
  open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: label }, signal),
626
711
  wait: api.slotWait ? ({ since }) => api.slotWait(nm, { since, excludeFrom: label }) : null,
627
712
  onMessage: (m0) => {
628
- // a broadcast carries the sender's session, not its slot: look the slot up so the header shows the reply address
629
- if (m0.from_slot || !api.slots || !parseBroadcastText(m0.text)) return handle(m0);
630
- Promise.resolve().then(() => api.slots()).then(rows => handle({ ...m0, from_slot: slotOfHolder(rows, m0.from) || undefined }), () => handle(m0));
713
+ // REPLY-ADDRESS: any message (say, broadcast, relayed) without a stamped from_slot gets its sender's held slot looked up
714
+ if (m0.from_slot || !api.slots) return handle(m0);
715
+ Promise.resolve().then(() => api.slots()).then(rows => handle(withSenderSlot(rows, m0)), () => handle(m0));
631
716
  },
632
717
  onState: (st) => {
633
718
  if (st.error && st.failures === 1 && !R.current.closed) { paused = true; streamPausedNote(`slot ${nm}`, st.error); }
@@ -640,9 +725,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
640
725
  noteReply(m); noteSender(m);
641
726
  if (R.current.closed || SC.current.slot !== nm || (label && m.from === label)) return;
642
727
  if (isOwnBroadcast(m, { label, display: myDisplay(), userId: api.userId?.() || null })) return; // your own broadcast is not news: the "BROADCAST sent" card told you
643
- { 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 }); }
728
+ if (holdSlotMsg(m)) { rememberSender(m); return; } // DND: collected, no line, no bell, no run card
729
+ { 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 });
730
+ DC.current.slotMessage(m, { broadcast: Boolean(bc), run: Boolean(rq) }).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); } // a session with a dragon speaks to you: its dragon appears (dragonMoments.js sender moments; mode on, not while typing, cooled per sender)
644
731
  rememberSender(m);
645
- if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
732
+ if ((api.settings?.() || {}).slotBell !== 'off' && !AWAY.current.afk) { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
646
733
  }
647
734
  }
648
735
  SC.current.listener = l; l.start({});
@@ -693,7 +780,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
693
780
  const wait = SC.current.waits ??= new Map();
694
781
  clearTimeout(wait.get(slot)); wait.delete(slot);
695
782
  if (p.listening) {
696
- const t = setTimeout(() => { wait.delete(slot); if (!R.current.closed) say(`no reply from ${slot} yet`, 'warn'); }, noReplyMs);
783
+ const t = setTimeout(() => { wait.delete(slot); if (R.current.closed) return; const heard = SC.current.heard?.get(String(slot).toLowerCase()); if (heard && Date.now() - heard < 10 * 60000) return; say(`no reply from ${slot} yet`, 'dim'); }, noReplyMs);
697
784
  t.unref?.(); wait.set(slot, t);
698
785
  } else if (r.presence?.state === 'not_listening') {
699
786
  // Transcript fp 6a482d60fe83 (2026-10-16): an attached window got nothing for 4 minutes after "not listening".
@@ -718,8 +805,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
718
805
  ttlMs: replyWatchMs,
719
806
  skipSlot: (nm) => nm === where.current.slot || SC.current.rooms?.has(nm),
720
807
  isMine: (m) => { const l = myLabel(); return Boolean(l && m?.from === l); },
721
- onReply: (nm, m) => {
808
+ onReply: async (nm, m0) => {
722
809
  if (R.current.closed) return;
810
+ let m = m0;
811
+ if (!m0.from_slot && api.slots) { try { m = withSenderSlot(await api.slots(), m0); } catch { m = m0; } if (R.current.closed) return; }
723
812
  noteReply(m); noteSender(m);
724
813
  const ln = slotLine(m); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() });
725
814
  if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: ln });
@@ -733,6 +822,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
733
822
  api.bindHolderSession?.(() => slotSession());
734
823
  /** Any inbound line from a slot we are waiting on cancels its "no reply" nudge. */
735
824
  function noteReply(m) {
825
+ const heard = SC.current.heard ??= new Map();
826
+ for (const k of [m?.from_slot, String(m?.from || '').split(' (')[0]]) if (k) heard.set(String(k).toLowerCase(), Date.now());
736
827
  const waits = SC.current.waits; if (!waits?.size) return;
737
828
  for (const [slot, t] of waits) if (isHolderLine(m, slot)) { clearTimeout(t); waits.delete(slot); } // case-insensitive: LUCID is the holder of lucid
738
829
  }
@@ -760,7 +851,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
760
851
  }
761
852
  /** A dropped slot stream: a server restart (connection cut) is routine and says so calmly; anything else keeps the error. */
762
853
  function streamPausedNote(where, err) {
763
- 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');
854
+ if (/terminated|ECONNRESET|ECONNREFUSED|socket hang up|fetch failed|other side closed|502|503/i.test(String(err || ''))) { R.current.restartSeenAt = Date.now(); return say(`${where}: Quox server restarting, reconnecting (nothing is lost).`, 'dim'); }
764
855
  return say(`${where}: live messages paused (${err}); retrying.`, 'warn');
765
856
  }
766
857
  /** QUOXON-REPLY (owner 2026-10-15: "click the sender to reply"; no mouse tracking, so /r): who last messaged me. */
@@ -784,16 +875,69 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
784
875
  for (const n of new Set(list.map(r => r.human_presence?.away && r.human_presence.notice).filter(Boolean))) lines.push(`⏸ ${n}`); // PRESENCE
785
876
  return say(lines.join('\n'));
786
877
  }
787
- async function sendToSlot(slot, text) {
878
+ // ---- SLOT-OUTBOX: a send that failed because QuoxCORE did not answer is kept on disk and sent when it answers (slotOutbox.js) ----
879
+ const OB = React.useRef(null);
880
+ OB.current ??= slotOutbox(outboxPath);
881
+ const obProbe = React.useRef({ timer: null, i: 0 });
882
+ // Put text back in the prompt, in front of anything typed since (same shape as a failed steer).
883
+ const restoreDraft = (d) => { if (!d) return; const cur = B.current.text; edit({ type: 'set', text: cur ? `${d}\n${cur}` : d }); };
884
+ const noteAnswering = () => { // one "answering again" line, whichever probe saw it first
885
+ const t = Date.now(); if (t - (R.current.answeringAt || 0) < 5000) return;
886
+ R.current.answeringAt = t; say('✔ QuoxCORE is answering again', 'accent');
887
+ };
888
+ async function flushOutbox({ explicit = false } = {}) {
889
+ if (!api.slotSay) return { sent: [], failed: [], pending: 0 };
890
+ const r = await OB.current.flush(async (it) => {
891
+ const res = await api.slotSay(it.slot, it.text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
892
+ if (res.ok) return { ok: true };
893
+ return { ok: false, retryable: isConnectivityFailure(res), error: res.message };
894
+ }, { profile, includeUnsure: explicit });
895
+ if (R.current.closed) return r;
896
+ for (const it of r.sent) say(sentLine(it.slot), 'ok');
897
+ for (const it of r.failed) say(`Queued message to ${it.slot} was refused (${it.error}); it stays in /outbox (drop it with /outbox drop N).`, 'warn');
898
+ if (r.pending > 0 && !r.busy) armOutboxProbe();
899
+ return r;
900
+ }
901
+ function armOutboxProbe() {
902
+ const P = obProbe.current;
903
+ if (P.timer || R.current.closed) return;
904
+ const delays = healthRecheckDelays?.length ? healthRecheckDelays : [5000];
905
+ P.timer = setTimeout(async () => {
906
+ P.timer = null;
907
+ if (R.current.closed) return;
908
+ let hl = null; try { hl = await (healthRecheck ? healthRecheck() : api.health?.(5000)); } catch { hl = null; }
909
+ if (R.current.closed) return;
910
+ if (hl?.ok) { P.i = 0; noteAnswering(); flushOutbox(); return; }
911
+ P.i = Math.min(P.i + 1, delays.length - 1);
912
+ armOutboxProbe();
913
+ }, delays[Math.min(P.i, delays.length - 1)]);
914
+ }
915
+ React.useEffect(() => {
916
+ if (blocked || demoLabel || !api.slotSay) return undefined;
917
+ const open = OB.current.list({ profile });
918
+ const queued = open.filter(i => i.status === 'queued').length, unsure = open.filter(i => i.status === 'unsure').length;
919
+ if (queued) { say(`⏸ ${queued} message${queued === 1 ? '' : 's'} queued from earlier: sending when QuoxCORE answers · /outbox`, 'warn'); flushOutbox(); }
920
+ if (unsure) say(`⏸ ${unsure} earlier message${unsure === 1 ? '' : 's'} may or may not have been delivered (the window closed mid-send). /outbox lists them; /outbox send retries.`, 'warn');
921
+ return () => { clearTimeout(obProbe.current.timer); obProbe.current.timer = null; };
922
+ }, []);
923
+ async function sendToSlot(slot, text, { draft = `/say ${slot} ${text}` } = {}) {
788
924
  const target = await sayTarget(slot, { senders: true });
789
925
  slot = target.slot;
790
926
  if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
791
927
  if (SC.current.rooms.has(slot)) return postRoom(slot, text); // a joined room posts as a member
792
928
  const r = await api.slotSay(slot, text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
929
+ if (!r.ok && isConnectivityFailure(r) && !demoLabel) { // QuoxCORE did not answer: keep the message, never lose it
930
+ const q = OB.current.enqueue({ slot, text, profile });
931
+ if (q.ok) { obProbe.current.i = 0; say(queuedLine(slot, q.count), 'warn'); armOutboxProbe(); return undefined; }
932
+ say(q.full ? fullLine() : `slot ${slot}: ${r.message}. Could not save it to the outbox either; your text is back in the prompt.`, 'warn');
933
+ restoreDraft(draft); return undefined;
934
+ }
935
+ if (!r.ok) restoreDraft(draft); // a refusal (4xx, no such slot): show it and keep the text for editing
793
936
  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'); }
937
+ if (!r.ok && r.ambiguous) return say(r.message, 'warn');
794
938
  if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
795
939
  R.current.lastSay = { slot, at: Date.now() }; // SAY-LAST-TARGET
796
- if (r.viaOrgId) say(`${slot} lives in another of your orgs; sent there.`, 'dim');
940
+ if (r.viaOrgId) say(`${slot} lives in ${r.viaOrgName || 'another of your orgs'}; sent there.`, 'dim');
797
941
  const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
798
942
  dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
799
943
  announceDelivery(slot, r);
@@ -828,10 +972,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
828
972
  onMessage: (m) => {
829
973
  noteReply(m); noteSender(m);
830
974
  if (R.current.closed || !SC.current.rooms.has(nm) || isMine(m)) return;
975
+ if (holdSlotMsg(m, `#${nm}`)) { rememberSender(m); return; } // DND
831
976
  if (SC.current.active !== nm) entry.unread += 1; // before the dispatch, so the status line's count is current
832
977
  dispatch({ type: 'slotmsg', line: roomLine(nm, m) });
833
978
  rememberSender(m);
834
- if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
979
+ if ((api.settings?.() || {}).slotBell !== 'off' && !AWAY.current.afk) { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
835
980
  },
836
981
  onState: (st) => { if (st.error && st.failures === 1 && !R.current.closed) streamPausedNote(`#${nm}`, st.error); },
837
982
  });
@@ -915,13 +1060,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
915
1060
  SC.current.pendingFile = { slot, f, at: Date.now() };
916
1061
  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'}.` });
917
1062
  }
918
- return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }));
1063
+ return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }), { draft: null });
919
1064
  }
920
1065
  // Startup: the slot this profile last held in this directory, free or stale (or still under our old label), is offered once.
921
1066
  React.useEffect(() => {
922
1067
  if (blocked || demoLabel || restartSlot || !api.checkoutSlot || !api.slots) return;
923
1068
  const rec = loadLastSlot({ profile, cwd: realCwd(), path: slotRecordPath });
924
1069
  if (!rec) return;
1070
+ if (restartDeclined && restartDeclined === rec.slot) return; // this window already said no; a restart must not re-ask (a fresh launch has no carry)
925
1071
  api.slots().then((rows) => {
926
1072
  const row = (Array.isArray(rows) ? rows : []).find(x => String(x.name) === rec.slot);
927
1073
  if (!row || SC.current.slot || R.current.closed) return; // only this window's own checkout suppresses the ask, not an inherited one
@@ -975,6 +1121,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
975
1121
  const shown = showWhatsNew();
976
1122
  if (tipStartup && !welcome && !shown) showTip({ startup: true });
977
1123
  };
1124
+ React.useEffect(() => { // STARTUP OFFER: agent login + human logins on this machine => ask once to restart as the human
1125
+ if (!startupSwitchOffer || blocked || demoLabel || R.current.startupSwitchOffered) return;
1126
+ R.current.startupSwitchOffered = true;
1127
+ offerStartupSwitch();
1128
+ }, []);
978
1129
  React.useEffect(() => { // LAZY-AI-CLI: first launch after setup: one line, then a y/N card per missing CLI (admins only; never a wall)
979
1130
  if (blocked || !welcome || !api.providerCliStatus || !api.providerCliPlan) return;
980
1131
  api.providerCliStatus().then(async (st) => {
@@ -999,7 +1150,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
999
1150
  // Only the live region repaints at the new width (R3 D2): the stdout proxy (term.js) erases the old frame
1000
1151
  // by its reflowed height, so no ghost composers, and history is never reprinted into scrollback.
1001
1152
  const onResize = () => setCols(clampCols((stdout.columns || 80) - 1));
1153
+ onResize.resizeFirst = true; // term.js runs Ink's own resize repaint a beat later, after this commits the new width
1002
1154
  stdout?.on?.('resize', onResize);
1155
+ onResize(); // a resize between first render and this subscription (a restart replay) must not leave the old width
1003
1156
  return () => stdout?.off?.('resize', onResize);
1004
1157
  }, [stdout, columns]);
1005
1158
 
@@ -1029,37 +1182,77 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1029
1182
  } catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
1030
1183
  };
1031
1184
  // HITL-INBOX-BAR: unread inbox items on the status area + one stream line per new arrival. A failed read keeps the last bar.
1032
- const refreshInboxBar = async () => {
1185
+ // The inbox list read can be heavy on the auth service (seconds per call), and every window polls: the bar backs off in
1186
+ // proportion to the last read's cost (INBOX_BAR_COST_X, capped at INBOX_BAR_MAX_MS), reads 10 items, one read in flight
1187
+ // per window (2026-10-27 auth overload: every window read 30 items every 4s at ~3.4s each and pegged auth).
1188
+ const inboxMutes = () => activeMutes((api.settings ? api.settings() : (R.current.sessionSettings ||= {})).inboxMutes); // per profile, in the CLI settings
1189
+ const barOpts = () => ({ mutes: inboxMutes(), announced: (R.current.inboxAnnounced ||= new Map()) });
1190
+ const refreshInboxBar = async ({ force = false } = {}) => {
1033
1191
  if (!api.inboxItems) return;
1192
+ const t = Date.now();
1193
+ const gap = Math.min(INBOX_BAR_MAX_MS, (R.current.inboxCost || 0) * INBOX_BAR_COST_X);
1194
+ if (R.current.inboxBusy || (!force && t - (R.current.inboxAt || 0) < gap)) return;
1195
+ R.current.inboxBusy = true; R.current.inboxAt = t;
1196
+ const epoch = R.current.inboxEpoch || 0;
1034
1197
  try {
1035
- const items = await api.inboxItems({ limit: 30 });
1198
+ const items = await api.inboxItems({ limit: INBOX_BAR_READ });
1199
+ R.current.inboxCost = Date.now() - t;
1200
+ if (epoch !== (R.current.inboxEpoch || 0)) return; // an org switch landed while this read was in flight: it belongs to the old org
1201
+ if (!R.current.inboxSeen && items.length) { try { R.current.inboxSeen = loadSeen(api.settings?.(), R.current.orgId ?? api.orgId?.() ?? null); } catch { /* no saved watermark: baseline from this read */ } }
1202
+ const before = R.current.inboxSeen?.size || 0;
1036
1203
  const a = arrivals(items, R.current.inboxSeen || null);
1037
1204
  R.current.inboxSeen = a.ids;
1038
- R.current.inboxList = newestFirst(items).slice(0, 10);
1039
- for (const it of a.fresh.slice(0, 3)) say(arrivalLine(it), 'warn');
1040
- if (a.fresh.length > 3) say(`📨 ${a.fresh.length - 3} more · /inbox`, 'warn');
1041
- dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols) } });
1042
- } catch { /* last known bar stays; the approvals poll reports the outage */ }
1205
+ if (a.ids.size !== before) { try { api.saveSetting?.('inboxSeenByOrg', withSeen(api.settings?.(), R.current.orgId ?? api.orgId?.() ?? null, a.ids)); } catch { /* persisting is best effort */ } }
1206
+ const top = newestFirst(items).slice(0, 10);
1207
+ R.current.inboxList = top;
1208
+ const numOf = (it) => top.findIndex((x) => x.id === it.id) + 1; // each arrival names its own /inbox N (0: not in the latest 10)
1209
+ const ann = announceFresh(a.fresh, barOpts().announced, { mutes: inboxMutes() }); // repeats of an announced title and muted sources print nothing
1210
+ const lines = collapseArrivals(ann.print, numOf, arrivalLine); // APPROVAL-BURST: a burst of one agent's same-tool approvals is ONE line
1211
+ if (AWAY.current.afk) { const col = (AWAY.current.col ||= newCollected()); for (const it of a.fresh) if (!isApprovalItem(it) && !isMuted(it, inboxMutes())) col.inbox.push(it); } // DND: collected for /morningreport, never announced
1212
+ if (!AWAY.current.afk) for (const l of lines.slice(0, 3)) say(l, 'warn'); // AFK-QUIET-CARDS: away keeps notices quiet
1213
+ if (lines.length > 3 && !AWAY.current.afk) say(`📨 ${lines.length - 3} more · /inbox`, 'warn');
1214
+ dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
1215
+ } catch { R.current.inboxCost = Math.max(R.current.inboxCost || 0, Date.now() - t, 3000); /* a failed read backs off too; the last bar stays */ } finally { R.current.inboxBusy = false; }
1216
+ };
1217
+ // ONE pending set (transcript HIGH c80bcdf776d2): the bar count, the roster's approval row(s) and the all-clear all read the
1218
+ // approvals poll's items. The roster endpoint's own approval rows are replaced by rows derived from that set, atomically.
1219
+ const syncApprovalRows = () => {
1220
+ const ro = R.current.roster, open = R.current.openItems;
1221
+ if (!open || !ro.allRows || !api.roster) return; // no roster source in this window: the bar count alone speaks
1222
+ const now = Date.now();
1223
+ const keep = (x) => x.kind !== 'approval';
1224
+ const next = [...ro.allRows.filter(keep), ...open.map((it) => fromApproval(it, now)).filter(Boolean)];
1225
+ ro.allRows = next; ro.rows = selectRows(next, { all: ro.all });
1226
+ if (!ro.rows.length) { ro.focus = false; ro.peek = false; ro.confirm = null; }
1227
+ ro.sel = Math.min(ro.sel, Math.max(0, ro.rows.length - 1));
1043
1228
  };
1044
1229
  const refreshNotify = async () => {
1045
1230
  if (BL.current) return;
1046
1231
  const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
1047
1232
  try {
1233
+ const readAt = Date.now();
1048
1234
  const items = await api.approvals();
1049
1235
  if (N.authLost) say('Signed in again: approvals are back.', 'ok');
1050
1236
  else if (N.slow) say('Quox server is back.', 'ok');
1051
- N.fails = 0; N.authLost = false; N.slow = false;
1237
+ N.fails = 0; N.authLost = false; N.slow = false; R.current.readOkAt = Date.now();
1052
1238
  R.current.deckItems = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); // QUOXDECK: numbered as /approvals N
1053
- dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
1239
+ R.current.openItems = items; syncApprovalRows();
1240
+ dispatch({ type: 'notify', patch: { approvals: items.length, error: null, slow: false } });
1054
1241
  refreshInboxBar(); // HITL-INBOX-BAR: rides the same poll, never blocks it
1242
+ if (api.otherOrgApprovals && !R.current.othersBusy) { // other orgs' counts: the helper's own 60s cache bounds the cost; never awaited, never blocks
1243
+ R.current.othersBusy = true;
1244
+ Promise.resolve().then(() => api.otherOrgApprovals()).then(async (v) => { if (v?.checked) { dispatch({ type: 'notify', patch: { others: v.waiting || [] } }); await autoOthers(v); } }, () => {}).finally(() => { R.current.othersBusy = false; });
1245
+ }
1055
1246
  retryOrgName();
1056
1247
  dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
1057
1248
  R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
1249
+ walkWatch(R.current.openIds.ids, readAt); // TUI-CARD-SYNC: the card on screen, answered in another window, closes here too (never awaited)
1058
1250
  // Provider paths that emit no approval.requested still surface here, as the same card.
1059
1251
  for (const raw of items) {
1060
1252
  const item = await enrichPlanItem(raw, { objectiveGet: api.objectiveGet, agents: api.agents, orgs: api.orgs, cache: (R.current.planCache ||= new Map()) });
1061
1253
  dispatchEv({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
1062
1254
  }
1255
+ autoArrive(items); // APPROVALS-AUTO: after the loop, so this conversation's own inline cards are already known (and skipped)
1063
1256
  } catch (e) {
1064
1257
  N.fails++; // counted apart from an empty inbox
1065
1258
  const msg = e?.message || 'failed';
@@ -1070,7 +1263,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1070
1263
  return;
1071
1264
  }
1072
1265
  if (e?.transient && !N.slow) { N.slow = true; say('Quox server is slow or unreachable, retrying.', 'warn'); } // QUOXON-FW12: not signed out
1073
- dispatch({ type: 'notify', patch: { error: msg } });
1266
+ // APPROVALS-POLL: one or two misses keep the last-known count on screen; only a sustained failure blanks it.
1267
+ if (approvalsPollStale({ fails: N.fails, lastOkAt: R.current.readOkAt ?? R.current.startedAt, now: Date.now() })) dispatch({ type: 'notify', patch: { error: msg, slow: true } });
1074
1268
  }
1075
1269
  };
1076
1270
  React.useEffect(() => {
@@ -1107,7 +1301,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1107
1301
  // model's provider, else the provider the last turn ran on. Unknown yet: say nothing; a failing turn says why.
1108
1302
  const mine = String(MC.current?.provider || S.current?.ctx?.provider || '').toLowerCase();
1109
1303
  const hits = list ? list.filter(e => mine && String(e.provider || '').toLowerCase() === mine) : null;
1110
- if (hits) dispatch({ type: 'notify', patch: { providerLines: hits.map(e => `⚠ ${e.line || e.provider + ' ' + e.state}: chat on it fails until then`) } });
1304
+ if (hits) {
1305
+ // Limited / out of credits on the model in use: offer a working one (y/N), never switch on its own.
1306
+ // Dismissed (n/Esc/chat) stays dismissed until the health state changes.
1307
+ let offer = null;
1308
+ try {
1309
+ const sig = JSON.stringify(list.map(e => [e.provider, e.state, e.resets_at || null]));
1310
+ if (R.current.limitedSig !== sig) { R.current.limitedSig = sig; R.current.limitedDismissed = false; }
1311
+ if (hits.length && !R.current.limitedDismissed && api.connections) {
1312
+ const [res, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
1313
+ offer = limitedOffer(list, MC.current, res, caps, mine);
1314
+ }
1315
+ } catch { /* no offer is the safe answer when the server cannot say what is ready */ }
1316
+ R.current.limitedAsk = offer ? { ...offer, at: Date.now() } : null;
1317
+ const lines = hits.map(e => `⚠ ${e.line || e.provider + ' ' + e.state}: chat on it fails until then`);
1318
+ R.current.limitedBase = lines.slice();
1319
+ if (offer) lines.push(askText(`Switch to ${offer.id} (${offer.why})?`));
1320
+ dispatch({ type: 'notify', patch: { providerLines: lines } });
1321
+ }
1111
1322
  }
1112
1323
  if (!stopped) timer = setTimeout(tick, 60000);
1113
1324
  };
@@ -1125,11 +1336,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1125
1336
  applyProgress(r.rows, (R0.progress ||= new Map()), Date.now());
1126
1337
  R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
1127
1338
  r = { ...r, rows: selectRows(r.rows, { all: R0.all }) };
1128
- R0.rows = r.rows;
1129
- DC.current.watchRoster(rowsWithoutLoopCards(R0.allRows, (id) => LC.current.has(id))).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); // an objective or loop that just finished or failed summons its dragon (companion mode on, not while typing)
1130
- const at = r.rows.findIndex(x => x.key === prevKey);
1131
- R0.sel = at >= 0 ? at : Math.min(R0.sel, Math.max(0, r.rows.length - 1));
1132
- if (!r.rows.length) { R0.focus = false; R0.peek = false; R0.confirm = null; }
1339
+ R0.rows = r.rows; syncApprovalRows(); R0.at = Date.now(); // when these rows were last read: a sign-out shows them as "as of" this time
1340
+ if (!AWAY.current.afk) DC.current.watchRoster(rowsWithoutLoopCards(R0.allRows, (id) => LC.current.has(id))).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); // an objective or loop that just finished or failed summons its dragon (companion mode on, not while typing)
1341
+ const at = R0.rows.findIndex(x => x.key === prevKey);
1342
+ R0.sel = at >= 0 ? at : Math.min(R0.sel, Math.max(0, R0.rows.length - 1));
1343
+ if (!R0.rows.length) { R0.focus = false; R0.peek = false; R0.confirm = null; }
1133
1344
  if (!R.current.closed) bump(n => n + 1);
1134
1345
  } catch { /* api.roster handles per-source failures; nothing to show */ }
1135
1346
  };
@@ -1252,7 +1463,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1252
1463
  // After approving from the terminal the tool runs asynchronously: poll its stored result.
1253
1464
  const pollResult = async (run) => {
1254
1465
  if (!run.approvalId || !api.approvalResult) return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
1255
- const t0 = Date.now();
1466
+ const t0 = Date.now(); let undecided = 0;
1256
1467
  while (Date.now() - t0 < resultTimeoutMs) {
1257
1468
  await new Promise(r => { const t = setTimeout(() => { R.current.pollTimers.delete(t); r(); }, resultPollMs); R.current.pollTimers.add(t); R.current.pollWake = r; });
1258
1469
  if (R.current.closed) return undefined;
@@ -1260,9 +1471,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1260
1471
  try {
1261
1472
  const r = await api.approvalResult(run.approvalId);
1262
1473
  if (r?.status === 'denied') return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'denied' });
1474
+ // Live 2026-11-08 (contacts_dial_batch): the inbox took the yes but the runner never got it, so the approval stayed
1475
+ // pending with nobody as the decider. The runner moves a row off pending the moment it receives a decision, so two
1476
+ // reads like that after our yes are proof nothing ran: say so, never "result not known yet".
1477
+ if (r && r.status === 'pending' && !r.decided_by) { if (++undecided >= 2) return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'unrecorded' }); }
1478
+ else undecided = 0;
1263
1479
  if (r && (r.status === 'approved' || r.status === 'error') && r.executed_at) {
1264
1480
  const text = r.result == null ? null : clip(typeof r.result === 'string' ? r.result : JSON.stringify(r.result), cols - 20);
1265
- return dispatch({ type: 'approval.result', callId: run.callId, outcome: r.status === 'error' ? 'failed' : 'done', text });
1481
+ // Command reached the host and exited >= 1 (not a connect/auth failure): say "ran, exit N", not "failed".
1482
+ const rr = r.result && typeof r.result === 'object' ? r.result : null;
1483
+ const exit = r.status === 'error' && rr && Number.isInteger(rr.exit_code) && rr.exit_code >= 1 && !/handshake failed|unable to authenticate|no supported methods|failed to connect|connection refused|econnrefused|no route to host|connect etimedout/i.test(String(rr.error || '')) ? rr.exit_code : undefined;
1484
+ return dispatch({ type: 'approval.result', callId: run.callId, outcome: r.status === 'error' ? 'failed' : 'done', text, ...(exit !== undefined ? { exit } : {}) });
1266
1485
  }
1267
1486
  } catch { /* keep polling until the deadline */ }
1268
1487
  }
@@ -1293,7 +1512,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1293
1512
  };
1294
1513
  // QUOXON-STEPUP: the server decides what needs a person. We try with any live step-up token; on 403 step_up_required
1295
1514
  // we ask for the PIN (masked card) and retry once. `proactive` (/authorise) asks first. Deny never needs a PIN.
1515
+ // PRESENCE (owner 2026-11-04): approving, denying or answering a card is the owner being here, PIN or not: close an open away
1516
+ // window server-side and stamp last-seen, so the next "While you were away" covers real absence only.
1517
+ const ownerActed = (action) => { touchSeen(true); markPresent(action, { quiet: true }); };
1296
1518
  const respondWithStepUp = async (card, action, { proactive = false, reason = null, bulk = false } = {}) => {
1519
+ ownerActed(action === 'approve' ? 'approval' : 'deny');
1297
1520
  const via = `tty ${sessionLabel()}`;
1298
1521
  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 };
1299
1522
  const ask = async () => {
@@ -1332,30 +1555,114 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1332
1555
  // 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
1333
1556
  // command ends the walk with a summary; an id answered in this window never comes back, and a list held from earlier
1334
1557
  // (a bulk card, a range) is re-read from the server before its first card.
1335
- const WALK_TTL = 1800000;
1558
+ const WALK_TTL = walkTtlMs; // 30 minutes; a prop only so a test can lapse a card
1336
1559
  const walkAnsweredIds = () => (R.current.walkAnswered ||= new Set());
1337
- 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
1560
+ // POPUP-DEDUPE (owner 2026-11-06, hit twice): what this window answered on the inline prompt, by inbox id and by approval id,
1561
+ // so the auto-popup never re-opens it a second later while the server still lists it, and a walk card says so plainly.
1562
+ const inlineAnswered = () => (R.current.inlineAnswered ||= { ids: new Map(), approvals: new Map() });
1563
+ const itemApprovalId = (it) => it?.payload?.approval_id || it?.approval_id || it?.approvalId || it?.aee_correlation_id || null;
1564
+ const answeredInline = (it) => { const m = inlineAnswered(); return m.ids.get(it?.id) || m.approvals.get(itemApprovalId(it)) || null; };
1565
+ const walkCloseCard = (W, why = 'next', settle = null) => { if (W?.card) { W.card.at = Date.now(); W.card.walkWhy = why; if (settle) { W.card.settleText = settle.text; W.card.settleTone = settle.tone; } W.card = null; } }; // why: next/done settle silently; stop = interrupted, gone = closed by the server (settle = the line it leaves)
1338
1566
  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(''); };
1339
1567
  // 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".
1340
1568
  const askExpiry = (it) => { const t = Date.parse(it?.due_at ?? it?.payload?.expires_at); return Number.isFinite(t) ? t : null; };
1341
1569
  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."; };
1342
1570
  const walkGoneElsewhere = (W, id) => { const o = R.current.openIds; return Boolean(o && o.at > W.startedAt && !o.ids.has(id)); };
1343
- const walkSummaryLines = (W, head) => {
1571
+ // TUI-CARD-SYNC (owner 2026-11-08, two windows on one user: a card answered "a" in one window stayed open in the other and
1572
+ // later said "expired, not answered"): what the SERVER says became of a walk item that is no longer in the open list.
1573
+ // { kind: 'elsewhere', choice, at } | { kind: 'expired', at } | { kind: 'closed', at }. Read only for an item already gone.
1574
+ const walkFateOfGone = async (it) => {
1575
+ let item = null;
1576
+ if (api.inboxItem) { try { item = await api.inboxItem(it.id); } catch { item = null; } }
1577
+ const res = item && item.id ? resolutionFromItem(item) : null;
1578
+ const at = res?.resolved_at ? Date.parse(res.resolved_at) : NaN;
1579
+ if (!res) return { kind: 'closed', at };
1580
+ if (res.outcome === 'expired') return { kind: 'expired', at };
1581
+ const r = item.response || null;
1582
+ if (!r || r.housekeeping || ['withdrawn', 'cancelled'].includes(res.outcome)) return { kind: 'closed', at };
1583
+ const opt = typeof r.option === 'string' ? r.option : null;
1584
+ const label = opt ? it.payload?.options?.find(o => o.key === opt)?.label : null;
1585
+ const choice = opt ? (label && label !== opt ? `${opt}: ${decodeEntities(label)}` : opt) : res.outcome === 'approved' ? 'approved' : res.outcome === 'rejected' ? 'denied' : res.outcome;
1586
+ return { kind: 'elsewhere', choice, at };
1587
+ };
1588
+ const walkTitleQ = (it) => `"${clip(decodeEntities(it?.title || it?.id || 'the question'), Math.max(20, cols - 50))}"`;
1589
+ const walkGoneSettle = (it, fate) => ({ text: settledAskLine(walkTitleQ(it), fate, Number.isFinite(fate.at) ? fate.at : Date.now()), tone: settledAskTone(fate.kind) });
1590
+ // The poll's open list no longer has the card on screen: close it with what the server says, then on to the next card.
1591
+ const walkWatch = async (ids, readAt) => {
1592
+ const W = R.current.pendingWalk; const card = W?.card; const id = W?.current; const it = id ? W.items[id] : null;
1593
+ if (!card || !it || it.__orgId || W.done.has(id) || ids.has(id) || R.current.walkSend || R.current.walkWatching || readAt < (card.shownAt ?? card.at)) return;
1594
+ R.current.walkWatching = true;
1595
+ try {
1596
+ const fate = await walkFateOfGone(it);
1597
+ if (R.current.pendingWalk !== W || W.card !== card || W.current !== id || R.current.walkSend) return; // answered or stopped here meanwhile
1598
+ W.done.add(id); (W.dropped ||= new Set()).add(id);
1599
+ walkCloseCard(W, 'gone', walkGoneSettle(it, fate));
1600
+ walkShow();
1601
+ } finally { R.current.walkWatching = false; }
1602
+ };
1603
+ // A card left open past WALK_TTL: ask the server before saying anything (it may have been answered in another window).
1604
+ const walkLapsed = async (card) => {
1605
+ const W = R.current.pendingWalk; const it = W?.items?.[card.walk];
1606
+ if (!it) { card.settleText = ''; return; }
1607
+ if (it.__orgId) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered · /approvals`; card.settleTone = 'plain'; return; }
1608
+ const now = await openIdsNow();
1609
+ if (!now.ids) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered; couldn't check it with the server (${now.error}) · /approvals`; card.settleTone = 'plain'; return; }
1610
+ if (now.ids.has(it.id)) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered; the question is still open · /walkthrough`; card.settleTone = 'plain'; return; }
1611
+ const fate = await walkFateOfGone(it);
1612
+ if (R.current.pendingWalk === W) { W.done.add(it.id); (W.dropped ||= new Set()).add(it.id); }
1613
+ const st = walkGoneSettle(it, fate); card.settleText = st.text; card.settleTone = st.tone;
1614
+ };
1615
+ // fresh = the ids the server still has open (null: not re-checked), so an item answered elsewhere is never "still waiting"
1616
+ const walkSummaryLines = (W, head, fresh = null) => {
1344
1617
  const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null;
1345
- const left = W.ids.filter(id => !W.done.has(id) && id !== sending).map(id => W.items[id].title || id);
1346
- const lines = walkSummary({ answered: W.answered, skipped: W.skipped.map(id => W.items[id].title || id), left });
1618
+ const stillOpen = (id) => !fresh || fresh.has(id);
1619
+ const left = W.ids.filter(id => !W.done.has(id) && id !== sending && stillOpen(id)).map(id => W.items[id].title || id);
1620
+ const skipped = W.skipped.filter(stillOpen);
1621
+ const lines = walkSummary({ answered: W.answered, skipped: skipped.map(id => W.items[id].title || id), left });
1347
1622
  if (head) lines[0] = lines[0].replace(/^Walkthrough done/, head);
1348
1623
  if (sending) lines.push(` … still sending: ${W.items[sending].title || sending}`);
1349
- return { lines, open: left.length + W.skipped.length };
1624
+ return { lines, open: left.length + skipped.length };
1625
+ };
1626
+ // WALK-REVALIDATE (owner 2026-11-08 03:56: a window open since the day before listed 8 "still waiting", most answered
1627
+ // hours earlier in another window): the list a walk holds is re-checked with the server before it is shown; a failed
1628
+ // or slow re-check shows the held list, labelled as possibly out of date.
1629
+ const WALK_RECHECK_MS = 4000;
1630
+ const openIdsNow = async () => {
1631
+ let timer;
1632
+ try {
1633
+ const items = await Promise.race([api.approvals(), new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('timed out')), WALK_RECHECK_MS); })]);
1634
+ return { ids: new Set((Array.isArray(items) ? items : []).map(x => x?.id).filter(Boolean)), error: null };
1635
+ } catch (e) { return { ids: null, error: clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80) }; }
1636
+ finally { clearTimeout(timer); }
1350
1637
  };
1351
1638
  const walkEnd = (why) => { // Esc, a command, or a card left too long: the walk stops here and says where it stands
1352
1639
  const W = R.current.pendingWalk; if (!W) return false;
1640
+ const card = W.card; const cur = W.current && !W.done.has(W.current) ? W.items[W.current] : null; // the card on screen: its line comes from the server
1353
1641
  R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); walkCloseCard(W, 'stop');
1354
- const { lines, open } = walkSummaryLines(W, `Walkthrough stopped (${why})`);
1355
- say(' ');
1356
- lines.forEach((l, k) => say(l, k === 0 ? 'ok' : undefined));
1357
- if (open) say('Nothing else was sent. /walkthrough picks up what is still waiting.', 'dim');
1358
- queueMicrotask(() => reconcileAsks());
1642
+ const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null; // read now: the send settles on its own
1643
+ const held = W.ids.some(id => !W.done.has(id) && id !== sending) || W.skipped.length;
1644
+ // TUI-CARD-SYNC (owner 2026-11-08: /say during a card printed "The question closed before you answered." while it was
1645
+ // still open): the stopped card says closed only when the server's open list no longer has it.
1646
+ const settleCard = async ({ ids, error }) => {
1647
+ if (!card) return;
1648
+ if (!cur || sending || cur.__orgId) { card.settleText = cur && cur.__orgId && !sending ? `Not answered; it is still open in ${cur.__orgName || 'its org'}.` : ''; card.settleTone = 'plain'; return; }
1649
+ if (!ids) { card.settleText = `Not answered; couldn't check whether the question is still open${error ? ` (${error})` : ''}.`; card.settleTone = 'plain'; return; }
1650
+ if (ids.has(cur.id)) { card.settleText = 'Not answered; the question is still open.'; card.settleTone = 'plain'; return; }
1651
+ const fate = await walkFateOfGone(cur);
1652
+ if (fate.kind === 'elsewhere') { const st = walkGoneSettle(cur, fate); card.settleText = st.text; card.settleTone = st.tone; return; }
1653
+ card.settleText = 'The question closed before you answered.'; card.settleTone = 'plain';
1654
+ };
1655
+ const show = ({ ids, error }) => {
1656
+ settleCard({ ids, error }).catch(() => { if (card) card.settleText = ''; });
1657
+ const { lines, open } = walkSummaryLines(W, `Walkthrough stopped (${why})`, ids);
1658
+ say(' ');
1659
+ lines.forEach((l, k) => say(l, k === 0 ? 'ok' : undefined));
1660
+ if (error && held) say(`⚠ couldn't re-check with the server (${error}); this list may be out of date. /approvals to look again`, 'warn');
1661
+ if (open) say('Nothing else was sent. /walkthrough picks up what is still waiting.', 'dim');
1662
+ queueMicrotask(() => reconcileAsks());
1663
+ };
1664
+ if (!held) { show({ ids: null, error: null }); return true; }
1665
+ R.current.walkEnding = openIdsNow().then(show, () => show({ ids: null, error: 'unknown error' })).finally(() => { R.current.walkEnding = null; });
1359
1666
  return true;
1360
1667
  };
1361
1668
  // TUI-WALK (owner 2026-10-19: he always runs /approvals again to confirm it is empty): the walk ends with a FRESH server
@@ -1366,10 +1673,21 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1366
1673
  catch (e) { say(`⚠ couldn't re-check approvals (${clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80)}); /approvals to look again`, 'warn'); return; }
1367
1674
  const gone = walkAnsweredIds();
1368
1675
  const open = (Array.isArray(items) ? items : []).filter(x => x && !gone.has(x.id));
1369
- if (!open.length) { say('✔ Nothing else is currently waiting for you.', 'ok'); return; }
1676
+ // TUI-CARD-SYNC (owner 2026-11-08: "Nothing else is currently waiting" above a status line still saying "⏸ 1 approval
1677
+ // waiting"): the counter is set from this same read, so the line and the bar never disagree.
1678
+ R.current.openItems = open; syncApprovalRows(); R.current.openIds = { ids: new Set(open.map(i => i.id)), at: Date.now() };
1679
+ dispatch({ type: 'notify', patch: { approvals: open.length, error: null, slow: false } });
1680
+ // the closing line repeats the other-org count + switch hint the walk opened with (the helper's own cache: no new token mints)
1681
+ const sayOthers = async () => { try { const v = api.otherOrgApprovals ? await api.otherOrgApprovals() : null; for (const l of crossOrgTuiLines(v)) say(l, 'warn'); } catch { /* the hint is optional */ } };
1682
+ if (!open.length) { say('✔ Nothing else is currently waiting for you.', 'ok'); await sayOthers(); return; }
1370
1683
  const fresh = open.filter(x => !W.ids.includes(x.id)).length;
1371
- if (fresh) say(`⏸ ${fresh} more arrived while you were answering · /approvals`, 'warn');
1684
+ AO.current.noticeAt = Date.now(); // APPROVALS-AUTO: a bare y/n right after this notice is the card's, never chat
1685
+ const added = AO.current.st.enabled ? autoArrive(open).added.length : 0; // only what is NEW since this window opened pops; older items stay a count
1686
+ if (added) { say(`⏸ ${added} more arrived while you were answering · /approvals`, 'warn'); say(`Opening ${added === 1 ? 'it' : 'them'} next by itself${fresh > added ? `; ${fresh - added} older still waiting` : ''}.`, 'dim'); }
1687
+ else if (fresh && W.auto) say(`⏸ ${fresh} older still waiting · /approvals`, 'warn'); // an auto-opened walk held only the new ones: the rest were there before
1688
+ else if (fresh) say(`⏸ ${fresh} more arrived while you were answering · /approvals`, 'warn');
1372
1689
  else say(`⏸ ${open.length} still waiting (skipped) · /approvals`, 'warn');
1690
+ await sayOthers();
1373
1691
  };
1374
1692
  const walkShow = () => {
1375
1693
  endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
@@ -1381,21 +1699,38 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1381
1699
  if (W.done.has(id)) continue;
1382
1700
  if (gone.has(id)) { W.done.add(id); continue; } // answered in this window already: never shown again
1383
1701
  { 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; } }
1384
- 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; }
1702
+ if (walkGoneElsewhere(W, id)) { W.done.add(id); const was = answeredInline(W.items[id]); if (was) { say(`Already ${was === 'approve' ? 'approved' : 'denied'} (you answered it inline). Moving on.`, 'ok'); continue; } say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
1385
1703
  nextId = id; break;
1386
1704
  }
1387
1705
  if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); walkRecheck(W); queueMicrotask(() => reconcileAsks()); return; }
1388
1706
  const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
1389
1707
  const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
1390
- W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
1708
+ W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId, shownAt: W.at };
1391
1709
  say(' ');
1710
+ if (it.__orgName) say(`in ${it.__orgName} · answered there directly; this window stays in ${orgShown() || 'its own org'}`, 'warn'); // APPROVALS-AUTO: another org's card says whose it is
1392
1711
  c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
1393
1712
  ask('Your choice, with an optional note', { who: it.payload.session || '', action: '', reason: '', keys: '· Esc stops', plain: true });
1394
1713
  };
1714
+ // DESTRUCTIVE-CONFIRM: a delete is approved only by typing the object's name. y opens this step; Esc or n goes back.
1715
+ const startDelConfirm = (obj, run) => {
1716
+ R.current.delConfirm = { name: obj.name, run, at: Date.now() };
1717
+ say(askText(`Type the name to delete it: ${obj.name}`, { keys: 'Esc or n cancels', action: `delete ${obj.label}`, reason: 'A delete is not approved by a single key' }), 'ask');
1718
+ bump(n => n + 1);
1719
+ };
1720
+ const cancelDelConfirm = () => { R.current.delConfirm = null; B.current = initialBuffer(); say('Cancelled; nothing deleted. The card is still open.', 'dim'); bump(n => n + 1); };
1721
+ const delConfirmLine = (text) => {
1722
+ const d = R.current.delConfirm; const t = String(text ?? '').trim();
1723
+ if (t === d.name) { R.current.delConfirm = null; d.run(); return; }
1724
+ if (!t || /^n(o)?$/i.test(t)) { cancelDelConfirm(); return; }
1725
+ say(`That is not the name, so nothing was deleted. Type ${d.name} exactly, or Esc to cancel.`, 'warn');
1726
+ };
1395
1727
  const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
1728
+ if (R.current.walkEnding) await R.current.walkEnding; // the stopped walk's summary prints before the next walk's first card
1396
1729
  let decs = (list || []).filter(isDecision);
1397
1730
  if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
1398
- 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 */ }
1731
+ const now = await openIdsNow();
1732
+ if (now.ids) decs = decs.filter(x => now.ids.has(x.id));
1733
+ else say(`⚠ couldn't re-check these with the server (${now.error}); they may be out of date.`, 'warn'); // the answered-in-this-window set below still applies
1399
1734
  }
1400
1735
  const gone = walkAnsweredIds();
1401
1736
  decs = decs.filter(x => !gone.has(x.id));
@@ -1412,6 +1747,93 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1412
1747
  walkShow();
1413
1748
  return undefined;
1414
1749
  };
1750
+ // APPROVALS-AUTO (owner 2026-11-06: "just make approvals pop up, i am tired of opening them"): a NEW approval or decision
1751
+ // opens by itself as the walkthrough card. It only OPENS; it never answers. A draft, typing in the last 2s, a streaming
1752
+ // turn or another card on screen holds it until the next idle point; an open walk simply gains the card.
1753
+ const autoSkip = (items = []) => new Set([...S.current.approvals.map(x => x.inboxItemId), ...walkAnsweredIds(), ...(R.current.pendingWalk ? R.current.pendingWalk.ids : []), ...items.filter(x => x && !x.__orgId && answeredInline(x)).map(x => x.id)].filter(Boolean));
1754
+ const autoBusy = () => Boolean(askOpen() || headCard(S.current) || SU.current.isOpen() || LI.current?.isOpen?.() || CN.current?.isOpen?.() || PK.current?.isOpen?.() || R.current.rs || R.current.noteAsk || R.current.delConfirm || R.current.denyReason || K.current.cardFocus || BL.current || AWAY.current.afk);
1755
+ const autoOpenItems = (items, how) => {
1756
+ const skip = autoSkip(items); const list = items.filter(x => !skip.has(x.id)); // re-checked right before opening: answered here since it queued
1757
+ if (!list.length) return;
1758
+ if (how === 'open') { const n = list.length; say(' '); say(`⏸ ${n === 1 ? 'A new approval just arrived; it is open below' : `${n} new approvals just arrived; they are open below, one at a time`}. Esc stops · /approvals auto off stops them opening by themselves.`, 'warn'); }
1759
+ const had = R.current.pendingWalk;
1760
+ walkStart(list.map(approvalAsWalkItem), { fresh: true, quiet: true });
1761
+ if (!had && R.current.pendingWalk) R.current.pendingWalk.auto = true;
1762
+ };
1763
+ const autoTry = () => {
1764
+ const A = AO.current; clearTimeout(A.timer); A.timer = null;
1765
+ if (A.dead) return;
1766
+ const d = openDecision(A.st, { draft: B.current.text, lastKeyAt: K.current.lastKeyAt || 0, now: Date.now(), streaming: Boolean(S.current.turn), walkOpen: Boolean(R.current.pendingWalk), askOpen: autoBusy() });
1767
+ if (!d) return;
1768
+ if (d === 'open' || d === 'join') { A.waitSaid = false; autoOpenItems(takeQueue(A.st), d); return; }
1769
+ if (d === 'wait-draft' && !A.waitSaid) { // TUI-CARD-SYNC: another org's card says whose it is, so it never reads against "Nothing waiting for you"
1770
+ A.waitSaid = true; const q = A.st.queue; const orgs = [...new Set(q.map(x => x.__orgId ? x.__orgName || x.__orgId : ''))];
1771
+ const where = orgs.length === 1 && orgs[0] ? ` in ${orgs[0]}` : orgs.some(Boolean) ? ` (${q.filter(x => x.__orgId).length} in other orgs)` : '';
1772
+ say(`⏸ ${q.length === 1 ? 'A new approval is' : `${q.length} new approvals are`} waiting${where}; it opens once your draft is empty.`, 'dim');
1773
+ }
1774
+ A.timer = setTimeout(autoTry, 400); A.timer.unref?.();
1775
+ };
1776
+ const autoArrive = (items, opts = {}) => { const A = AO.current; const r = noteArrivals(A.st, items, { skipIds: autoSkip(opts.orgId ? [] : items), ...opts }); if (A.st.queue.length) autoTry(); return r; };
1777
+ // Other orgs: read an org's open items only when its count changed (the counts helper caches 60s), with the user's own token.
1778
+ const autoOthers = async (v) => {
1779
+ const A = AO.current; const first = !A.othersDone; A.othersDone = true;
1780
+ if (!api.approvalsInOrg) return;
1781
+ const waiting = v?.waiting || []; const ids = new Set(waiting.map(w => w.id));
1782
+ A.st.queue = A.st.queue.filter(x => !x.__orgId || ids.has(x.__orgId)); // nothing waiting there any more
1783
+ for (const [id] of A.otherCounts) if (!ids.has(id)) A.otherCounts.delete(id);
1784
+ for (const w of waiting) {
1785
+ if (A.otherCounts.get(w.id) === w.count) continue;
1786
+ try {
1787
+ const items = await api.approvalsInOrg(w.id);
1788
+ A.otherCounts.set(w.id, w.count);
1789
+ autoArrive(items, { orgId: w.id, orgName: w.name, seed: first ? true : (A.st.seeded.has(w.id) ? null : false) });
1790
+ } catch { /* unreadable: the "N more waiting in" line still says so */ }
1791
+ }
1792
+ };
1793
+ // Another org's card in the walk: answered via that org's own URL (the server checks membership there). A high-risk yes
1794
+ // still takes the PIN (a one-shot token minted for THAT org); a privileged yes still needs its explicit confirm there.
1795
+ const walkCrossOrg = async (W, it, rep) => {
1796
+ const org = it.__orgName || it.__orgId; const c = it.payload?.approval_context || {};
1797
+ const keep = () => { W.at = Date.now(); if (W.card) W.card.at = W.at; return true; };
1798
+ if (it.__approval && rep.key === 'y' && isPrivileged({ tool: c.method?.tool, risk: c.risk, flag: it.payload?.privileged === true || c.privileged === true, title: it.title })) {
1799
+ say(`This one is privileged, so a yes needs its explicit confirm in ${org}: ${mark(`/org switch ${org}`)} then ${mark('/approvals')}. n denies it from here; s skips.`, 'warn');
1800
+ return keep();
1801
+ }
1802
+ const label = it.payload.options.find(o => o.key === rep.key)?.label || rep.key;
1803
+ const subject = { line1: `${it.__approval ? (rep.key === 'y' ? 'approve' : 'deny') : 'answer'} ${String(it.title || '')} (in ${org})`, raisedBy: it.payload.session || null, risk: 'high' };
1804
+ let stepUp = {};
1805
+ const pin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: it.__orgId, fresh: true, subject }); if (!r.ok) { const e = new Error(declinedText(r.reason)); e.stepUpDeclined = true; throw e; } stepUp = { 'x-step-up-token': r.token }; };
1806
+ const go = () => { ownerActed('answer'); return it.__approval
1807
+ ? api.respond(it.id, rep.key === 'y' ? 'approve' : 'reject', { via: 'walkthrough', reason: rep.note || null, org: it.__orgId, stepUp })
1808
+ : api.answerDecision(it.id, rep.key, { note: rep.note || null, org: it.__orgId, stepUp }); };
1809
+ R.current.walkSend = true;
1810
+ try {
1811
+ if (needsPin(it) && rep.key !== 'n') await pin();
1812
+ try { await go(); }
1813
+ catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; await pin(); await go(); }
1814
+ } catch (e) {
1815
+ if (e?.stepUpDeclined) { say(`Not answered: ${e.message}.`, 'warn'); return keep(); }
1816
+ if (e?.status === 404 || e?.status === 400 || e?.status === 409) { say(`"${clip(it.title || it.id, 60)}" is no longer waiting in ${org}. Moving on.`, 'dim'); W.done.add(it.id); walkAnsweredIds().add(it.id); if (R.current.pendingWalk === W) walkShow(); return true; }
1817
+ say(`Not answered in ${org} (${clip(String(e?.message || e), 80)}). The question stays: say a choice again, s to skip, or ${mark(`/org switch ${org}`)} to answer it there.`, 'warn');
1818
+ return keep();
1819
+ } finally { R.current.walkSend = false; }
1820
+ say(`✔ answered in ${org}: ${clip(decodeEntities(it.title || it.id), Math.max(20, cols - 40))} -> (${rep.key}) ${decodeEntities(label)}${rep.note ? ` (note: ${clip(rep.note, 40)})` : ''}`, 'ok');
1821
+ walkAnsweredIds().add(it.id); W.done.add(it.id); W.answered.push({ title: it.title, word: `${label} (in ${org})${rep.note ? ` (note: ${rep.note.length} characters)` : ''}` });
1822
+ if (R.current.pendingWalk === W) walkShow();
1823
+ return true;
1824
+ };
1825
+ // The stray y (2026-11-06: a y typed after "1 more arrived" went to triage-q as chat): a bare y/n while a card is queued,
1826
+ // or just after a "more arrived" notice, opens that card NOW with the key kept in the box. A key typed before the card
1827
+ // was on screen never approves what it has not shown: Enter then answers the card you can see.
1828
+ const autoStray = (text) => {
1829
+ const A = AO.current; const t = String(text).trim();
1830
+ const queued = takeQueue(A.st);
1831
+ const kept = () => { edit({ type: 'set', text: t }); say(`Your "${t}" was not sent as chat. It is kept in the box: press Enter to answer the card above.`, 'warn'); };
1832
+ if (queued.length) { autoOpenItems(queued, 'open'); if (R.current.pendingWalk) kept(); return; }
1833
+ A.noticeAt = 0;
1834
+ Promise.resolve(command('/approvals walkthrough')).then(() => { if (R.current.pendingWalk) kept(); else say(`Your "${t}" was not sent as chat: the approval it was for is no longer waiting.`, 'dim'); });
1835
+ };
1836
+ React.useEffect(() => () => { AO.current.dead = true; clearTimeout(AO.current.timer); }, []);
1415
1837
  // 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".
1416
1838
  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
1417
1839
  const noteCard = (st) => {
@@ -1445,8 +1867,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1445
1867
  if (rep.notYesNo) { say(walkNotYesNo(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1446
1868
  if (rep.defer) { W.done.add(it.id); W.skipped.push(it.id); walkShow(); return true; }
1447
1869
  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; }
1448
- 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
1449
- if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
1870
+ if (it.__approval && rep.key === 'y' && isDestructive(it) && R.current.delBypass !== it.id) { // a delete is confirmed by typing its name, then this same yes goes through
1871
+ W.at = Date.now(); if (W.card) W.card.at = W.at;
1872
+ startDelConfirm(destructiveObject(it), async () => { R.current.delBypass = it.id; try { await walkHandle('y'); } finally { R.current.delBypass = null; } });
1873
+ return true;
1874
+ }
1875
+ if (it.__orgId) return walkCrossOrg(W, it, rep); // APPROVALS-AUTO: another org's card is answered in THAT org, no switch
1876
+ let n = S.current.inbox.findIndex(x => x.id === it.id) + 1; // routed by id; the position is only looked up at the moment of sending
1877
+ if (!n) { // APPROVALS-AUTO: a card that opened by itself was never listed by /approvals: read the same list, in the same order, now
1878
+ try { const pl = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: pl }); R.current.shownIds = pl.map(x => x.id); n = pl.findIndex(x => x.id === it.id) + 1; }
1879
+ catch (e) { say(`Could not re-read approvals (${clip(String(e?.message || e), 80)}); nothing was sent. Say the choice again.`, 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1880
+ }
1881
+ if (!n) { const was = answeredInline(it); say(was ? `Already ${was === 'approve' ? 'approved' : 'denied'} (you answered it inline). Moving on.` : `"${it.title}" is no longer waiting (answered or expired). Moving on.`, was ? 'ok' : 'warn'); W.done.add(it.id); walkShow(); return true; }
1450
1882
  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; }
1451
1883
  if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
1452
1884
  const o = it.payload.options.find(x => x.key === rep.key);
@@ -1473,14 +1905,29 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1473
1905
  if (!card.inboxItemId && card.approvalId && !batch) { // an event card that never learned its inbox id: find it, never answer null
1474
1906
  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 */ }
1475
1907
  }
1476
- await respondWithStepUp(card, action, { proactive, reason, bulk });
1908
+ const res = await respondWithStepUp(card, action, { proactive, reason, bulk });
1909
+ // A 2xx is not a recorded yes on its own: the server reports the card's real status (an expired approval comes
1910
+ // back as status expired, nothing ran). Only "resolved" lets this window say approved.
1911
+ const st = action === 'approve' && res && typeof res === 'object' ? res.item?.status : null;
1912
+ if (st && st !== 'resolved') {
1913
+ const text = st === 'expired'
1914
+ ? `Not approved: ${res.message || 'this approval had expired, so nothing ran. Ask the agent again to get a fresh one.'}`
1915
+ : `Your approval was NOT recorded: the server still shows it ${st}. Nothing ran. Answer it again from /approvals.`;
1916
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, batch, notRecorded: text });
1917
+ return { notRecorded: true };
1918
+ }
1919
+ if (card.inboxItemId) { walkAnsweredIds().add(card.inboxItemId); inlineAnswered().ids.set(card.inboxItemId, action); } // POPUP-DEDUPE
1920
+ if (card.approvalId) inlineAnswered().approvals.set(card.approvalId, action);
1477
1921
  // The demo's stream carries on to its own tool result, so no "running, result later" note, no poll, no stale count.
1478
- dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
1922
+ const consent = action === 'approve' && card.consent && api.grantContactability ? card.consent : null;
1923
+ if (consent) R.current.consentAsk = { ...consent, at: Date.now() }; // the next line typed is the lawful basis (or n)
1924
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch, consent });
1479
1925
  if (demoLabel) { B.current = initialBuffer(); dispatch({ type: 'notify', patch: { approvals: 0, error: null } }); } // (a stray early keystroke is not left in the composer)
1480
- if (action === 'approve' && card.tool !== 'hat.switch' && !card.foreign && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1926
+ if (action === 'approve' && card.tool !== 'hat.switch' && !consent && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1481
1927
  return { ok: true };
1482
1928
  } catch (e) {
1483
1929
  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)
1930
+ if (card.inboxItemId) walkAnsweredIds().add(card.inboxItemId); // POPUP-DEDUPE: handled elsewhere is never popped up again
1484
1931
  if (!batch) dispatch({ type: 'approval.gone', callId: card.callId, inboxItemId: card.inboxItemId || null });
1485
1932
  return { gone: true };
1486
1933
  }
@@ -1490,6 +1937,35 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1490
1937
  finally { R.current.answering.delete(card.callId); }
1491
1938
  };
1492
1939
 
1940
+ // APPROVAL-BURST: y / n on a batch card answers every item through the normal per-item API (one call each, recorded per item).
1941
+ const answerBatch = async (b, action) => {
1942
+ if (R.current.batchBusy) return;
1943
+ R.current.batchBusy = true;
1944
+ const word = action === 'approve' ? 'approved' : 'denied'; const total = b.cards.length; let done = 0, gone = 0; const failed = [];
1945
+ try {
1946
+ for (const c of b.cards) {
1947
+ try {
1948
+ const r = await answer(c, action, { batch: true, bulk: action === 'approve' });
1949
+ if (r?.ok) done++; else if (r?.gone) gone++; else failed.push(batchLabel(c));
1950
+ } catch (e) { failed.push(`${batchLabel(c)} (${clip(String(e?.message || e), 40)})`); if (e?.stepUpDeclined) break; }
1951
+ }
1952
+ } finally { R.current.batchBusy = false; }
1953
+ const rest = total - done - gone - failed.length; // left unattempted (step-up declined)
1954
+ if (rest > 0) for (const c of b.cards.slice(total - rest)) failed.push(batchLabel(c));
1955
+ say(`${word} ${done} of ${total}${gone ? ` (${gone} already handled elsewhere)` : ''}${failed.length ? `. Not ${word}: ${clip(failed.join(', '), Math.max(30, cols - 40))}` : ''}`, failed.length ? 'warn' : 'ok');
1956
+ };
1957
+ // p on a batch card: the existing one-by-one walk, for just those items.
1958
+ const walkBatch = async (b) => {
1959
+ let items;
1960
+ try { items = await api.approvals(); } catch (e) { say(`Could not read the approvals (${clip(String(e?.message || e), 80)}); nothing changed.`, 'warn'); return; }
1961
+ const flat = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item));
1962
+ dispatch({ type: 'inbox', items: flat }); R.current.shownIds = flat.map(x => x.id);
1963
+ const ids = new Set(b.cards.map(c => c.inboxItemId));
1964
+ for (const x of queueOf(S.current)) K.current.dismissed.add(x.callId); // the walk owns the keys, as with Tab
1965
+ K.current.confirm = false; bump(n => n + 1);
1966
+ await walkStart(flat.filter(x => ids.has(x.id)).map(approvalAsWalkItem), { fresh: true });
1967
+ };
1968
+
1493
1969
  // ---- turn runner ---------------------------------------------------------------------------
1494
1970
  // Z2H-R2B: with no AI connected a turn is answered here, whole and wrapped, without a server call.
1495
1971
  async function aiConnected() {
@@ -1584,6 +2060,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1584
2060
  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);
1585
2061
  // QUOXON-FW3: the server was unreachable before it said anything (a redeploy). Keep the message, wait for it
1586
2062
  // to come back, and send once. Only a send that produced NO event is ever retried, so nothing runs twice.
2063
+ // Owner 2026-11-07: a "down" send can still have REACHED the server (it registered the turn_id, then the socket
2064
+ // dropped before any event). The resend keeps the same id; if the server says it already holds that id, attach to
2065
+ // that turn and show its answer. If it cannot be shown, the text goes back to the composer (a re-send there mints
2066
+ // a new id), never a bare "turn_id already ran" with the message lost.
2067
+ const resend = async function* (signal) {
2068
+ const it = open(signal)[Symbol.asyncIterator]();
2069
+ let first;
2070
+ try { first = await it.next(); } catch (e) { if (!isTurnIdTaken(e)) throw e; first = null; }
2071
+ if (first && !(first.done === false && isTurnIdTaken(first.value))) {
2072
+ if (first.done) return;
2073
+ yield first.value;
2074
+ for (let n = await it.next(); !n.done; n = await it.next()) yield n.value;
2075
+ return;
2076
+ }
2077
+ if (first) await it.return?.();
2078
+ const conversationId = S.current.ctx.conversationId;
2079
+ const held = Object.assign(new Error('turn held by the server but not attachable'), { code: 'turn_held_unattachable' });
2080
+ if (!conversationId || !api.reattach) throw held;
2081
+ let seen = false;
2082
+ try { for await (const ev of api.reattach({ conversationId, turnId, after: 0 }, signal)) { seen = true; yield ev; } }
2083
+ catch (e) { if (!seen && (e?.status === 404 || e?.status === 410)) throw held; throw e; }
2084
+ };
1587
2085
  let r = await consumeTurn(turnId, open, { keepOnDown: true });
1588
2086
  if (!r?.down) return r;
1589
2087
  say(SERVER_RESTARTING, 'warn');
@@ -1597,7 +2095,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1597
2095
  edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
1598
2096
  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');
1599
2097
  }
1600
- r = await consumeTurn(turnId, open, { keepOnDown: true });
2098
+ r = await consumeTurn(turnId, resend, { keepOnDown: true });
2099
+ if (r?.held) {
2100
+ dispatch({ type: 'turn.deferred' });
2101
+ edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
2102
+ return say(HELD_NOT_SHOWN, 'warn');
2103
+ }
1601
2104
  }
1602
2105
  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'); }
1603
2106
  return r;
@@ -1672,7 +2175,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1672
2175
  dispatchEv({ type: 'turn.begin', turnId });
1673
2176
  const ctrl = new AbortController();
1674
2177
  R.current.abort = ctrl;
1675
- let offered = false, sawEvent = false, down = false;
2178
+ let offered = false, sawEvent = false, down = false, held = false;
1676
2179
  let answerText = '', suggested = false, gate = null; // the safety net reads the answer when no suggestion came
1677
2180
  try {
1678
2181
  for await (const ev of open(ctrl.signal)) {
@@ -1711,7 +2214,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1711
2214
  dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
1712
2215
  }
1713
2216
  } catch (e) {
1714
- if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
2217
+ if (keepOnDown && !sawEvent && !ctrl.signal.aborted && (isServerDown(e) || e?.code === 'turn_held_unattachable')) { down = true; held = e?.code === 'turn_held_unattachable'; }
1715
2218
  else if (!ctrl.signal.aborted) {
1716
2219
  // FW4: settle in-flight tools explicitly (never leave a row "running"), then explain the cut with a way forward.
1717
2220
  if (e?.status === 423 && typeof e?.body?.block_id === 'string') { // QUOXON-STEPUP: a policy block; a person may override it once
@@ -1764,11 +2267,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1764
2267
  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
1765
2268
  if (ra && !newOffer && askOpen()) { touchAsks(); if (ra.redo) ra.redo(); else say(ra.text, 'ask'); }
1766
2269
  }
1767
- return down ? { down: true } : undefined;
2270
+ return down ? { down: true, ...(held ? { held: true } : {}) } : undefined;
1768
2271
  }
1769
2272
 
1770
2273
  React.useEffect(() => {
1771
- if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length) wakeDragon(); // an approval always wakes it
2274
+ if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length && !AWAY.current.afk) wakeDragon(); // an approval always wakes it
1772
2275
  const s = S.current;
1773
2276
  if (!s.turn && s.autoDrain && s.pending.length) {
1774
2277
  const next = s.pending[0];
@@ -1779,7 +2282,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1779
2282
 
1780
2283
  // A queued or steered draft that is a slash command runs as that command when its turn comes; it is never sent as chat.
1781
2284
  function runQueued(text, { checked = false } = {}) {
1782
- if (text.startsWith('/') && !text.includes('\n')) { command(text); return; }
2285
+ if (isOneLineCommand(text)) { command(text); return; }
1783
2286
  const stray = checked ? null : strayCardAnswer(text, commandNames()); // TUI-INPUT: "n/help" = a card answer that leaked into the composer
1784
2287
  if (stray) { R.current.strayAsk = { text, command: stray.command, at: Date.now() }; ask(strayQuestion(stray)); return; }
1785
2288
  dispatch({ type: 'user', text, queued: true });
@@ -1793,30 +2296,123 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1793
2296
 
1794
2297
  // PRESENCE (owner 2026-10-16): /afk and /sleep tell the server, so sessions and LLMs know you are not at this
1795
2298
  // terminal (approvals still queue; nothing is approved). /back or any typed turn marks you present.
1796
- async function setPresence(state, note) {
2299
+ async function setPresence(state, note, { human = false, dnd = false } = {}) {
1797
2300
  if (!api.setPresence) return { ok: true, local: true }; // demo / offline: the local mark is all there is
1798
- try { return await api.setPresence(state, { note: note || undefined, slots: where.current.slot ? [where.current.slot] : undefined }); }
2301
+ R.current.presenceSetAt = Date.now();
2302
+ const r0 = await setPresenceRaw(state, note, { human, dnd }); R.current.presenceUnsynced = !r0.ok; return r0;
2303
+ }
2304
+ async function setPresenceRaw(state, note, { human = false, dnd = false } = {}) {
2305
+ try { return await api.setPresence(state, { note: note || undefined, human, dnd, slots: where.current.slot ? [where.current.slot] : undefined }); }
1799
2306
  catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
1800
2307
  }
1801
2308
  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');
1802
- function markPresent(action = 'chat') {
1803
- if (/^\/(afk|sleep|back|away)(\s|$)/.test(String(action).replace(/^slash /, ''))) return;
2309
+ // DO-NOT-DISTURB (owner 2026-10-28, "sleep means sleep"): while AWAY.afk is set (/dnd, /afk, /sleep) the window collects
2310
+ // instead of announcing: no notice lines, cards, bells or dragon moments. /morningreport is the grouped digest.
2311
+ const dndNote = (until) => DND_NOTE + (until ? ` until ${String(new Date(until).getHours()).padStart(2, '0')}:${String(new Date(until).getMinutes()).padStart(2, '0')}` : '');
2312
+ function holdSlotMsg(m, label) {
2313
+ if (!AWAY.current.afk) return false;
2314
+ (AWAY.current.col ||= newCollected()).slots.push({ from: label || m.from || 'slot', text: String(m.text || '') });
2315
+ bump(n => n + 1);
2316
+ return true;
2317
+ }
2318
+ // MORNINGREPORT-ALWAYS (owner 2026-11-09): with nothing held (DND off) /morningreport still digests what happened since
2319
+ // you were last here (the same since-marker as /away): approvals, slot messages to you, sessions that need you,
2320
+ // incidents, then the /away card (agent actions). Reuses /sitrep, the held slot's messages and /away; never a new path.
2321
+ async function sinceDigest() {
2322
+ const p = parseSince('', { lastSeen: AWAY.current.last }); const sMs = Date.parse(p.since);
2323
+ const part = (fn) => (fn ? Promise.resolve().then(fn).then((v) => ({ ok: true, v }), (e) => ({ ok: false, error: e?.status ? `HTTP ${e.status}` : e?.message || 'failed' })) : Promise.resolve(null));
2324
+ const slot = where.current.slot || null;
2325
+ const [sit, apl, sl, aw] = await Promise.all([
2326
+ part(api.sitrep && (() => api.sitrep())),
2327
+ part(!api.sitrep && api.approvals && (() => api.approvals())),
2328
+ part(slot && api.slotMessages && (() => api.slotMessages(slot, 50))),
2329
+ part(api.away && (() => api.away(p.since)))
2330
+ ]);
2331
+ const blind = [];
2332
+ const sd = sit?.ok ? sitrepData(sit.v?.situation, sit.v?.approvals) : null;
2333
+ if (sit && !sit.ok) blind.push(`sitrep (${sit.error})`);
2334
+ let approvals = sd?.approvals || null;
2335
+ if (!approvals && apl?.ok) approvals = (apl.v || []).map((a) => ({ title: a.title || a.summary || a.id }));
2336
+ if (!approvals) approvals = [...S.current.approvals, ...S.current.waiting].map((a) => ({ title: a.title || a.tool || a.callId }));
2337
+ const label = myLabel();
2338
+ const slots = sl?.ok ? (sl.v || []).map((m) => ({ ...m, ts: Date.parse(m.created_at || m.ts) }))
2339
+ .filter((m) => Number.isFinite(m.ts) && m.ts >= sMs && !(label && m.from === label) && !isOwnBroadcast(m, { label, display: myDisplay(), userId: api.userId?.() || null })) : [];
2340
+ if (sl && !sl.ok) blind.push(`slot ${slot} (${sl.error})`);
2341
+ const sessions = [...new Set([...(R.current.roster.allRows || []).filter((x) => x.state === 'waiting').map((x) => String(x.name || x.key || 'a session')), ...(sd?.needs || []).map((n) => String(n.title))])];
2342
+ const incidents = aw?.ok ? (aw.v?.items || []).filter((it) => { const t = Date.parse(it.ts); return (!Number.isFinite(t) || t >= sMs) && (it.kind === 'alert' || /_failed$/.test(String(it.kind || '')) || isIncident(it)); }) : [];
2343
+ for (const [t, tone] of sinceLines({ approvals, slots, sessions, incidents })) say(t, tone);
2344
+ if (blind.length) say(`Could not read: ${blind.join(', ')}. Nothing is assumed there.`, 'warn');
2345
+ if (aw?.ok) dispatch({ type: 'object', obj: { type: 'away', data: aw.v } });
2346
+ else if (aw) say(`away: could not read the digest (${aw.error}). Nothing is assumed.`, 'warn');
2347
+ else say('/away is not available in this mode.', 'warn');
2348
+ return undefined;
2349
+ }
2350
+ async function dndOn(until = null) {
2351
+ const at = Date.now(); const untilMs = until ? untilEpoch(until, at) : null;
2352
+ const note = dndNote(untilMs);
2353
+ AWAY.current.last = at; AWAY.current.afk = { at, note, dnd: true, explicit: true, until: untilMs }; AWAY.current.col ||= newCollected();
2354
+ try { away?.write?.(profile, at); } catch { /* in-memory mark still works */ }
2355
+ say(`🌙 Do not disturb is on${untilMs ? ` until ${note.slice(-5)}` : ''}. Everything is collected for the morning: /morningreport shows it, /dnd off or /back ends it.`, 'ok');
2356
+ const pr = await setPresence('sleeping', note, { dnd: true });
2357
+ if (!pr.ok) presenceFail(pr);
2358
+ bump(n => n + 1);
2359
+ }
2360
+ // MORNINGREPORT-WINDOW (owner 2026-11-03): ending DND/away must not erase what it collected. The most recent window
2361
+ // (start, end, collected) is kept so /morningreport answers about it whether or not DND is on at the moment of asking.
2362
+ function archiveWindow() {
2363
+ const a = AWAY.current.afk, c = AWAY.current.col;
2364
+ if (!a && !c) return;
2365
+ AWAY.current.lastWindow = { since: c?.since || a?.at || Date.now(), until: Date.now(), col: c || newCollected() };
2366
+ }
2367
+ async function dndOff() {
2368
+ archiveWindow();
2369
+ AWAY.current.afk = null;
2370
+ const n = waitingCount(AWAY.current.col, 0); AWAY.current.col = null; // the archived window keeps it; the next window starts empty
2371
+ say(`Do not disturb is off.${n ? ` ${n} collected while it was on: /morningreport shows them.` : ''}`, 'ok');
2372
+ const pr = await setPresence('present', undefined, { human: true });
2373
+ if (!pr.ok) presenceFail(pr);
2374
+ bump(n2 => n2 + 1);
2375
+ }
2376
+ async function sleepNow() { // owner 2026-10-29: one step to bed: sleeping AND do-not-disturb
2377
+ const at = Date.now(); const note = dndNote(null);
2378
+ AWAY.current.last = at; AWAY.current.afk = { at, note, dnd: true, explicit: true, until: null }; AWAY.current.col ||= newCollected();
2379
+ try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
2380
+ say("🌙 Asleep, do not disturb on: everything collects for the morning. /morningreport or /back when you're up.", 'ok');
2381
+ const pr = await setPresence('sleeping', note, { dnd: true });
2382
+ if (!pr.ok) presenceFail(pr);
2383
+ bump(n => n + 1);
2384
+ }
2385
+ function offerDndToo() {
2386
+ if (R.current.dndAsk) return;
2387
+ R.current.dndAsk = { at: Date.now(), off: false };
2388
+ say(askText('Turn on do not disturb too? (y/N)', { action: 'collect arrivals quietly until you are back (same as /dnd on)' }), 'ask');
2389
+ }
2390
+ function offerDnd() {
2391
+ if (AWAY.current.afk || R.current.dndAsk) return;
2392
+ R.current.dndAsk = { at: Date.now(), off: false };
2393
+ say(askText('Turn on do not disturb until morning?', { action: 'collect arrivals quietly until you are back (same as /dnd on)', title: 'Going to sleep?' }), 'ask');
2394
+ }
2395
+ function markPresent(action = 'chat', { quiet = false } = {}) {
2396
+ if (/^\/(afk|sleep|back|away|dnd|donotdisturb|morningreport)(\s|$)/.test(String(action).replace(/^slash /, ''))) return;
1804
2397
  if (AWAY.current.afk) { // this window set the flag: a typed turn here is the owner back (explicit, as before)
2398
+ archiveWindow();
1805
2399
  AWAY.current.afk = null;
1806
- setPresence('present').then((r) => { if (!r.ok) presenceFail(r); }, () => {});
2400
+ setPresence('present', undefined, { human: true }).then((r) => { if (!r.ok) presenceFail(r); }, () => {});
1807
2401
  return;
1808
2402
  }
1809
2403
  // PRESENCE (owner 2026-10-19): a window that never set the flag (restarted TUI) still clears the server's stale one,
1810
2404
  // but only from a real terminal (api.noteActivity is a no-op for --print, pipes and proof runs). Welcome shows once.
1811
2405
  if (!api.noteActivity) return;
1812
- api.noteActivity(action).then((r) => {
2406
+ api.noteActivity(action, quiet ? { quiet: true } : undefined).then((r) => {
1813
2407
  if (r?.welcome?.away_text && !AWAY.current.welcomed) { AWAY.current.welcomed = true; say(`✔ Welcome back, you were away ${r.welcome.away_text}`, 'ok'); }
1814
2408
  }, () => {});
1815
2409
  }
1816
2410
  function submit(text) {
1817
2411
  touchSeen();
2412
+ AR.current.busyAt = Date.now(); // a just-sent turn is in flight before S.turn shows it: auto-restart must not fire in that gap
1818
2413
  markPresent(/^\//.test(text.trim()) ? `slash ${text.trim().split(/\s+/)[0]}` : 'chat');
1819
2414
  dispatch({ type: 'user', text });
2415
+ if (!/^\//.test(text.trim()) && sleepIntent(text)) offerDnd(); // never silently: it asks
1820
2416
  const tip = intentHint(text); // a hint only: the turn still goes to the model
1821
2417
  if (tip) say(tip);
1822
2418
  const panel = intentPanel(text); // the same panel as the command, from local facts, above the answer
@@ -1920,7 +2516,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1920
2516
  if (cmd === '/cost') return help(costModel(LG.current));
1921
2517
  if (cmd === '/status') {
1922
2518
  let hl = R.current.lastHealth || null;
1923
- if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; }
2519
+ if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; if (hl) R.current.lastGoodAt = Date.now(); }
1924
2520
  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 }));
1925
2521
  }
1926
2522
  if (cmd === '/context') {
@@ -2089,6 +2685,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2089
2685
 
2090
2686
  // ---- QUOXON-FW8: ! commands ---------------------------------------------------------------------
2091
2687
  function bangCommand(bang, { echoed = false } = {}) {
2688
+ if (isWebPty(env)) return say(SHELL_REFUSED, 'warn'); // QUOXON web PTY: the browser terminal has no shell
2092
2689
  if (bang.kind === 'reserved') return say('!! is reserved. Use ! followed by a command, e.g. !ls or !quox inbox list.', 'warn');
2093
2690
  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');
2094
2691
  let plan = { cmd: bang.cmd };
@@ -2110,7 +2707,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2110
2707
  out = cleanOutput(out).replace(/\n+$/, '');
2111
2708
  if (out) dispatch({ type: 'bang.out', text: out });
2112
2709
  };
2113
- const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
2710
+ const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, columns: cols - 2, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
2114
2711
  const slow = setTimeout(() => { if (R.current.bang?.run === run && !R.current.exiting) say('still running (Ctrl+C stops it)', 'accent'); }, 5000);
2115
2712
  R.current.bang = { run, cmd };
2116
2713
  run.done.then((r) => {
@@ -2199,6 +2796,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2199
2796
  const cand = loginCandidates.find(x => x !== me && okProfileName(x)) || 'me';
2200
2797
  return offerLogin(cand, `This window is an agent login (${me}) and no human login is signed in on this machine.`);
2201
2798
  }
2799
+ // STARTUP OFFER (owner 2026-10-28): `quox` opened on an agent login while human logins exist asks once, y/N, to
2800
+ // restart as the first human profile (the rest are in /profile). N keeps the agent window; the intro warning stays.
2801
+ function offerStartupSwitch() {
2802
+ const { profile: me, agentLogin: isAgent } = WHO.current;
2803
+ if (!isAgent) return undefined;
2804
+ const human = humanProfiles.find(x => x !== me && okProfileName(x));
2805
+ if (!human) return undefined;
2806
+ R.current.profileAsk = { name: human, at: Date.now() };
2807
+ return say(startupSwitchAskLine(me, human, humanProfiles.filter(x => x !== me && x !== human).length), 'ask');
2808
+ }
2809
+ // Plain-language slot attach: a known slot gets the /slot offer (y runs it); anything else is ordinary chat.
2810
+ async function offerSlotAttach(nm, text) {
2811
+ let rows = null;
2812
+ try { rows = await api.slots(); } catch { rows = null; }
2813
+ const known = Array.isArray(rows) && rows.find(x => String(x.name).toLowerCase() === nm.toLowerCase());
2814
+ if (!known) { submit(text); return; }
2815
+ const line = `/slot ${known.name}`;
2816
+ dispatch({ type: 'user', text });
2817
+ R.current.suggestAsk = { line, at: Date.now() };
2818
+ ask(`Run ${line}?`, { action: line, reason: `"${clip(text, 60)}" is a request to attach to that slot` });
2819
+ }
2202
2820
  function offerLogin(name, lead) {
2203
2821
  return armRun(loginRunRequest(name, `${lead} Sign ${name} in, then /profile ${name} switches this window to it.`, runNow()), { who: 'Quox', glyph: 'Ⓠ', text: '' });
2204
2822
  }
@@ -2241,17 +2859,37 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2241
2859
  target = c.onPath || undefined;
2242
2860
  }
2243
2861
  say(`Restarting${S.current.ctx.conversationId ? ' into this conversation' : ''}…`, 'dim');
2244
- 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 });
2862
+ AR.current.lastRestartAt = Date.now(); api.saveSetting?.('lastRestartAt', AR.current.lastRestartAt); // the next window reads it: restarts coalesce
2863
+ onRestart({ conversationId: S.current.ctx.conversationId || null, name: SN.current.name || null, slot: where.current.slot || null, attach: SN.current.agent ? `agent:${SN.current.agent}` : (SN.current.slotTarget ? `slot:${SN.current.slotTarget}` : null), org: api.windowOrgId?.() || null, ...(MD.current === 'plan' ? { mode: 'plan' } : {}), ...(R.current.declinedSlot || restartDeclined ? { declined: R.current.declinedSlot || restartDeclined } : {}), bin: target, ...extra });
2245
2864
  return exit(30);
2246
2865
  }
2247
2866
 
2867
+ // INSTANT-FEEDBACK (owner 2026-10-26): every slash command goes through here, so any async handler shows `⠋ <verb>…`
2868
+ // within 100 ms until its result or error line. A command run from inside another one reuses the outer spinner.
2248
2869
  async function command(line) {
2870
+ if (R.current.spinOn || !/^\s*\/(?!\/)/.test(String(line))) return commandRun(line);
2871
+ R.current.spinOn = true;
2872
+ try {
2873
+ return await runWithSpinner(() => commandRun(line), {
2874
+ verb: spinnerVerb(line),
2875
+ show: (t) => dispatch({ type: 'notify', patch: { spinner: t } }),
2876
+ hide: () => dispatch({ type: 'notify', patch: { spinner: '' } }),
2877
+ register: (stop) => { R.current.spinStop = stop; }
2878
+ });
2879
+ } finally { R.current.spinOn = false; R.current.spinStop = null; }
2880
+ }
2881
+ // A command the TUI dispatches from inside another one (/sleep -> /dragon sleep) is not the owner typing: it never marks present.
2882
+ async function commandRun(line) {
2883
+ const nested = (R.current.cmdDepth || 0) > 0; R.current.cmdDepth = (R.current.cmdDepth || 0) + 1;
2884
+ try { return await commandBody(line, nested); } finally { R.current.cmdDepth -= 1; }
2885
+ }
2886
+ async function commandBody(line, nested) {
2249
2887
  if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/devnote');
2250
2888
  const [cmd0, arg] = line.trim().split(/\s+/);
2251
2889
  const cmd = cmd0 === '/agent' ? '/agents' : cmd0; // /agent is the same command
2252
- if (!['/afk', '/sleep', '/back', '/away'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
2890
+ if (!nested && !['/afk', '/sleep', '/back', '/away', '/dnd', '/donotdisturb', '/morningreport'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
2253
2891
  if (cmd !== '/devnote' && cmd !== '/context') SCX.current.command(line.trim());
2254
- if (cmd !== '/devnote') 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
2892
+ if (cmd !== '/devnote') transcript?.command((/^\/(authenticate|authorise|authorize)$/.test(cmd) && !/^[\d,\s-]*$/.test(line.trim().replace(/^\S+\s*/, ''))) || (cmd === '/verify-email' && /(^|\D)\d{6}(\D|$)/.test(line)) ? cmd : line); // QUOXON-STEPUP: never record a stray argument that might be a PIN
2255
2893
  const restArg = line.trim().replace(/^\S+\s*/, '');
2256
2894
  touchSeen();
2257
2895
  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
@@ -2312,10 +2950,16 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2312
2950
  const first = to[0] || m[1].toLowerCase();
2313
2951
  let rows = [];
2314
2952
  if (api.slots) { try { rows = await api.slots(); } catch { rows = []; } }
2315
- const known = !looksLikeAddress(m[1]) && !!resolveSayTarget(first, Array.isArray(rows) ? rows : [], SC.current.senders);
2953
+ let known = !looksLikeAddress(m[1]) && !!resolveSayTarget(first, Array.isArray(rows) ? rows : [], SC.current.senders);
2316
2954
  const last = R.current.lastSay;
2955
+ let lookupFailed = false;
2956
+ // Not in this org's list: a slot in another of the user's own orgs (or name@org) is still a target, not message text.
2957
+ if (!known && last && !looksLikeAddress(m[1]) && api.slotLocate) {
2958
+ try { known = !!(await api.slotLocate(first))?.found; } catch { lookupFailed = true; }
2959
+ }
2317
2960
  if (!known && (looksLikeAddress(m[1]) || last)) {
2318
2961
  if (last && Date.now() - last.at < 2 * 3600000) {
2962
+ if (lookupFailed) say(`Could not check your other orgs for "${first}", so this goes to ${last.slot}, the last target.`, 'warn');
2319
2963
  R.current.sayLastAsk = { slot: last.slot, text: restArg.trim(), at: Date.now() };
2320
2964
  return say(askText(`Send to ${last.slot}?`, { keys: 'y send · n cancel', action: `send the whole message to ${last.slot}` }), 'ask');
2321
2965
  }
@@ -2326,13 +2970,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2326
2970
  // QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
2327
2971
  const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
2328
2972
  const head = `(to ${slots.join(', ')}) `;
2329
- for (const sl of slots) await sendToSlot(sl, head + m[2].trim());
2973
+ for (const sl of slots) await sendToSlot(sl, head + m[2].trim(), { draft: null });
2330
2974
  return undefined;
2331
2975
  }
2332
2976
  case '/join': { const nm = roomName(restArg); if (!nm) return say('Usage: /join <slot> (or #<slot>)', 'warn'); return joinRoom(nm); }
2333
2977
  case '/leave': { const nm = roomName(restArg); if (!nm) return say('Usage: /leave <slot>', 'warn'); return leaveRoom(nm); }
2334
2978
  case '/rooms': case '/groups': return showRooms();
2335
2979
  case '/broadcast': case '/page':
2980
+ if (cmd === '/broadcast' && sleepIntent(restArg)) queueMicrotask(offerDnd); // never silently: it asks, after the card
2336
2981
  return runBroadcast({ api, kind: cmd === '/page' ? 'page' : 'broadcast', arg: restArg, from: myLabel(), say, card: (m) => dispatch({ type: 'bcard', card: m }), isClosed: () => R.current.closed });
2337
2982
  case '/ack': return runAck({ api, arg: restArg, slot: where.current.slot || null, say });
2338
2983
  case '/send': { // QUOXON-MESH: a local text file into a slot (inline, 64 KB cap, secrets scrubbed on a yes)
@@ -2409,7 +3054,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2409
3054
  }
2410
3055
  SN.current.slotTarget = null;
2411
3056
  SN.current.agent = m.agent.id; where.current = { ...where.current, agent: m.agent.id }; bump(x => x + 1);
2412
- DC.current.attach(m.agent.id, { greet: greetThis }).then(() => { const n = DC.current.takeArtNotice(); if (n) say(n, 'warn'); });
3057
+ DC.current.attach(m.agent.id, { greet: greetThis }).then(() => { const n = DC.current.takeArtNotice() || DC.current.narrowNotice({ cols, rows: termRows(stdout) ?? 24 }); if (n) say(n, 'warn'); });
2413
3058
  say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.`, 'ok');
2414
3059
  if (both && pt.kind !== 'agent') say(`${pt.name} is also a slot: /attach slot:${pt.name} for that`, 'warn');
2415
3060
  if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
@@ -2569,20 +3214,41 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2569
3214
  }
2570
3215
  case '/deck': { // provided by the quoxdeck extension when loaded; otherwise says where it comes from (the registry entry carries ext)
2571
3216
  if (await extDispatch(cmd, restArg)) return undefined;
2572
- return say('/deck comes from the quoxdeck extension, which is not loaded. npm i -g quoxdeck, then /ext on quoxdeck.', 'warn');
3217
+ const dw = restArg.trim().split(/\s+/)[0];
3218
+ if (dw && dw !== 'on') return say('/deck comes from the quoxdeck extension, which is not loaded. /deck on loads it.', 'warn');
3219
+ const lr = await DK.current.load('quoxdeck'); // LAZY PATH: not loaded -> find out why and OFFER (y/N), never a raw build error
3220
+ if (lr.ok) { extAdd('quoxdeck'); say(lr.text, 'ok'); return undefined; }
3221
+ const offer = await deckOffer({ isInstalled: async () => !lr.notFound });
3222
+ if (!offer) return say(lr.text, 'warn');
3223
+ if (!offer.steps) return say(offer.text, 'warn');
3224
+ R.current.deckAsk = { offer, at: Date.now() };
3225
+ return ask(offer.question, { action: offer.steps.map(s => s.cmd).join(' && '), reason: 'sudo, if shown, asks for your own password in this terminal' });
2573
3226
  }
2574
- case '/ext': { // EXTENSIONS: /ext list|on|off <name>
2575
- const [w, nm] = restArg.trim().split(/\s+/);
3227
+ case '/ext': { // EXTENSIONS: /ext list|on|off|tab|set|settings <name>
3228
+ const parts = restArg.trim().split(/\s+/).filter(Boolean);
3229
+ const [w, nm] = parts;
2576
3230
  const list = readExtList();
2577
3231
  if (!w || w === 'list') {
2578
3232
  if (!list.length) return say('No extensions listed. Install one (npm i -g <pkg>) and list it: quox ext add <pkg>.');
2579
- return say(list.map(x => `${x.name}: ${DK.current.isLoaded(x.name) ? `on (found: ${DK.current.where(x.name)})` : x.enabled ? 'listed, not loaded' : 'off'}`).join('\n'));
3233
+ return say(list.map(x => `${x.name}: ${DK.current.isLoaded(x.name) ? `on (found: ${DK.current.where(x.name)})` : DK.current.failure(x.name) ? `failed: ${DK.current.failure(x.name)}` : x.enabled ? 'listed, not loaded' : 'off'}`).join('\n'));
2580
3234
  }
2581
3235
  if ((w === 'on' || w === 'off') && nm) {
2582
3236
  if (w === 'on') { if (!list.some(x => x.name === nm)) extAdd(nm); else extSetEnabled(nm, true); await extLoad(nm, false); return undefined; }
2583
3237
  extSetEnabled(nm, false); const r = DK.current.unload(nm); return say(r.text, 'ok');
2584
3238
  }
2585
- return say('Usage: /ext list|on|off <name>', 'warn');
3239
+ if (w === 'tab') { // focus a panel; off collapses; alone cycles
3240
+ if (nm === 'off') { DK.current.hidePanel(); return say('Extension panel closed.'); }
3241
+ if (!nm) { const n = DK.current.cycle(); return n ? say(`${n} panel focused. Esc returns to the composer.`, 'ok') : say('No extension has a panel. /ext list', 'warn'); }
3242
+ const r = DK.current.focusPanel(nm); return say(r.text, r.ok ? 'ok' : 'warn');
3243
+ }
3244
+ if (w === 'settings' && nm) {
3245
+ const r = DK.current.settingsInfo(nm); if (!r.ok) return say(r.text, 'warn');
3246
+ return say(r.defs.map(d => `${d.key} = ${JSON.stringify(r.values[d.key])} (${d.type}${d.choices ? `: ${d.choices.join('|')}` : ''}, default ${JSON.stringify(d.default)}) ${d.help}`).join('\n'));
3247
+ }
3248
+ if (w === 'set' && nm && parts.length >= 4) {
3249
+ const r = DK.current.setSetting(nm, parts[2], parts.slice(3).join(' ')); return say(r.text, r.ok ? 'ok' : 'warn');
3250
+ }
3251
+ return say('Usage: /ext list | on|off <name> | tab [<name>|off] | settings <name> | set <name> <key> <value>', 'warn');
2586
3252
  }
2587
3253
  case '/watch': return KC.current.watch(restArg);
2588
3254
  case '/automate': return KC.current.automate(restArg);
@@ -2679,18 +3345,63 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2679
3345
  if (!text) return say(cmd === '/gripe!' ? 'Nothing to file. /gripe <text> first, or /gripe! <text>.' : 'Usage: /gripe <what is wrong> (/gripe! <text> files without asking)', 'warn');
2680
3346
  if (cmd === '/gripe') { // ask first: filing makes a public-ish issue
2681
3347
  R.current.pendingGripe = { text, at: Date.now() };
2682
- say(`File this as a GitHub issue via GRIPE? "${clip(text, Math.max(20, cols - 50))}"`);
3348
+ const nl = text.split('\n').length;
3349
+ say(`${gripeLead} File this as a GitHub issue via GRIPE? "${clip(text.split('\n')[0], Math.max(20, cols - 50))}"${nl > 1 ? ` (+${nl - 1} more lines, all of it is filed)` : ''}`);
2683
3350
  return say('/gripe! files it · /gripe <new text> replaces it · anything else leaves it unfiled', 'dim');
2684
3351
  }
2685
3352
  R.current.pendingGripe = null;
2686
3353
  say('Filing via GRIPE…', 'dim');
2687
3354
  const g = await api.gripe(text.slice(0, 4000), { yes: true });
2688
- if (!g.ok) return say(`Gripe not filed: ${g.message}`, 'warn');
3355
+ if (!g.ok && g.queued) return say(`${g.saved || "Saved; I'll file it when Quox answers again (/gripes shows the queue)"} (${clip(g.message, 60)})`, 'warn');
3356
+ if (!g.ok) return say(gripeFailedLine(text, g.message), 'warn');
2689
3357
  if (!g.superseded && g.response && !g.url) for (const l of String(g.response).split('\n').filter(Boolean).slice(0, 6)) say(clip(l, cols - 4), 'dim');
2690
3358
  for (const n of g.notes || []) {
2691
- if (n.kind === 'created') say(`✔ GRIPE │ issue filed │ ${n.url}`, 'ok');
3359
+ if (n.kind === 'created') { say(gripeFiledLine(n.url, text)); say(n.url, 'dim'); }
2692
3360
  else say(clip(n.text, cols * 3), n.kind === 'warn' ? 'warn' : 'dim');
2693
3361
  }
3362
+ if (g.url) gripeSweep.current(); // Quox just answered: file anything saved earlier
3363
+ return undefined;
3364
+ }
3365
+ case '/outbox': { // SLOT-OUTBOX: messages saved while QuoxCORE did not answer
3366
+ const [sub, ...rest] = restArg.trim().split(/\s+/).filter(Boolean);
3367
+ const rows = () => OB.current.list({ profile });
3368
+ if (!sub) {
3369
+ const list = rows();
3370
+ if (!list.length) return say('Outbox is empty. A /say that fails because QuoxCORE is not answering waits here and is sent when it answers.', 'dim');
3371
+ say(`Outbox: ${list.length} message${list.length === 1 ? '' : 's'}. /outbox send retries now, /outbox drop N removes one.`, 'dim');
3372
+ list.forEach((it, i) => {
3373
+ const t = new Date(it.created_at); const hm = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
3374
+ const st = it.status === 'queued' ? '⏸ queued' : it.status === 'sending' ? '◐ sending' : it.status === 'unsure' ? '⚠ may already be delivered (/outbox send retries)' : `✖ refused (${it.error || 'refused'})`;
3375
+ say(`${i + 1}. ${it.slot} · ${hm} │ ${clip(String(it.text).replace(/\s+/g, ' '), Math.max(20, cols - 60))} │ ${st}`, it.status === 'queued' ? 'dim' : 'warn');
3376
+ });
3377
+ return undefined;
3378
+ }
3379
+ if (sub === 'drop') {
3380
+ const n = Number(rest[0]);
3381
+ if (!Number.isInteger(n) || n < 1) return say('Usage: /outbox drop N (N is the number /outbox shows)', 'warn');
3382
+ const it = OB.current.drop(n, { profile });
3383
+ return say(it ? `Dropped the message to ${it.slot} (${clip(String(it.text).replace(/\s+/g, ' '), 40)}); it will not be sent.` : `No message ${n} in the outbox. /outbox lists them.`, it ? 'ok' : 'warn');
3384
+ }
3385
+ if (sub === 'send') {
3386
+ if (!rows().length) return say('Outbox is empty.', 'dim');
3387
+ const r = await flushOutbox({ explicit: true });
3388
+ if (r.busy) return say('A send is already in progress.', 'dim');
3389
+ if (r.pending > 0) say(`QuoxCORE is still not answering (${r.pending} queued). They will be sent when it does.`, 'warn');
3390
+ else if (!r.sent.length && !r.failed.length) say('Nothing to send.', 'dim');
3391
+ return undefined;
3392
+ }
3393
+ return say('Usage: /outbox | /outbox drop N | /outbox send', 'warn');
3394
+ }
3395
+ case '/gripes': {
3396
+ if (!api.gripes) return say('/gripes is not available in this mode.', 'warn');
3397
+ const rows = api.gripes();
3398
+ if (!rows.length) return say('No queued or recent gripes. /gripe! files one.', 'dim');
3399
+ const queued = rows.filter(g => g.status === 'queued').length;
3400
+ say(`Gripes: ${queued} waiting to be filed, ${rows.length - queued} recent`, 'dim');
3401
+ for (const g of rows.slice(-12)) {
3402
+ const where = g.status === 'filed' ? g.url : g.status === 'queued' ? 'queued, will retry' : g.status === 'expired' ? 'not filed (gave up after 24 h)' : `not filed (${g.error || 'refused'})`;
3403
+ say(`${g.status === 'filed' ? '✔' : '◐'} ${clip(g.text, Math.max(20, cols - 70))} │ ${where}`, g.status === 'filed' ? 'ok' : 'warn');
3404
+ }
2694
3405
  return undefined;
2695
3406
  }
2696
3407
  case '/context':
@@ -2905,6 +3616,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2905
3616
  case '/org': {
2906
3617
  if (!api.orgs) return say('Org switching is not available in this mode.', 'warn');
2907
3618
  const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
3619
+ // Already authorised: the person typed this /org line themselves, or said y to an agent's suggested one (owner 2026-10-15,
3620
+ // 2026-11-06: a typed command is the authorisation, never double-approve). Read once and cleared, so it never carries over.
3621
+ const authorised = R0.preConfirmed === 'org' || R0.typedOrg === line.trim(); R0.preConfirmed = null; R0.typedOrg = null;
2908
3622
  const sub = (restArg.match(/^(\S+)\s*(.*)$/) || [])[1] || '';
2909
3623
  const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
2910
3624
  let list;
@@ -2926,8 +3640,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2926
3640
  const q = restArg.trim();
2927
3641
  const m = matchOrg(q, list);
2928
3642
  if (m.org) return switchOrgNow(m.org, { quick: true });
2929
- const words = q.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
2930
- const part = orgRows(list).filter(o => words.every(w => `${o.name} ${o.slug || ''}`.toLowerCase().includes(w)));
3643
+ const part = orgCandidates(q, list); // verb stripped, best-matching token: never a dead end on a typo
2931
3644
  if (!part.length) return say(`No org of yours matches "${q}". /org lists them.`, 'warn');
2932
3645
  openOrgPicker(part, `Orgs matching "${q}"`);
2933
3646
  return undefined;
@@ -2944,7 +3657,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2944
3657
  if (!p) return say('No previous org in this session yet. /org lists yours.', 'warn');
2945
3658
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2946
3659
  R0.orgPending = { id: p.id, name: p.name, at: Date.now() };
2947
- if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
3660
+ if (authorised) return command('/org yes');
2948
3661
  return ask(`Switch back to ${p.name}?`, { action: `/org switch ${p.id} (${p.name})`, reason: 'This starts a new conversation in that org.' });
2949
3662
  }
2950
3663
  if (sub !== 'switch') return say('Usage: /org, /org switch <name or id>, or /org back', 'warn');
@@ -2955,8 +3668,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2955
3668
  if (!m.org) return say(`No org of yours matches "${query}". /org lists them.`, 'warn');
2956
3669
  if (m.org.id === curId) return say(`Already in ${m.org.name}.`);
2957
3670
  R0.orgPending = { id: m.org.id, name: m.org.name, at: Date.now() };
2958
- // Already said yes to the suggested "/org switch X? y/N": one confirmation, not two (owner 2026-10-15).
2959
- if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
3671
+ if (authorised) return command('/org yes'); // typed, or a y to the suggestion: one confirmation at most, never two
2960
3672
  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.' });
2961
3673
  }
2962
3674
  case '/todo': case '/todos': {
@@ -3000,6 +3712,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3000
3712
  return undefined;
3001
3713
  } catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
3002
3714
  }
3715
+ case '/callmeforapprovals': case '/callme': {
3716
+ if (!api.callMe) return say('This server has no call-me setting.', 'warn');
3717
+ try {
3718
+ const { data, changed } = await api.callMe(parseCallMeSlashArgs(restArg));
3719
+ const lines = callMeStatusLines(data);
3720
+ return say(`${changed ? 'Updated. ' : ''}${lines.join('\n')}`);
3721
+ } catch (e) { return say(`Call me for approvals: ${e?.headline || e?.message || 'failed'}`, 'warn'); }
3722
+ }
3003
3723
  case '/plugin': case '/extensions': case '/extension': case '/addons': case '/apps': case '/plugins': {
3004
3724
  if (!api.pluginList) return say('This server has no plugin list.', 'warn');
3005
3725
  const want = /^(list|ls|all)?$/i.test(restArg.trim()) ? '' : restArg.trim().split(/\s+/)[0]; // "/extension list" is just the list
@@ -3090,6 +3810,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3090
3810
  for (const src of ro.sources || []) {
3091
3811
  say(`${src.state === 'connected' ? '✔' : src.state === 'error' ? '✖' : '·'} ${src.label}: ${src.state === 'connected' ? `${src.count} shown` : `${src.state}${src.note ? ` (${src.note})` : ''}`}`, src.state === 'error' ? 'warn' : undefined);
3092
3812
  }
3813
+ for (const src of ro.sources || []) {
3814
+ if (src.id === 'agents' && src.state === 'connected') for (const l of attachLines(src.items, Date.now())) say(l);
3815
+ if (src.id === 'boxsessions' && src.state === 'connected') for (const l of sessionLines(src.items)) say(l, /x\d/.test(l) ? 'warn' : undefined);
3816
+ }
3093
3817
  if (ro.sources) say('Down on an empty composer focuses the roster. Enter peeks, s stops (y confirms), Esc returns.');
3094
3818
  return undefined;
3095
3819
  }
@@ -3116,17 +3840,65 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3116
3840
  return dispatch({ type: 'object', obj: { type: 'sitrep', data: sitrepData(r.situation, r.approvals) } });
3117
3841
  }
3118
3842
  case '/afk': { // owner 2026-10-15: "/afk need a break, keep my requests organised for my return"
3119
- const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: restArg || '' };
3843
+ if (afkMeansSleep(restArg)) { await sleepNow(); return command('/dragon sleep'); } // owner 2026-10-29: "/afk going to bed" is /sleep
3844
+ const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: restArg || '', dnd: true }; AWAY.current.col ||= newCollected();
3120
3845
  try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
3121
3846
  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');
3122
- const pr = await setPresence('away', restArg);
3847
+ const pr = await setPresence('away', restArg, { dnd: true });
3123
3848
  if (!pr.ok) presenceFail(pr);
3124
3849
  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');
3850
+ offerDndToo();
3851
+ return undefined;
3852
+ }
3853
+ case '/donotdisturb': case '/dnd': {
3854
+ const p = parseDndArgs(restArg);
3855
+ if (!p.ok) return say(p.error, 'warn');
3856
+ // owner 2026-10-29: a bare /dnd never turns DND off. The old toggle read /afk's away mark as "DND on" and reported "off" on the first press.
3857
+ if (p.op === 'toggle' && AWAY.current.afk?.explicit) return say('Do not disturb is already on. /dnd off turns it off, /back ends it.', 'dim');
3858
+ if (p.op === 'off') return AWAY.current.afk ? dndOff() : say('Do not disturb is off.', 'dim');
3859
+ return dndOn(p.until);
3860
+ }
3861
+ case '/morningreport': {
3862
+ // The report is about the most recent DND/away window (start to end, or now if still on), not the current on/off state.
3863
+ const live = AWAY.current.afk ? { since: AWAY.current.col?.since, col: AWAY.current.col } : null;
3864
+ const win = live || AWAY.current.lastWindow || (AWAY.current.col ? { since: AWAY.current.col.since, col: AWAY.current.col } : null);
3865
+ const held = (c) => Boolean(c && ((c.inbox?.length || 0) + (c.slots?.length || 0)));
3866
+ if (!win || (!AWAY.current.afk && !held(win.col))) { // nothing held in the last window: same since-last-seen digest as /away, honest note first
3867
+ say(win ? 'Nothing was held in your last do not disturb window. Here is what happened since you were last here.' : 'Nothing was held: do not disturb was off. Here is what happened since you were last here.', 'dim');
3868
+ return sinceDigest();
3869
+ }
3870
+ const col = win.col;
3871
+ const approvals = Math.max(S.current.notify?.approvals || 0, [...S.current.approvals, ...S.current.waiting].length);
3872
+ const sessions = (R.current.roster.allRows || []).filter((x) => x.state === 'waiting').map((x) => String(x.name || x.key || 'a session')).slice(0, 8);
3873
+ const hm = (ms) => new Date(ms).toISOString().slice(11, 16);
3874
+ say(`🌙 Morning report${win.since ? ` since ${hm(win.since)} UTC` : ''}${win.until ? ` until ${hm(win.until)} UTC` : ''}`, 'ok');
3875
+ for (const [t, tone] of morningLines(col || newCollected(), { approvals, sessions })) say(t, tone);
3876
+ if (api.away && win.since) { try { dispatch({ type: 'object', obj: { type: 'away', data: await api.away(new Date(win.since).toISOString()) } }); } catch { /* the local digest above stands */ } }
3877
+ if (AWAY.current.afk) {
3878
+ AWAY.current.col = newCollected();
3879
+ R.current.dndAsk = { at: Date.now(), off: true }; say(askText('Good morning. Turn off do not disturb?', { action: 'end do not disturb and show approvals again (same as /dnd off)' }), 'ask');
3880
+ }
3125
3881
  return undefined;
3126
3882
  }
3127
- case '/back': { // PRESENCE: present on the server too, then the digest
3128
- AWAY.current.afk = null;
3129
- const pr = await setPresence('present');
3883
+ case '/back': { // PRESENCE: present on the server too, then the digest (also ends do not disturb)
3884
+ // Presence is owner-level: if another window's first act already ended this away, the server's since/until win.
3885
+ let ended = null;
3886
+ if (AWAY.current.afk && api.getPresence) {
3887
+ try {
3888
+ const sp = await api.getPresence();
3889
+ const ea = sp?.ok && sp.presence && sp.presence.away !== true ? sp.presence.ended_away : null;
3890
+ if (ea && Date.parse(ea.until) >= AWAY.current.afk.at - 60000) ended = ea;
3891
+ } catch { /* unreadable: fall through to this window's own marks */ }
3892
+ }
3893
+ archiveWindow();
3894
+ AWAY.current.afk = null; AWAY.current.col = null;
3895
+ if (ended && api.away) {
3896
+ say(`✔ Welcome back, you were already marked present at ${new Date(ended.until).toISOString().slice(11, 16)} UTC (${ended.via || 'activity'} in another window).`, 'ok');
3897
+ try { dispatch({ type: 'object', obj: { type: 'away', data: { ...(await api.away(ended.since)), endedAt: ended.until } } }); }
3898
+ catch (e) { say(`away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). Nothing is assumed.`, 'warn'); }
3899
+ return undefined;
3900
+ }
3901
+ const pr = await setPresence('present', undefined, { human: true });
3130
3902
  if (!pr.ok) presenceFail(pr);
3131
3903
  return command('/away');
3132
3904
  }
@@ -3136,11 +3908,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3136
3908
  if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
3137
3909
  const r = await api.slotSay('quoxon', restArg.trim(), { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
3138
3910
  if (!r.ok) return say(`observe: not delivered (${r.message})`, 'warn');
3139
- return say('✔ noted for the observer (quoxon)', 'ok');
3911
+ return say(`✔ noted for the observer (quoxon${r.viaOrgName ? ` in ${r.viaOrgName}` : ''})`, 'ok');
3140
3912
  }
3141
3913
  case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
3142
- const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
3143
- 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');
3914
+ const m = parsePokeArgs(restArg, restArg.trim().includes(' ') ? await slotNames(api) : null); // FORCE-WAKE: --force is a no-op, never page text; TUI-WAKE-MULTI: a,b,c or a b c pages every one
3915
+ if (!m) return say(`Usage: ${cmd} <slot>[,<slot>...] [text] (several slots: a,b,c or a b c; 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');
3144
3916
  return command(`/page ${m.slot} ${m.text ? m.text.trim() : `are you there? (${cmd.slice(1)} from ${myLabel() || 'the owner'})`}`);
3145
3917
  }
3146
3918
  case '/away': {
@@ -3175,17 +3947,32 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3175
3947
  try { items = await api.inboxItems({ limit: 30 }); }
3176
3948
  catch (e) { return dispatch({ type: 'error', text: `Could not read the inbox (${e.message}). That does not mean it is empty; try again or run: quox inbox list` }); }
3177
3949
  const top = newestFirst(items).slice(0, 10);
3178
- R.current.inboxList = top; R.current.inboxSeen = new Set(items.map((i) => i?.id).filter(Boolean));
3179
- dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols) } });
3950
+ R.current.inboxList = top; R.current.inboxSeen = arrivals(items, R.current.inboxSeen || null).ids;
3951
+ const barText = () => inboxBarText(newestFirst(items).slice(0, INBOX_BAR_READ), cols, barOpts()); // TUI-CARD-SYNC: the poll's basis, never a 30-item count
3952
+ dispatch({ type: 'notify', patch: { inboxBar: barText() } });
3953
+ if (q.kind === 'mutes') { for (const l of mutesLines(inboxMutes())) say(l.text, l.tone); return undefined; }
3954
+ if (q.kind === 'mute' || q.kind === 'unmute') {
3955
+ const key = q.source.toLowerCase(); const next = { ...inboxMutes() };
3956
+ if (q.kind === 'mute') next[key] = Date.now() + q.ms; else if (!next[key]) return say(`${q.source} is not muted. ${mark('/inbox mutes')} lists them.`, 'warn'); else delete next[key];
3957
+ if (api.saveSetting) api.saveSetting('inboxMutes', next); else (R.current.sessionSettings ||= {}).inboxMutes = next;
3958
+ dispatch({ type: 'notify', patch: { inboxBar: barText() } });
3959
+ return say(q.kind === 'mute' ? `✔ muted ${q.source} for ${Math.round(q.ms / 360000) / 10}h: no arrival lines, out of the bar count, still listed in /inbox. Approvals are never muted. ${mark(`/inbox unmute ${q.source}`)} undoes it.` : `✔ unmuted ${q.source}`, 'ok');
3960
+ }
3180
3961
  const pick = (n) => { const it = top[n - 1]; if (!it) say(`No item ${n}: the list has ${top.length}. ${mark('/inbox')} shows it.`, 'warn'); return it; };
3181
- if (q.kind === 'list') { for (const l of inboxTable(items, Date.now(), cols)) say(l.text, l.tone === 'accent' ? 'warn' : l.tone); return undefined; }
3182
- if (q.kind === 'read') {
3183
- const targets = q.all ? top.filter((i) => !i.is_read) : [pick(q.n)].filter(Boolean);
3184
- if (!targets.length) return q.all ? say('Nothing unread.', 'dim') : undefined;
3185
- let ok = 0; const bad = [];
3186
- for (const it of targets) { try { await api.inboxMarkRead(it.id, true); ok++; } catch (e) { bad.push(e?.message || 'failed'); } }
3187
- if (bad.length) say(`Marked ${ok} read; ${bad.length} failed (${clip(bad[0], 60)}).`, 'warn'); else say(`✔ marked ${ok} read`, 'ok');
3188
- refreshInboxBar();
3962
+ if (q.kind === 'list') { for (const l of inboxTable(items, Date.now(), cols, 10, inboxMutes())) say(l.text, l.tone === 'accent' ? 'warn' : l.tone); return undefined; }
3963
+ if (q.kind === 'read' || q.kind === 'unread') {
3964
+ const markRead = q.kind === 'read';
3965
+ let targets;
3966
+ if (q.all) { // "all" is ALL unread, paged through the API, not just the 10 listed
3967
+ let every; try { every = api.inboxAllItems ? await api.inboxAllItems() : await api.inboxItems({ limit: 100 }); } catch (e) { return dispatch({ type: 'error', text: `Could not read the whole inbox (${e.message}). Nothing was marked ${q.kind}.` }); }
3968
+ targets = markRead ? unreadItems(every) : readItems(every);
3969
+ } else targets = (q.ns || [q.n]).map(pick).filter(Boolean);
3970
+ if (!targets.length) return q.all ? say(markRead ? 'Nothing unread.' : 'Nothing read to mark unread.', 'dim') : undefined;
3971
+ let ok = 0; const bad = []; let next = 0;
3972
+ const worker = async () => { while (next < targets.length) { const it = targets[next++]; try { await api.inboxMarkRead(it.id, markRead); ok++; } catch (e) { bad.push(e?.message || 'failed'); } } };
3973
+ await Promise.all(Array.from({ length: Math.min(5, targets.length) }, worker));
3974
+ if (bad.length) say(`Marked ${ok} ${q.kind}; ${bad.length} failed (${clip(bad[0], 60)}).`, 'warn'); else say(`✔ marked ${ok} ${q.kind}`, 'ok');
3975
+ refreshInboxBar({ force: true });
3189
3976
  return undefined;
3190
3977
  }
3191
3978
  const it = pick(q.n); if (!it) return undefined;
@@ -3195,21 +3982,52 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3195
3982
  return command(at >= 0 ? `/approvals ${at + 1}` : '/approvals');
3196
3983
  }
3197
3984
  let full = it; try { full = (await api.inboxItem(it.id)) || it; } catch (e) { say(`Could not load the full item (${clip(e?.message || 'failed', 60)}); showing the list row.`, 'warn'); }
3198
- for (const l of inboxDetail(full)) say(l.text, l.tone === 'accent' ? 'ok' : l.tone === 'text' ? undefined : l.tone);
3199
- if (isApprovalItem(full)) say(`This is an approval: ${mark(`/inbox open ${q.n}`)} takes you to it.`, 'dim');
3200
- refreshInboxBar();
3985
+ for (const l of inboxDetail(full, Date.now(), q.n)) say(l.text, l.tone === 'accent' ? 'ok' : l.tone === 'text' ? undefined : l.tone);
3986
+ refreshInboxBar({ force: true });
3987
+ return undefined;
3988
+ }
3989
+ case '/mail': { // TUI-MAIL: triaged InboxQ mail, read only (same reads as `quox mail list`)
3990
+ if (!api.mailMessages) return say('Mail is not available in this mode.', 'warn');
3991
+ const q = parseMail(restArg);
3992
+ if (q.kind === 'usage') return say(MAIL_USAGE, 'warn');
3993
+ let list;
3994
+ try { list = mailNewestFirst(await api.mailMessages({ limit: 30, zone: q.zone })).slice(0, 10); }
3995
+ catch (e) { return dispatch({ type: 'error', text: `Could not read your mail (${e.message}). That does not mean it is empty; try again or run: quox mail list` }); }
3996
+ if (!list.length && !q.zone) {
3997
+ let accts = null; try { accts = await api.mailAccounts(); } catch { /* unknown: fall through to the plain empty line */ }
3998
+ if (accts && !accts.length) { for (const l of noMailboxLines()) say(l.text, l.tone); return undefined; }
3999
+ }
4000
+ if (q.kind === 'list') { for (const l of mailTable(list, Date.now(), cols, 10)) say(l.text, l.tone === 'accent' ? 'warn' : l.tone); return undefined; }
4001
+ const m = list[q.n - 1]; if (!m) return say(`No message ${q.n}: the list has ${list.length}. ${mark('/mail')} shows it.`, 'warn');
4002
+ let full = m; try { full = (await api.mailMessage(m.id)) || m; } catch (e) { say(`Could not load the full message (${clip(e?.message || 'failed', 60)}); showing the list row.`, 'warn'); }
4003
+ for (const l of mailDetail(full, Date.now(), q.n)) say(l.text, l.tone === 'accent' ? 'ok' : l.tone === 'text' ? undefined : l.tone);
3201
4004
  return undefined;
3202
4005
  }
3203
4006
  case '/approvals': {
3204
4007
  if (/^help$/i.test(restArg.trim())) { // the syntax lives here, not above every list
3205
- 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')})`);
4008
+ say(`${mark('/approve N')} ${mark('/deny N <reason>')} ${mark('/approvals N')} detail ${mark('/approvals walkthrough')} one at a time ${mark('/approvals mine|agents|old|<agent>')} filter ${mark('/approvals auto on|off')} open new ones by themselves (privileged: ${mark('/approve N confirm')})`);
3206
4009
  return undefined;
3207
4010
  }
4011
+ if (/^auto\b/i.test(restArg.trim())) { // APPROVALS-AUTO: the real verb behind "/approvals instant-interrupt"
4012
+ const a = parseApprovalsArg(restArg);
4013
+ if (a.kind !== 'auto') return say(`Unknown option "${restArg.trim()}" for /approvals. Use ${mark('/approvals auto on')} or ${mark('/approvals auto off')}.`, 'warn');
4014
+ const st = AO.current.st;
4015
+ if (a.set === null) return say(`New approvals open by themselves: ${st.enabled ? 'on' : 'off'}. ${mark(`/approvals auto ${st.enabled ? 'off' : 'on'}`)} changes it.`);
4016
+ st.enabled = a.set;
4017
+ const saved = api.saveSetting ? api.saveSetting('approvalsAuto', a.set) : false;
4018
+ return say(a.set ? `✔ New approvals and questions now open by themselves as a card (after your draft or turn, never mid-typing). Nothing is ever answered for you.${saved ? '' : ' (Not saved to this profile: this window only.)'}` : `✔ New approvals no longer open by themselves; the ⏸ line and ${mark('/approvals')} still show them.${saved ? '' : ' (Not saved to this profile: this window only.)'}`, 'ok');
4019
+ }
3208
4020
  let items;
3209
- try { items = await api.approvals(); }
3210
- 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` }); }
3211
4021
  let stats = null; let statsErr = null;
3212
- if (api.approvalStats) { try { stats = await api.approvalStats(); } catch (e) { statsErr = e.message; } }
4022
+ // the list and the org-wide count are independent reads: ask together (the first decision waited on both in turn)
4023
+ const statsP = api.approvalStats ? api.approvalStats().then((v) => { stats = v; }, (e) => { statsErr = e.message; }) : Promise.resolve();
4024
+ // approvals waiting in the user's OTHER orgs: a count and the switch command, never their contents
4025
+ let moreOrgs = [];
4026
+ const moreP = api.otherOrgApprovals ? api.otherOrgApprovals().then((v) => { moreOrgs = crossOrgTuiLines(v); }, () => {}) : Promise.resolve();
4027
+ try { items = await api.approvals(); }
4028
+ catch (e) { await statsP; return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}). That does not mean nothing is waiting; try again or run: quox inbox list` }); }
4029
+ await Promise.all([statsP, moreP]);
4030
+ const sayMoreOrgs = () => { for (const l of moreOrgs) say(l, 'warn'); };
3213
4031
  const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
3214
4032
  const groups = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds });
3215
4033
  const flat = groups.flatMap(g => g.items.map(x => x.item));
@@ -3217,11 +4035,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3217
4035
  R.current.shownIds = flat.map(x => x.id);
3218
4036
  dispatch({ type: 'notify', patch: { approvals: flat.length, error: null } });
3219
4037
  const hidden = stats ? Math.max(0, stats.awaiting - flat.length) : 0;
3220
- if (!flat.length && !hidden && !statsErr) return say('Nothing waiting for you.', 'ok');
3221
4038
  const fArg = restArg.trim();
4039
+ { const pk = parseApprovalsArg(fArg, { items: flat }); if (pk.kind === 'unknown') return say(`Unknown option "${fArg}" for /approvals. Options: ${APPROVALS_OPTIONS}.${/instant|interrupt|pop|auto/i.test(fArg) ? ` To have new ones open by themselves: ${mark('/approvals auto on')}.` : ''}`, 'warn'); }
4040
+ if (!flat.length && !hidden && !statsErr) { say('Nothing waiting for you.', 'ok'); sayMoreOrgs(); return undefined; }
3222
4041
  if (/^walkthrough$/i.test(fArg)) { // plain approvals walk as cards too, after any session questions
3223
4042
  const plain = flat.filter(x => !isDecision(x));
3224
4043
  if (plain.length && plain.length === flat.length) say(`No session questions; ${plain.length} approval${plain.length === 1 ? '' : 's'} waiting, starting with the first.`, 'warn');
4044
+ sayMoreOrgs();
3225
4045
  return walkStart(flat.map(approvalAsWalkItem), { fresh: true });
3226
4046
  }
3227
4047
  if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
@@ -3258,6 +4078,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3258
4078
  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');
3259
4079
  if (agentLogin) offerSwitch(); // one keypress instead of quit and relaunch
3260
4080
  }
4081
+ sayMoreOrgs();
3261
4082
  if (statsErr) say(`Could not check the org-wide approval count (${statsErr}); the list above is what the inbox returned.`, 'warn');
3262
4083
  return undefined;
3263
4084
  }
@@ -3295,12 +4116,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3295
4116
  return say(m.text, m.tone || undefined);
3296
4117
  }
3297
4118
  case '/sleep': { // owner typed /sleep first (2026-10-16); it now MEANS sleeping: sessions stop waiting on approvals
3298
- const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: 'sleeping' };
3299
- try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
3300
- const pr = await setPresence('sleeping');
3301
- if (!pr.ok) presenceFail(pr);
4119
+ await sleepNow();
3302
4120
  return command('/dragon sleep');
3303
4121
  }
4122
+ case '/broadcast-active': return command(`/broadcast --active${restArg ? ` ${restArg}` : ''}`);
3304
4123
  case '/roar': return command(`/broadcast${restArg ? ` ${restArg}` : ''}`);
3305
4124
  case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
3306
4125
  case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
@@ -3334,6 +4153,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3334
4153
  if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
3335
4154
  await SUC.current.authenticate(); bump(n => n + 1); return undefined;
3336
4155
  }
4156
+ case '/verify-email': return SUC.current.verifyEmail(restArg); // the TUI front of `quox admin users verify-email`: preview, y/N, hidden PIN card, same endpoint
3337
4157
  case '/override': {
3338
4158
  const res = await SUC.current.override(restArg);
3339
4159
  if (res?.override_id && res.enforced) R.current.override = { id: res.override_id, until: Date.parse(res.expires_at) || Date.now() + 300000 };
@@ -3347,7 +4167,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3347
4167
  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` }); } }
3348
4168
  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);
3349
4169
  if (needs.length === 1) authArg = String(needs[0]);
3350
- 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');
4170
+ else if (needs.length) return say(`${needs.length} items need your PIN, so say which: ${needs.map(k => mark(`/authorise ${k}`)).join(' ')}`, 'warn');
4171
+ else if (li.length === 1) authArg = '1'; // DS-010: one thing is waiting; the server decides if it needs the PIN, and /approve asks for it
4172
+ else if (li.length) return say(`${li.length} items are waiting and none is marked as needing a PIN up front, so say which to approve with your PIN: ${li.slice(0, 5).map((_, k) => mark(`/authorise ${k + 1}`)).join(' ')}${li.length > 5 ? ' ...' : ''} (the server asks for the PIN on anything an agent raised)`, 'warn');
4173
+ else return say(`Nothing is waiting for your approval, so there is nothing to authorise. ${mark('/approvals')} lists what is open. /authenticate opens a human-present window ahead of time.`, 'warn');
3351
4174
  }
3352
4175
  R.current.authorising = true; // /approve's answer() asks for the PIN first when there is no live step-up
3353
4176
  try { await command(`/approve ${authArg}`); } finally { R.current.authorising = false; }
@@ -3396,7 +4219,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3396
4219
  const okIds = []; const failedK = [];
3397
4220
  for (const k of pa.ns) {
3398
4221
  const x = list[k - 1];
3399
- const go = () => api.answerDecision(x.id, keyOf(k), { note: pa.note || null, stepUp: SU.current.headers('approve'), bulk: bulkAns });
4222
+ const go = () => { ownerActed('answer'); return api.answerDecision(x.id, keyOf(k), { note: pa.note || null, stepUp: SU.current.headers('approve'), bulk: bulkAns }); };
3400
4223
  try {
3401
4224
  try { await go(); }
3402
4225
  catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
@@ -3419,7 +4242,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3419
4242
  }
3420
4243
  const it = list[pa.n - 1];
3421
4244
  if (!it) return say(`No such item: [${pa.n}] (${list.length} listed).`, 'warn');
3422
- 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');
4245
+ if (!isDecision(it)) { // DS-010 / #1191: a yes or no on an approval goes to /approve or /deny, which asks for the PIN itself
4246
+ if (pa.key === 'y') return command(`/approve ${pa.n}`);
4247
+ if (pa.key === 'n') return command(`/deny ${pa.n}${pa.note ? ` ${pa.note}` : ''}`);
4248
+ return say(`[${pa.n}] is an approval, not a question. Use ${mark(`/approve ${pa.n}`)} or ${mark(`/deny ${pa.n} <reason>`)}.`, 'warn');
4249
+ }
3423
4250
  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');
3424
4251
  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; }
3425
4252
  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
@@ -3428,7 +4255,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3428
4255
  const subject = { line1: `answer ${String(it.title || "")}`, raisedBy: it.payload.session || null, risk: 'high' };
3429
4256
  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; } };
3430
4257
  if (gate && !SU.current.token('approve')) await askPin();
3431
- const go = () => api.answerDecision(it.id, pa.key, { note: pa.note || null, stepUp: SU.current.headers('approve') });
4258
+ const go = () => { ownerActed('answer'); return api.answerDecision(it.id, pa.key, { note: pa.note || null, stepUp: SU.current.headers('approve') }); };
3432
4259
  try { await go(); }
3433
4260
  catch (e) {
3434
4261
  if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
@@ -3493,12 +4320,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3493
4320
  if (!targets.length) return say(`Nothing waiting from "${agent}". ${mark('/approvals agents')} shows who is asking.`, 'warn');
3494
4321
  }
3495
4322
  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 });
3496
- const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = [];
4323
+ const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = []; const heldDel = [];
3497
4324
  const heldDecisions = []; const done = []; const heldPriv = []; const failed = []; const missing = []; const gone = [];
3498
4325
  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
3499
4326
  for (const k of targets) {
3500
4327
  const it = list[k - 1];
3501
4328
  if (!it) { missing.push(k); continue; }
4329
+ if (verb === 'approve' && isDestructive(it)) { // a delete is never part of a bulk yes, and a single one needs its name typed
4330
+ if (bulkish) { heldDel.push(k); continue; }
4331
+ if (R.current.delBypass !== it.id) {
4332
+ startDelConfirm(destructiveObject(it), async () => { R.current.delBypass = it.id; try { const n2 = S.current.inbox.findIndex(x => x.id === it.id) + 1; await command(`${cmd} ${n2 || k}`); } finally { R.current.delBypass = null; } });
4333
+ continue;
4334
+ }
4335
+ }
3502
4336
  if (isDecision(it)) {
3503
4337
  if (all || agent) { heldDecisions.push(k); continue; } // a bulk approve never answers a human question for you
3504
4338
  if (verb === 'approve' && bulkish && isHeldFromBulk(it)) { heldRisky.push(k); continue; } // risky asks are never part of a bulk yes
@@ -3508,9 +4342,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3508
4342
  if (verb === 'approve' && privOf(it) && !(confirm && !all && !agent)) { heldPriv.push(k); continue; }
3509
4343
  try {
3510
4344
  const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
3511
- 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) });
4345
+ 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, consent: bulkish || targets.length > 1 ? null : consentOf(it) }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
3512
4346
  if (r?.gone) gone.push(k); else if (r?.ok) { done.push(k); R.current.lastAnswered = it.id; }
3513
- } catch (e) { failed.push(`[${k}] ${e.message}`); }
4347
+ } catch (e) {
4348
+ failed.push(`[${k}] ${e.message}`);
4349
+ if (e?.stepUpDeclined) { const rest = targets.slice(targets.indexOf(k) + 1).filter(j => list[j - 1]); if (rest.length) failed.push(`${rest.map(j => `[${j}]`).join(' ')} not tried: the PIN was not given, so nothing more was sent`); break; } // DS-010: one PIN ask per batch, a "no" ends it
4350
+ }
3514
4351
  }
3515
4352
  if (done.length || gone.length || failed.length || missing.length) say(' ');
3516
4353
  for (const k of done) say(`${verb === 'approve' ? '✔ approved' : '✔ denied'} ${label(k)}${reason ? ` (reason: ${clip(reason, 40)})` : ''}`, 'ok');
@@ -3531,13 +4368,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3531
4368
  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');
3532
4369
  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
3533
4370
  for (const k of ynHeld) say(` [${k}] ${list[k - 1].title || list[k - 1].id}`, 'dim');
3534
- 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 };
4371
+ R.current.pendingGrant = { cmd, verb, items: ynHeld.map(k => list[k - 1]), ks: ynHeld, decisions: true, hi: 0, at: Date.now(), thenWalk: walkKs.length ? walkKs.map(k => approvalAsWalkItem(list[k - 1])) : null };
3535
4372
  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' });
3536
4373
  }
3537
4374
  if (walkKs.length) {
3538
4375
  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');
3539
- if (!ynHeld.length) walkStart(walkKs.map(k => list[k - 1])); // with a bulk card up, the walkthrough starts once the card is answered
4376
+ if (!ynHeld.length) walkStart(walkKs.map(k => approvalAsWalkItem(list[k - 1]))); // with a bulk card up, the walkthrough starts once the card is answered
3540
4377
  }
4378
+ if (heldDel.length) say(`${heldDel.length} delete${heldDel.length === 1 ? '' : 's'} left for you to confirm one by one: ${heldDel.map(k => `${cmd} ${k}`).join(', ')}`, 'warn');
3541
4379
  if (heldPriv.length && (all || agent)) say(`${heldPriv.length} privileged left: ${heldPriv.map(k => `${cmd} ${k} confirm`).join(', ')}`, 'warn');
3542
4380
  else if (heldPriv.length) { // ask inline instead of sending the user to a second command
3543
4381
  R.current.pendingGrant = { cmd, verb, items: heldPriv.map(k => list[k - 1]), at: Date.now() };
@@ -3599,10 +4437,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3599
4437
  function openAskStates() {
3600
4438
  const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
3601
4439
  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
3602
- return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.sayLastAsk, R.current.profileAsk, R.current.cliAsk, R.current.installAsk, R.current.updateAsk, R.current.orgPending, R.current.hatPending, HW.current.card(), RW.current.walk.card(), R.current.suggestAsk && !R.current.suggestLine ? R.current.suggestAsk : null, R.current.objectiveAsk, R.current.handoffAsk, R.current.askQuox, R.current.strayAsk, R.current.planAsk, SC.current.pendingFile, SC.current.reattach, CH.current.pending, SPN.current.pending].map(fresh)].filter(Boolean);
4440
+ return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.sayLastAsk, R.current.profileAsk, R.current.loginAsk, R.current.neverAsk, R.current.dndAsk, R.current.secretAsk, R.current.limitedAsk, R.current.cliAsk, R.current.deckAsk, R.current.installAsk, R.current.updateAsk, R.current.orgPending, R.current.hatPending, HW.current.card(), RW.current.walk.card(), R.current.suggestAsk && !R.current.suggestLine ? R.current.suggestAsk : null, R.current.objectiveAsk, R.current.handoffAsk, R.current.askQuox, R.current.strayAsk, R.current.planAsk, SC.current.pendingFile, SC.current.reattach, CH.current.pending, SPN.current.pending].map(fresh)].filter(Boolean);
3603
4441
  }
3604
- const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || UW.current.pending?.() || runAskOpen();
3605
- function touchAsks() { for (const x of openAskStates()) x.at = Date.now(); KC.current.touch?.(); UW.current.touch?.(); }
4442
+ const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || SUC.current.pending() || UW.current.pending?.() || runAskOpen();
4443
+ function touchAsks() { for (const x of openAskStates()) x.at = Date.now(); KC.current.touch?.(); SUC.current.touch(); UW.current.touch?.(); }
3606
4444
  // ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): each live ask card is
3607
4445
  // bound to the open state it asks about; once that state is no longer open the card goes and ONE line stays:
3608
4446
  // "✔ Wake it now? yes (00:44)", "⌛ ... expired, not answered", "↷ ... no longer open". Run requests likewise.
@@ -3630,12 +4468,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3630
4468
  }
3631
4469
  const st = bind.get(cd.id);
3632
4470
  if (askStateOpen(st)) continue;
4471
+ if (st && typeof st === 'object' && st.walk && st.walkWhy !== 'next' && st.walkWhy !== 'done') { // a walkthrough card that did not close normally: its line comes from the server
4472
+ if (!st.walkWhy) { st.walkWhy = 'lapsed'; st.at = now; walkLapsed(st); } // TUI-CARD-SYNC: left open past its window; never "expired" before the server says so
4473
+ if (st.settleText == null && now - (st.at ?? now) < 15000) continue; // still checking
4474
+ bind.delete(cd.id); takeAnswer();
4475
+ if (st.settleText) dispatch({ type: 'ask.settle', id: cd.id, text: st.settleText, tone: st.settleTone || 'plain' });
4476
+ else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
4477
+ continue;
4478
+ }
3633
4479
  bind.delete(cd.id);
3634
4480
  const expired = st && typeof st === 'object' ? now - (st.at ?? 0) >= ASK_TTL : false;
4481
+ if (expired && st === SC.current.reattach && st.slot) R.current.declinedSlot = st.slot; // an unanswered reattach counts as a decline across the auto-restart of this window
3635
4482
  if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
3636
4483
  takeAnswer();
3637
- if (st.walkWhy === 'stop') dispatch({ type: 'ask.settle', id: cd.id, text: 'The question closed before you answered.', tone: 'plain' });
3638
- else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
4484
+ dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
3639
4485
  continue;
3640
4486
  }
3641
4487
  const outcome = expired ? { kind: 'expired' } : takeAnswer() || { kind: st === 'any' || st === 'kc' || st === 'uw' ? 'expired' : 'closed' };
@@ -3669,6 +4515,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3669
4515
  if (now - K.current.lastPasteAt < PASTE_GUARD_MS) return; // post-paste guard
3670
4516
  if (now - K.current.cardGoneAt < CARD_GONE_MS) return; // keys meant for the card must not send a turn
3671
4517
  let buf = B.current;
4518
+ if (R.current.delConfirm) { // DESTRUCTIVE-CONFIRM: this line is the typed name (a slash command leaves the step)
4519
+ const t = buf.text.trim();
4520
+ if (!t.startsWith('/')) { B.current = initialBuffer(); bump(n => n + 1); delConfirmLine(t); return; }
4521
+ R.current.delConfirm = null;
4522
+ }
4523
+ if (!buf.text.trim() && R.current.secretAsk) { B.current = { ...initialBuffer(), text: 'y', cursor: 1 }; buf = B.current; } // SECRET-GUARD: Enter alone keeps the credentials out (never "send anyway")
3672
4524
  // y/N/c: Enter alone answers an open ask "no" (the default), as typing n does.
3673
4525
  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"
3674
4526
  if (!buf.text.trim() && !R.current.denyReason && !S.current.turn && askOpen()) { B.current = { ...initialBuffer(), text: 'n', cursor: 1 }; buf = B.current; }
@@ -3684,7 +4536,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3684
4536
  if (!chk.ok) { B.current = { ...chk.buffer, undo: buf.undo, redo: buf.redo }; say(chk.note, 'warn'); bump(n => n + 1); return; }
3685
4537
  let text = chk.text.trim();
3686
4538
  const wrapped = joinWrappedSlash(text); // a wrapped paste of "/say slot long text": one command, not a refusal
3687
- if (wrapped) { text = wrapped.text; say(`joined ${wrapped.lines} lines`, 'dim'); }
4539
+ if (wrapped && !GRIPE_LINE.test(text)) { text = wrapped.text; say(`joined ${wrapped.lines} lines`, 'dim'); }
4540
+ if (SUC.current.pending() && !text.startsWith('/')) { // the /verify-email y/N preview owns the next plain line: never chat, never a turn, never history (a PIN typed early must go nowhere)
4541
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.histIdx = -1;
4542
+ if (/^(y(es)?|no?)$/i.test(text)) SUC.current.answer(text);
4543
+ else { SUC.current.touch(); say('Answer y or n for /verify-email (n cancels). Nothing was sent. The PIN is asked for in a private card after y, never here.', 'warn'); }
4544
+ bump(n => n + 1); return;
4545
+ }
3688
4546
  const keyHit = looksLikeKey(text);
3689
4547
  if (keyHit) { B.current = initialBuffer(); R.current.confirmMulti = null; say(keyWarning(keyHit), 'warn'); return; } // never sent, never in history
3690
4548
  if (text.startsWith('!') && text.includes('\n')) { // owner 2026-10-15: a wrapped `!git ... push origin\nHEAD:main` ran as two commands
@@ -3693,6 +4551,29 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3693
4551
  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');
3694
4552
  bump(n => n + 1); return;
3695
4553
  }
4554
+ // QUOXON-SECRET-GUARD: credential-shaped text is never sent, saved or remembered without an explicit "send anyway"
4555
+ const sa = R.current.secretAsk;
4556
+ if (sa) {
4557
+ const ans = askAnswer(text);
4558
+ if (ans === 'yes' || ans === 'no') {
4559
+ R.current.secretAsk = null; R.current.askAnswer = { kind: ans, at: Date.now() }; queueMicrotask(() => reconcileAsks());
4560
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.histIdx = -1;
4561
+ if (ans === 'yes' && Date.now() - sa.at < 120000) { say('Not sent. Nothing was saved anywhere.', 'ok'); bump(n => n + 1); return; }
4562
+ if (ans === 'no') { R.current.secretOk = sa.text; if (sa.text.startsWith('/') && sa.text.includes('\n')) R.current.confirmMulti = true; B.current = { ...initialBuffer(), text: sa.text, cursor: sa.text.length }; bump(n => n + 1); onEnter(now); return; }
4563
+ say('That question expired, so nothing was sent. Paste it again if you still mean to.', 'dim'); bump(n => n + 1); return;
4564
+ }
4565
+ R.current.secretAsk = null; // anything else moves on; the new line is checked on its own below
4566
+ }
4567
+ if (R.current.secretOk === text) R.current.secretOk = null;
4568
+ else {
4569
+ R.current.secretOk = null;
4570
+ const kinds = secretKinds(text);
4571
+ if (kinds.length) {
4572
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.secretAsk = { at: Date.now(), text };
4573
+ say(askText(`This looks like credentials (${kinds.join(', ')}). Don't send it?`, { keys: 'y keeps it out (recommended) · n send anyway', action: 'discard it, nothing is sent or saved', title: 'Credentials in your message' }), 'ask');
4574
+ bump(n => n + 1); return;
4575
+ }
4576
+ }
3696
4577
  const bang = parseBang(text);
3697
4578
  if (bang) { // QUOXON-FW8: the user's own command on their own machine; never a turn, never the server
3698
4579
  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; }
@@ -3700,16 +4581,61 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3700
4581
  echoCmd(text); bangCommand(bang, { echoed: true }); bump(n => n + 1); return;
3701
4582
  }
3702
4583
  const s = S.current;
3703
- const isCommand = text.startsWith('/') && !text.includes('\n');
4584
+ const isCommand = isOneLineCommand(text);
3704
4585
  const ws = R.current.wokeSwallowed; R.current.wokeSwallowed = null;
3705
4586
  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
3706
4587
  B.current = { ...initialBuffer(), text, cursor: text.length };
3707
4588
  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;
3708
4589
  }
4590
+ if (R.current.dndAsk) { // DND offers: "Turn on do not disturb until morning?" and "Good morning. Turn off do not disturb?"
4591
+ const a = R.current.dndAsk;
4592
+ const ans = askAnswer(text);
4593
+ if (ans === 'yes' || ans === 'no') {
4594
+ R.current.dndAsk = null; R.current.askAnswer = { kind: ans, at: Date.now() }; queueMicrotask(() => reconcileAsks());
4595
+ B.current = initialBuffer(); R.current.histIdx = -1;
4596
+ if (ans === 'yes' && Date.now() - a.at < 600000) { (a.off ? dndOff() : dndOn()).catch(() => {}); return; }
4597
+ say(ans === 'yes' ? 'That offer expired. /dnd on turns it on.' : (a.off ? 'Staying on do not disturb. /dnd off ends it.' : 'Not now. /dnd on whenever you like.'), 'dim'); bump(n => n + 1); return;
4598
+ }
4599
+ if (isCommand) R.current.dndAsk = null;
4600
+ }
4601
+ if (R.current.neverAsk) { // ONBOARD-P2: "Quox at <url> has never answered. Connect to a different server? y/N", then the address
4602
+ const a = R.current.neverAsk;
4603
+ if (a.stage === 'offer') {
4604
+ const ans = askAnswer(text);
4605
+ if (ans === 'yes') {
4606
+ R.current.neverAsk = { ...a, stage: 'url', at: Date.now() };
4607
+ say(askText('Quox server address (for example https://192.0.2.10:3000)?', { keys: 'Enter or n cancels', action: 'check it is a Quox, then sign you in (the certificate is shown first)' }), 'ask'); return;
4608
+ }
4609
+ if (ans === 'no' || ans === 'chat') {
4610
+ R.current.neverAsk = null;
4611
+ say('Still trying quietly. To connect elsewhere any time, run: quox connect <url>', 'dim');
4612
+ if (healthRecheck) startupRecheck.current?.(0);
4613
+ return;
4614
+ }
4615
+ if (ans === 'command') R.current.neverAsk = null;
4616
+ } else {
4617
+ const t = text.trim();
4618
+ R.current.neverAsk = null;
4619
+ if (!t || /^no?$/i.test(t)) { say('Not connecting. To connect elsewhere any time, run: quox connect <url>', 'dim'); if (healthRecheck) startupRecheck.current?.(0); return; }
4620
+ if (!/^https?:\/\//i.test(t)) { say('That is not a web address. Run: quox connect <url> when you have it.', 'warn'); return; }
4621
+ say(`Connecting to ${t}…`, 'dim');
4622
+ onConnect(t); exit(); return;
4623
+ }
4624
+ }
4625
+ if (R.current.loginAsk) { // "Signed out of X. Sign in now? y/N": y is exactly /login; n or Enter alone dismisses; other chat falls to the signed-out refusal below
4626
+ const a = R.current.loginAsk;
4627
+ const ans = askAnswer(text);
4628
+ if (ans === 'yes' || ans === 'no') {
4629
+ R.current.loginAsk = null; R.current.askAnswer = { kind: ans === 'yes' ? 'yes' : 'no', at: Date.now() }; queueMicrotask(() => reconcileAsks());
4630
+ if (ans === 'yes' && Date.now() - a.at < 120000) { echoCmd('/login'); command('/login'); return; }
4631
+ say('Not now. /login signs in whenever you like.', 'dim'); return;
4632
+ }
4633
+ if (isCommand) R.current.loginAsk = null;
4634
+ }
3709
4635
  if (blocked && !isCommand) { // Z2H-R4: clear the box (else the next /login is appended and refused); Up recalls the message
3710
4636
  remember(text); R.current.histIdx = -1; B.current = initialBuffer(); say(blocked, 'warn'); bump(n => n + 1); return;
3711
4637
  }
3712
- if (text.startsWith('/') && text.includes('\n') && !R.current.confirmMulti) {
4638
+ if (text.startsWith('/') && text.includes('\n') && !isOneLineCommand(text) && !R.current.confirmMulti) {
3713
4639
  // commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter.
3714
4640
  // The second Enter sends it even after edits (R3 D3: requiring a byte-identical draft swallowed every later Enter).
3715
4641
  R.current.confirmMulti = true;
@@ -3737,13 +4663,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3737
4663
  let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
3738
4664
  if (askOpen() && !R.current.noteAsk) {
3739
4665
  const o = askOutcomeOf(text, { runWord: runAskOpen() }) || (askAnswer(text) === 'chat' && !S.current.turn && !R.current.pendingWalk && !isAskClarification(text) ? { kind: 'dismissed' } : null);
3740
- const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'sayLastAsk', 'profileAsk', 'cliAsk', 'installAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
4666
+ const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'sayLastAsk', 'profileAsk', 'loginAsk', 'neverAsk', 'dndAsk', 'secretAsk', 'limitedAsk', 'cliAsk', 'deckAsk', 'installAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
3741
4667
  const foreign = KC.current.pending?.() || UW.current.pending?.() || HW.current.card() || SC.current.pendingFile || SC.current.reattach || CH.current.pending || SPN.current.pending || runAskOpen();
3742
4668
  if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
3743
4669
  else if (o) {
3744
4670
  askDismissed = o.kind === 'dismissed';
3745
4671
  R.current.askAnswer = { ...o, at: Date.now() }; queueMicrotask(() => reconcileAsks());
3746
- 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
4672
+ if (askDismissed) { if (R.current.limitedAsk) { R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } }); } for (const k of OWN) R.current[k] = null; } // the offer is gone: the line below is plain chat, not an answer to anything
3747
4673
  }
3748
4674
  }
3749
4675
  // y/N/c: c or any other free text keeps the open ask and asks Quox about it first (never a silent cancel).
@@ -3755,8 +4681,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3755
4681
  say('Not sent. Your answer and note are back in the composer; press Enter to send it, or edit it first.', 'warn');
3756
4682
  return;
3757
4683
  }
4684
+ if (!R.current.pendingWalk && !askOpen() && !headCard(S.current) && strayAnswer(text, { queued: AO.current.st.queue.length, walkOpen: false, noticeAt: AO.current.noticeAt, now: Date.now() })) { autoStray(text); return; } // APPROVALS-AUTO: the stray y
3758
4685
  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
3759
4686
  if (!askDismissed && !S.current.turn && !noAiNow() && askAnswer(text) === 'chat' && askOpen() && !(runAskOpen() && /^run$/i.test(text.trim()))) { chatAboutAsk(text); return; }
4687
+ if (SUC.current.answer(text)) return; // y/N after a /verify-email preview
3760
4688
  if (KC.current.answer(text)) return; // y/n after a /watch, /automate or /crystallize preview card
3761
4689
  if (UW.current.answer(text)) return; // y/n after a /undo or /wish card
3762
4690
  if (HW.current.active() && HW.current.answer(text)) return; // CUSTOM-HATS: the next line of a /hat new|edit|delete walk
@@ -3770,7 +4698,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3770
4698
  if (Date.now() - g.at < 120000 && !text.startsWith('/')) {
3771
4699
  if (/^y(es)?$/i.test(text.trim())) {
3772
4700
  (async () => {
3773
- if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it)); // defence: a risky ask never rides the bulk card
4701
+ if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it) && !isDestructive(it)); // defence: a risky ask or a delete never rides the bulk card
3774
4702
  if (g.decisions && g.hi) { // one PIN for the whole batch; the token then rides on every item
3775
4703
  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' } });
3776
4704
  if (!r.ok) return say(`Not answered: ${declinedText(r.reason)}. Nothing was sent.`, 'warn');
@@ -3817,6 +4745,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3817
4745
  if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
3818
4746
  if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
3819
4747
  }
4748
+ if (R.current.limitedAsk) { // "Switch to <model>? y/N" under the limited banner: y is exactly /model <name>; anything else dismisses it
4749
+ const a = R.current.limitedAsk; R.current.limitedAsk = null;
4750
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { echoCmd(`/model ${a.id}`); command(`/model ${a.id}`); return; }
4751
+ R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } });
4752
+ if (!text.startsWith('/')) { say(`Staying on the current model. /model ${a.id} switches whenever you like.`, 'dim'); return; }
4753
+ }
3820
4754
  if (R.current.sayLastAsk) { // SAY-LAST-TARGET "Send to <slot>? y send · n cancel": y sends the whole text
3821
4755
  const a = R.current.sayLastAsk; R.current.sayLastAsk = null;
3822
4756
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { sendToSlot(a.slot, a.text); return; }
@@ -3842,6 +4776,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3842
4776
  }
3843
4777
  if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
3844
4778
  }
4779
+ if (R.current.deckAsk) { // LAZY PATH: "quoxdeck needs: ... Install now? y/N" (typing y is the authorization; sudo is the user's own)
4780
+ const a = R.current.deckAsk; R.current.deckAsk = null;
4781
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
4782
+ const run = (st) => new Promise((resolve) => {
4783
+ const hold = { deck: true }; R.current.bang = hold;
4784
+ setTimeout(() => {
4785
+ if (R.current.bang === hold) R.current.bang = null;
4786
+ const r = handOverRun({ cmd: st.cmd, env, cwd: process.cwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
4787
+ bump(n => n + 1); resolve({ code: r.error ? 1 : r.code, error: r.error });
4788
+ }, runHandOverDelayMs);
4789
+ });
4790
+ runDeckOffer(a.offer, { run: (st) => st.cmd === `quox ext add quoxdeck` ? Promise.resolve(extAdd('quoxdeck').ok ? { code: 0 } : { code: 1 }) : run(st), say: (t, tone) => say(t, tone || 'dim'), rerun: async () => { echoCmd('/deck on'); command('/deck on'); } }).catch(() => {});
4791
+ return;
4792
+ }
4793
+ if (!text.startsWith('/')) { say(declineText(a.offer), 'dim'); return; }
4794
+ }
3845
4795
  if (R.current.installAsk) { // LAZY-AI-CLI: "Claude Code isn't installed on this server. Install it now? y/N" (typing y is the authorization)
3846
4796
  const a = R.current.installAsk; R.current.installAsk = null;
3847
4797
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { installNow(a); return; }
@@ -3852,6 +4802,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3852
4802
  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; }
3853
4803
  if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
3854
4804
  }
4805
+ if (R.current.consentAsk && !text.startsWith('/')) { // "needs the lawful basis: type it now" after approving a contact-consent card
4806
+ const a = R.current.consentAsk; R.current.consentAsk = null;
4807
+ const basis = text.trim();
4808
+ if (!basis || /^no?$/i.test(basis) || Date.now() - a.at > 600000) { say(`No consent recorded. Contact with ${a.recipient} via ${a.channel} stays denied by default.`, 'dim'); return; }
4809
+ const pr = a.proposed || {};
4810
+ const body = { recipient: a.recipient, channel: a.channel, basis };
4811
+ for (const k of ['purpose', 'quiet_start', 'quiet_end', 'tz_offset_minutes', 'min_interval_minutes', 'max_per_day']) if (pr[k] !== undefined && pr[k] !== null && pr[k] !== '') body[k] = pr[k];
4812
+ (async () => {
4813
+ try {
4814
+ const r = await api.grantContactability(body);
4815
+ if (!r || r.granted !== true) { say(`Consent NOT recorded for ${a.recipient} via ${a.channel}: the server did not confirm it.`, 'warn'); return; }
4816
+ say(`Consent recorded: ${a.recipient} via ${a.channel}, basis "${basis}". Evidence: consent artifact ${r.consent_artifact_id || '(no id returned)'}.`, 'ok');
4817
+ } catch (e) { say(`Consent NOT recorded for ${a.recipient} via ${a.channel} (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.body?.message || e?.message || 'failed'}).`, 'warn'); }
4818
+ })();
4819
+ return;
4820
+ }
3855
4821
  if (R.current.hatPending && !text.startsWith('/')) { // "Put on the NSOC hat? y/N" from /hat: y switches (/hat yes still works)
3856
4822
  const a = R.current.hatPending;
3857
4823
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/hat yes'); return; }
@@ -3894,13 +4860,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3894
4860
  }
3895
4861
  if (SC.current.pendingFile) { // /send found secret-shaped values: "y/N" (send redacted)
3896
4862
  const pf = SC.current.pendingFile; SC.current.pendingFile = null;
3897
- 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; }
4863
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - pf.at < 120000) { sendToSlot(pf.slot, fileMessage({ name: pf.f.name, body: pf.f.redacted, size: Buffer.byteLength(pf.f.redacted) }), { draft: null }); return; }
3898
4864
  if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3899
4865
  }
3900
4866
  if (SC.current.reattach) { // startup "Reattach to slot X? y/N"
3901
4867
  const ra = SC.current.reattach; SC.current.reattach = null;
3902
4868
  if (/^y(es)?$/i.test(text.trim())) { takeSlot(ra.slot, ra.take); return; }
3903
- if (/^no?$/i.test(text.trim())) { say('Left it.', 'dim'); return; }
4869
+ if (/^no?$/i.test(text.trim())) { R.current.declinedSlot = ra.slot; say('Left it.', 'dim'); return; }
3904
4870
  }
3905
4871
  if (SC.current.runAsk) { // SLOT-RUN: "Run it? y/N/c" (or "run" for a dangerous one) on a slot sender's command
3906
4872
  const ra = SC.current.runAsk;
@@ -3914,7 +4880,16 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3914
4880
  }
3915
4881
  { const ls = R.current.listShownAt; R.current.listShownAt = null; // APPROVALS-UX: y/n right after a list (not a card) is not chat to the agent
3916
4882
  if (ls && Date.now() - ls < 600000 && /^(y(es)?|no?)$/i.test(text.trim())) { remember(text); R.current.histIdx = -1; say('Which one? /approve 1 or /approvals 1 to see it.', 'warn'); R.current.listShownAt = ls; return; } }
3917
- if (text.startsWith('/') && !text.includes('\n')) { remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
4883
+ if (isOneLineCommand(text)) { if (/^\/org\s+(switch\s+\S|back\s*$|-\s*$)/i.test(text.trim())) R.current.typedOrg = text.trim(); PL.current.submitted(buf.text); remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
4884
+ if (!SC.current.active && BARE_COMMAND_WORDS.has(text.toLowerCase()) && findEntry('/' + text.toLowerCase())?.kind === 'command') { // owner 2026-10-26 18:22: "clear" went to the model as chat. Offer the command BEFORE anything is sent; n sends it as chat.
4885
+ const line = '/' + text.toLowerCase();
4886
+ remember(text); R.current.histIdx = -1;
4887
+ R.current.strayAsk = { text, command: line, at: Date.now() };
4888
+ ask(`Run ${line}?`, { action: line, reason: `"${text}" is a command word. n sends it to Quox as chat instead.` });
4889
+ return;
4890
+ }
4891
+ const attachSlot = !SC.current.active && !s.turn && api.slots ? parseSlotAttach(text) : null;
4892
+ if (attachSlot) { remember(text); R.current.histIdx = -1; offerSlotAttach(attachSlot, text); return; } // "attach to hitl slot": offer /slot hitl, no model turn
3918
4893
  const roomSend = api.slotSay ? parseRoomSend(text) : null;
3919
4894
  if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
3920
4895
  remember(text); R.current.histIdx = -1;
@@ -4047,7 +5022,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4047
5022
  k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50); k.askEchoTimer.unref?.();
4048
5023
  bump(n => n + 1); return true;
4049
5024
  }
4050
- if (!askOpen() || R.current.pendingWalk || R.current.denyReason || HW.current.active?.()) return false;
5025
+ if (!askOpen() || R.current.pendingWalk || R.current.denyReason || R.current.delConfirm || HW.current.active?.()) return false;
4051
5026
  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; }
4052
5027
  B.current = { ...initialBuffer(), text: ch, cursor: 1 };
4053
5028
  onEnter(now);
@@ -4074,6 +5049,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4074
5049
  const k = K.current;
4075
5050
  const g = k.guard;
4076
5051
  if (R.current.denyReason && Date.now() - R.current.denyReason.at < 120000) return false; // typing the deny reason: y/n are text
5052
+ if (R.current.delConfirm) return false; // typing the name to delete: every key is text
4077
5053
  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)
4078
5054
  const armedAt = Math.max(card.displayedAt ?? card.shownAt ?? 0, g?.since ?? 0);
4079
5055
  const ok = () => approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: k.lastKeyAt, heldKey: g?.heldKey, ch });
@@ -4084,6 +5060,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4084
5060
  return true; // any other key cancels
4085
5061
  }
4086
5062
  const key = ch.toLowerCase();
5063
+ if (signedOutRef.current) { if (key === 'y' && ok()) { echoCmd('/login'); command('/login'); } return key === 'y' || key === 'n' || key === 'c'; } // signed out: the card is read-only, y signs in
5064
+ const burst = batchFor(queueOf(S.current), card); // APPROVAL-BURST: y and n apply to the whole batch, p walks it
5065
+ if (burst && (key === 'y' || key === 'n' || key === 'p')) {
5066
+ if (!ok()) return true;
5067
+ if (key === 'p') walkBatch(burst); else answerBatch(burst, key === 'y' ? 'approve' : 'reject');
5068
+ return true;
5069
+ }
4087
5070
  if (key === 'c' && !S.current.turn && !noAiNow()) { // y/N/c: chat about the approval first; the card stays
4088
5071
  if (!ok()) return true;
4089
5072
  const what = card.what || [card.tool, card.target].filter(Boolean).join(' ') || 'this approval';
@@ -4093,9 +5076,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4093
5076
  if (key !== 'y' && key !== 'n') return false; // ordinary typing: the composer keeps it
4094
5077
  if (!ok()) return true; // too early / key repeat: swallowed, never answered
4095
5078
  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
4096
- if (key === 'n') { // ask why in one line; Enter skips. The card stays until the answer is sent.
5079
+ if (key === 'n') { // ask why in one line; Enter skips, Esc backs out. The card stays until the answer is sent.
4097
5080
  R.current.denyReason = { card, at: Date.now() }; say('Reason? (Enter to skip)', 'warn'); bump(n => n + 1); return true;
4098
5081
  }
5082
+ if (card.destructive) { startDelConfirm(card.destructive, () => answer(card, 'approve')); return true; } // a delete is never one key
4099
5083
  if (card.privileged || card.undescribable) { k.confirm = true; bump(n => n + 1); return true; }
4100
5084
  answer(card, 'approve');
4101
5085
  return true;
@@ -4186,6 +5170,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4186
5170
  if (R.current.askArmed) { R.current.askArmed = false; R.current.dragonTalk = null; say('Dragon ask cancelled.', 'dim'); return; }
4187
5171
  if (HW.current.cancel()) return; // CUSTOM-HATS: Esc ends the hat walk, nothing sent
4188
5172
  if (RW.current.walk.cancel()) return; // ROWS: Esc ends the row walk, nothing saved
5173
+ if (R.current.limitedAsk && !B.current.text) { R.current.limitedAsk = null; R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } }); return; } // Esc says no to the switch offer
4189
5174
  if (R.current.pendingWalk) { walkEnd('Esc'); K.current.lastEscAt = now; return; } // WALK-FIXES: Esc leaves the walkthrough with a summary; the draft stays
4190
5175
  if (now - K.current.lastEscAt < ESC_ESC_MS && B.current.text) {
4191
5176
  if (looksLikeKey(B.current.text)) { edit({ type: 'clear' }); say('Draft cleared. It looked like a key, so it was not kept.'); }
@@ -4247,6 +5232,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4247
5232
  return edit({ type: 'down', width: composerWidth() });
4248
5233
  }
4249
5234
  case 'ctrl-g': {
5235
+ if (isWebPty(env)) { say(SHELL_REFUSED, 'warn'); return; } // QUOXON web PTY: no external editor
4250
5236
  const full = sanitizeForSend({ ...B.current }).text ?? B.current.text;
4251
5237
  mouseTerm(false); // the editor gets a plain terminal (QUOXON-MOUSE), and tracking comes back after
4252
5238
  if (isRawModeSupported) setRawMode(false);
@@ -4281,6 +5267,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4281
5267
  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; }
4282
5268
  if (e.name === 'enter' && autoIdle()) { stopCountdown(); cdLog('restart-now'); doRestart(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4283
5269
  }
5270
+ if (e.type === 'key' && e.name === 'enter' && !AR.current.timer && !AR.current.waiting && AR.current.quietId && AR.current.quietId === AR.current.newId && autoIdle()) { cdLog('restart-now'); autoFire(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the quiet line's Enter: waits for collector /health like the countdown does
4284
5271
  if (R.current.sleep) { // the sleep screen eats the waking key (nothing is lost), except "/": slash commands always work asleep
4285
5272
  if (e.type === 'key' && e.name === 'ctrl-c') { exit(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4286
5273
  if (R.current.sleep.wakeAt != null) { /* already waking: later keys are typed as normal, so "/back" typed in one go is not cut short */ }
@@ -4297,6 +5284,16 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4297
5284
  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)
4298
5285
  if (R.current.rs && (e.type === 'key' || e.type === 'text' || e.type === 'paste')) { rsEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4299
5286
  if (e.type === 'key' && e.name === 'ctrl-r' && !headCard(S.current) && R.current.history.length) { R.current.rs = { saved: B.current, q: '', skip: 0, hit: null }; rsShow(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
5287
+ if (e.type === 'key' && e.name === 'ctrl-]') { // EXT panels: Ctrl+] cycles focus through them
5288
+ if (!DK.current.cycle()) say(DK.current.armed() ? 'No extension has a panel. /ext list' : 'No extension is loaded. /ext list', 'dim');
5289
+ K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue;
5290
+ }
5291
+ if (DK.current.focused() && (e.type === 'text' || (e.type === 'key' && e.name !== 'ctrl-c' && e.name !== 'ctrl-d'))) { // a focused panel has the keys; Esc gives them back
5292
+ let took = false;
5293
+ for (const k of e.type === 'text' ? [...e.text].map(panelKeyName) : [panelKeyName(e)]) took = DK.current.key(k) || took;
5294
+ if (took) { K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
5295
+ }
5296
+ if (e.type === 'key' && e.name === 'escape' && R.current.delConfirm) { cancelDelConfirm(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // Esc backs out of the type-the-name step to the card
4300
5297
  const card = headCard(S.current);
4301
5298
  const g = K.current.guard;
4302
5299
  // any different key ends the "this key was already down" suppression
@@ -4304,6 +5301,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4304
5301
  // Tab (composer empty) puts the card away into the status line until a NEW approval arrives
4305
5302
  // RESTORE-TUI: Tab with a draft focuses the card (the draft is kept, never queued or sent); Esc returns to the draft
4306
5303
  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; }
5304
+ if (card && e.type === 'key' && e.name === 'escape' && R.current.denyReason) { R.current.denyReason = null; B.current = initialBuffer(); say('Deny cancelled. The card is still open.', 'dim'); bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // Esc backs out of the Reason? prompt to y/n
4307
5305
  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; }
4308
5306
  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; }
4309
5307
  if (card && K.current.confirm && e.type === 'key') { K.current.confirm = false; bump(n => n + 1); } // any other key cancels a pending privileged confirm
@@ -4365,6 +5363,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4365
5363
  }, (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'}` }); });
4366
5364
  return () => { live = false; };
4367
5365
  }, []);
5366
+ const startupRecheck = React.useRef(null);
4368
5367
  React.useEffect(() => {
4369
5368
  if (!startup) return undefined;
4370
5369
  let live = true;
@@ -4377,18 +5376,90 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4377
5376
  timer = null;
4378
5377
  let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
4379
5378
  if (!live) return;
4380
- if (hl?.ok) { say('✔ QuoxCORE is answering again', 'accent'); return; }
5379
+ if (hl?.ok) { noteAnswering(); flushOutbox(); return; }
4381
5380
  recheck(Math.min(i + 1, delays.length - 1));
4382
5381
  }, delays[Math.min(i, delays.length - 1)]);
4383
5382
  };
5383
+ // A fresh window that cannot reach QuoxCORE is most likely racing a collector restart: say so (spinner) for the grace
5384
+ // window and only then fall back to "No QuoxCORE running". Connecting inside it says nothing.
5385
+ const graceThenSay = (note) => {
5386
+ const t0 = Date.now(); let tm = null;
5387
+ const spin = (t) => dispatch({ type: 'notify', patch: { spinner: t } });
5388
+ spin('QuoxCORE is restarting, retrying…');
5389
+ const step = async () => {
5390
+ tm = null;
5391
+ let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
5392
+ if (!live) return;
5393
+ if (hl?.ok) { spin(''); return; }
5394
+ if (Date.now() - t0 >= startupGraceMs) { spin(''); say(note, 'warn'); return; }
5395
+ tm = setTimeout(step, startupRetryMs);
5396
+ };
5397
+ tm = setTimeout(step, startupRetryMs);
5398
+ stopGrace = () => { clearTimeout(tm); spin(''); };
5399
+ };
5400
+ let stopGrace = null;
5401
+ startupRecheck.current = recheck;
4384
5402
  Promise.resolve().then(startup).then((note) => {
4385
5403
  if (!live || !note) return;
5404
+ if (neverAnswered && onConnect && /^No QuoxCORE running/.test(note) && !R.current.neverAsk && !R.current.neverAsked) { // ONBOARD-P2: never answered + dead URL = wrong address, not an outage
5405
+ const url = (/ at (\S+)/.exec(note) || [])[1] || 'the configured server';
5406
+ R.current.neverAsked = true; R.current.neverAsk = { stage: 'offer', url, at: Date.now() };
5407
+ say(askText(`Quox at ${url} has never answered from this machine. Connect to a different server?`, { action: 'ask for its address, check it is a Quox, then sign you in' }), 'ask');
5408
+ return;
5409
+ }
5410
+ if (/^No QuoxCORE running/.test(note) && healthRecheck && startupGraceMs > 0) { graceThenSay(note); return; }
4386
5411
  if (slow(note)) { if (startup.said) return; startup.said = true; } // once per session, even if the probe is mounted twice
4387
5412
  say(note, 'warn');
4388
5413
  if (slow(note)) recheck(0);
4389
5414
  }, () => {});
5415
+ return () => { live = false; clearTimeout(timer); stopGrace?.(); };
5416
+ }, []);
5417
+ // The server holds presence per user: any window's /afk, /dnd, /sleep, /back lands there. A window that is already open
5418
+ // re-reads it (mount + every presencePollMs) so they all agree. An unreadable read changes nothing.
5419
+ function applyServerAway(p) {
5420
+ if (!p || AWAY.current.afk || !(p.away === true || p.state === 'away' || p.state === 'sleeping')) return;
5421
+ const note = p.note || (p.state === 'sleeping' ? 'sleeping' : '');
5422
+ const explicit = p.dnd === true || note.toLowerCase().includes(DND_NOTE); const um = /until (\d\d):(\d\d)/.exec(note); // DND restored from the server note
5423
+ const sMs = Date.parse(p.since); const sOk = Number.isFinite(sMs) && sMs <= Date.now(); // the server knows when this away began: carry it, not this window's start
5424
+ if (sOk) AWAY.current.last = sMs;
5425
+ AWAY.current.afk = { at: sOk ? sMs : Date.now(), ...(sOk ? {} : { sinceUnknown: true }), note, dnd: true, ...(explicit ? { explicit: true, until: um ? untilEpoch({ h: Number(um[1]), m: Number(um[2]) }) : null } : {}) }; AWAY.current.col ||= newCollected(); if (sOk) AWAY.current.col.since = sMs;
5426
+ bump(n => n + 1);
5427
+ }
5428
+ React.useEffect(() => { // PRESENCE-SYNC: /back (or /afk, /dnd) in ANOTHER window of this user reaches this one
5429
+ if (!api.getPresence || !(presencePollMs > 0)) return undefined;
5430
+ let live = true;
5431
+ const t = setInterval(() => {
5432
+ api.getPresence().then((r) => {
5433
+ if (!live || !r?.ok || !r.presence) return; // unreadable: never a verdict
5434
+ const p = r.presence; const isAway = p.away === true || p.state === 'away' || p.state === 'sleeping';
5435
+ if (isAway) { applyServerAway(p); return; }
5436
+ const a = AWAY.current.afk;
5437
+ // present on the server: a window still showing away was ended elsewhere. Not while this window's own write is in flight / failed.
5438
+ if (a && !R.current.presenceUnsynced && Date.now() - (R.current.presenceSetAt || 0) > 15000) { archiveWindow(); AWAY.current.afk = null; bump(n => n + 1); }
5439
+ }, () => {});
5440
+ }, presencePollMs);
5441
+ return () => { live = false; clearInterval(t); };
5442
+ }, []);
5443
+ React.useEffect(() => { // AFK-SURVIVES-RESTART (owner 2026-10-28): a restarted window forgets AWAY; the server still knows. Read-only, never marks present; unreadable = as today.
5444
+ if (!api.getPresence) return undefined;
5445
+ let live = true; let timer = null;
5446
+ // owner live 2026-10-30 10:16: a restart onto a slow collector read nothing and defaulted DND off. An UNREADABLE presence
5447
+ // (error, timeout) is retried with backoff and never means "present"; only a successful read can say so.
5448
+ const attempt = (i) => {
5449
+ const retry = () => { if (live && i < presenceRetryDelays.length) timer = setTimeout(() => attempt(i + 1), presenceRetryDelays[i]); };
5450
+ api.getPresence().then((r) => {
5451
+ if (!r?.ok) { retry(); return; }
5452
+ if (!live) return;
5453
+ applyServerAway(r.presence);
5454
+ }, retry);
5455
+ };
5456
+ attempt(0);
4390
5457
  return () => { live = false; clearTimeout(timer); };
4391
5458
  }, []);
5459
+ React.useEffect(() => { // DND "until HH:MM": the deadline ends it (the owner is back at that time; collected items stay for /morningreport)
5460
+ const t = setInterval(() => { const a = AWAY.current.afk; if (a?.explicit && a.until && Date.now() >= a.until) dndOff(); }, 15000);
5461
+ return () => clearInterval(t);
5462
+ }, []);
4392
5463
  React.useEffect(() => { // E5: "While you were away" under the intro, after more than 2 h away
4393
5464
  if (!away) return undefined;
4394
5465
  let live = true;
@@ -4484,10 +5555,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4484
5555
  }
4485
5556
  pendingHist = s.history.slice(committedRef.current);
4486
5557
  }
5558
+ const staticCols = columns != null ? cols : liveCols(stdout, cols); // history prints at the terminal's width NOW
4487
5559
  const staticItems = (() => { // QLOOP-LIVE: the committed history for <Static>; append-only, a settled loop card joins at the end
4488
5560
  let st = staticRef.current;
4489
5561
  const upto = Math.min(committedRef.current, s.history.length);
4490
- if (st.seen > s.history.length) st = staticRef.current = { list: [], seen: 0, deferred: [] };
5562
+ if (st.seen > s.history.length || st.epoch !== epoch) st = staticRef.current = { list: [], seen: 0, deferred: [], epoch }; // /clear bumps the epoch: a reset that leaves history no shorter than before (one welcome, then one note) must still start the list over
4491
5563
  let list = st.list;
4492
5564
  for (; st.seen < upto; st.seen++) { const it = s.history[st.seen]; if (LC.current.held(it)) st.deferred.push(it); else list = [...list, it]; }
4493
5565
  if (st.deferred.length) { const done = st.deferred.filter((it) => !LC.current.held(it)); if (done.length) { st.deferred = st.deferred.filter((it) => !done.includes(it)); list = [...list, ...done]; } }
@@ -4498,6 +5570,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4498
5570
  // Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
4499
5571
  const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
4500
5572
  const queue = queueOf(s);
5573
+ const burst0 = queue[0] && !signedOut ? batchFor(queue, queue[0]) : null; // APPROVAL-BURST: the head card's batch, if it has one
4501
5574
  const card = queue[0] || null;
4502
5575
  const armed = card ? Date.now() - Math.max(card.displayedAt ?? card.shownAt ?? 0, K.current.guard?.since ?? 0) >= 300 : false;
4503
5576
  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)
@@ -4527,13 +5600,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4527
5600
  function approvalRowShown() {
4528
5601
  const ro = R.current.roster;
4529
5602
  if (!ro.on || !ro.rows.some(r => r.kind === 'approval')) return false;
4530
- 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() });
5603
+ const v = rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40, max: MAX_ROSTER, on: ro.on, sel: ro.sel, focus: ro.focus, now: Date.now(), staleAt: signedOut ? ro.at || Date.now() : null });
4531
5604
  return v.mode === 'full' && v.lines.some(l => l.kind === 'approval');
4532
5605
  }
4533
5606
 
5607
+ /** EXT panels: the host-owned tab strip (only with 1+ panels) and, when one is open, its clipped lines above the composer. */
5608
+ function extPanelRows() {
5609
+ const ps = DK.current.panels(); if (!ps.length) return [];
5610
+ const open = DK.current.shown();
5611
+ const rowsN = DK.current.regionHeight(termRows(stdout));
5612
+ const theme = { name: mono() ? 'mono' : isLight() ? 'light' : 'dark', color: !mono(), unicode: /utf-?8/i.test(String(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '')) };
5613
+ const out = [h(Box, { key: 'ext-tabs', paddingLeft: 1 }, ...ps.map(p => h(Text, { key: p.name, color: c(p.focused ? 'accent' : 'dim'), bold: p.focused, inverse: p.focused }, ` ${p.title} `)), h(Text, { key: 'ext-hint', color: c('dim') }, DK.current.focused() ? ' Esc: composer Ctrl+]: next' : ' Ctrl+]: focus'))];
5614
+ if (open) {
5615
+ const lines = DK.current.renderPanel(open, { width: Math.max(1, cols - 2), height: rowsN, theme });
5616
+ out.push(h(Box, { key: 'ext-body', flexDirection: 'column', paddingLeft: 1 }, ...lines.map((l, i) => h(Text, { key: i, wrap: 'truncate-end' }, l || ' '))));
5617
+ }
5618
+ return out;
5619
+ }
4534
5620
  function rosterNode() {
4535
5621
  const ro = R.current.roster;
4536
- 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() });
5622
+ const view = rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40, max: MAX_ROSTER, on: ro.on, sel: ro.sel, focus: ro.focus, now: Date.now(), motion: motionOn && !mono(), staleAt: signedOut ? ro.at || Date.now() : null });
4537
5623
  const row = ro.rows[ro.sel];
4538
5624
  return h(Roster, {
4539
5625
  view, cols,
@@ -4547,14 +5633,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4547
5633
  if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
4548
5634
  const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
4549
5635
  return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
4550
- h(Static, { key: `static-${epoch}`, items: staticItems }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
5636
+ h(Static, { key: `static-${epoch}`, items: staticItems }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols: staticCols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
4551
5637
  h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
4552
5638
  }
4553
5639
  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());
4554
5640
  return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
4555
5641
  // Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
4556
5642
  // already-printed items would drop it (and reprint the rest).
4557
- h(Static, { key: `static-${epoch}`, items: introDone.current ? staticItems : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
5643
+ h(Static, { key: `static-${epoch}`, items: introDone.current ? staticItems : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols: staticCols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
4558
5644
  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) })),
4559
5645
  liveRegion([
4560
5646
  ...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 }))),
@@ -4566,22 +5652,25 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4566
5652
  h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
4567
5653
  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,
4568
5654
  ...LC.current.pinned().map((lc) => h(Box, { key: `loopcard-${lc.id}`, marginTop: 1 }, h(LoopCardView, { item: { card: lc }, cols, pinned: true }))), // QLOOP-LIVE: running loops are pinned above the composer
4569
- 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, others: otherSessionsCount(queue) } }) : null,
5655
+ // PIN-GLITCH: while the PIN pad is open the pad is the whole question (its subject names the action). Keeping the card body above it
5656
+ // pushed the live region to the terminal height, and Ink then wipes and rewrites the whole screen on every keystroke and poll.
5657
+ SU.current.isOpen() ? null : card && burst0 ? h(BatchCard, { model: batchModel(burst0), cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null }) : card ? h(ApprovalCard, { card, org: orgShown() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, signedOut: Boolean(signedOut), draftHint: R.current.denyReason ? 'reason' : K.current.cardFocus ? 'focus' : (B.current.text ? 'draft' : null), confirm: K.current.confirm, position: { i: 1, n: queue.length, others: otherSessionsCount(queue) } }) : null,
4570
5658
  ...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
4571
5659
  // TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
4572
5660
  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,
4573
5661
  h(Pending, { pending: s.pending, steering: s.steering, cols }),
4574
5662
  AR.current.timer && AR.current.endsAt ? h(Box, { key: 'restart-countdown', paddingLeft: 2 }, h(Text, { color: c('accent'), bold: true }, countdownLine(Math.max(1, Math.ceil((AR.current.endsAt - Date.now()) / 1000)), cols, { waiting: Boolean(AR.current.waiting) }))) : null,
5663
+ ...extPanelRows(),
4575
5664
  ruleAbove ? null : h(Box, { marginTop: 1 }),
4576
5665
  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 })
4577
- : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, signedOut: Boolean(blocked) }, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
4578
- : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, signedOut: Boolean(blocked) } }),
5666
+ : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, slow: s.notify.slow, signedOut: Boolean(blocked), outstanding: s.notify.outstanding, blind: s.notify.blind, others: s.notify.others }, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
5667
+ : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, slow: s.notify.slow, signedOut: Boolean(blocked), outstanding: s.notify.outstanding, blind: s.notify.blind, others: s.notify.others } }),
4579
5668
  !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,
4580
5669
  h(RowsBar, { rows: RW.current.view(), cols, limit: { grid: QPE_GRID, n: qpeRowsSetting(api.settings?.()) } }),
4581
5670
  h(PresentBar, { stepUp: SU.current, cols }),
4582
5671
  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 }),
4583
5672
  h(WaitingNotice, { items: s.waiting.filter(x => K.current.dismissed.has(x.callId)), cols }),
4584
- h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), inbox: s.notify.inboxBar || '', alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
5673
+ h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), away: Boolean(AWAY.current.afk), awaySince: AWAY.current.afk?.sinceUnknown ? 0 : AWAY.current.afk?.at || 0, awayUnknown: Boolean(AWAY.current.afk?.sinceUnknown), dndUntil: AWAY.current.afk?.until || 0, dnd: Boolean(AWAY.current.afk?.explicit), awayCount: Math.max(s.notify.approvals || 0, [...s.approvals, ...s.waiting].length), dndCount: waitingCount(AWAY.current.col, Math.max(s.notify.approvals || 0, [...s.approvals, ...s.waiting].length)), inbox: AWAY.current.afk ? '' : (s.notify.inboxBar || ''), spinner: s.notify.spinner || '', alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
4585
5674
  rosterNode())));
4586
5675
  }
4587
5676