quoxcli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/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/inbox.js +6 -0
  9. package/lib/api/endpoints/inboxq.js +19 -0
  10. package/lib/api/endpoints/index.js +2 -0
  11. package/lib/api/endpoints/mon.js +122 -0
  12. package/lib/api/endpoints/tools.js +13 -0
  13. package/lib/api/endpoints/v1.js +13 -0
  14. package/lib/commands/admin.js +35 -0
  15. package/lib/commands/agentAttach.js +15 -0
  16. package/lib/commands/approvals.js +193 -3
  17. package/lib/commands/ask.js +1 -1
  18. package/lib/commands/auth.js +64 -11
  19. package/lib/commands/backup.js +11 -4
  20. package/lib/commands/capabilities.js +3 -2
  21. package/lib/commands/chat.js +15 -0
  22. package/lib/commands/claude.js +4 -1
  23. package/lib/commands/compliance.js +109 -0
  24. package/lib/commands/connect.js +69 -0
  25. package/lib/commands/dev.js +6 -8
  26. package/lib/commands/doctor.js +22 -3
  27. package/lib/commands/evidence.js +2 -1
  28. package/lib/commands/gripe.js +24 -13
  29. package/lib/commands/inbox.js +37 -3
  30. package/lib/commands/inboxq.js +147 -0
  31. package/lib/commands/kuma.js +78 -5
  32. package/lib/commands/licenceLinkView.js +311 -0
  33. package/lib/commands/linkedin.js +19 -0
  34. package/lib/commands/loop.js +30 -3
  35. package/lib/commands/lucid.js +1 -0
  36. package/lib/commands/mail.js +161 -20
  37. package/lib/commands/memory.js +68 -4
  38. package/lib/commands/memorySync.js +158 -0
  39. package/lib/commands/mon.js +55 -144
  40. package/lib/commands/notes.js +29 -7
  41. package/lib/commands/notesSeal.js +259 -0
  42. package/lib/commands/objective.js +5 -2
  43. package/lib/commands/offload.js +2 -3
  44. package/lib/commands/plan.js +17 -0
  45. package/lib/commands/provider.js +29 -8
  46. package/lib/commands/qlarity.js +32 -1
  47. package/lib/commands/run.js +2 -1
  48. package/lib/commands/server.js +37 -0
  49. package/lib/commands/setup.js +127 -75
  50. package/lib/commands/skills.js +4 -1
  51. package/lib/commands/slot.js +49 -9
  52. package/lib/commands/status.js +92 -44
  53. package/lib/commands/tool.js +30 -0
  54. package/lib/commands/vault.js +6 -3
  55. package/lib/commands/walkthrough.js +14 -2
  56. package/lib/commands/watch.js +11 -2
  57. package/lib/commands/wazuh.js +824 -0
  58. package/lib/commands/wazuhIntake.js +317 -0
  59. package/lib/commands/wazuhReport.js +192 -0
  60. package/lib/commands/workflow.js +47 -4
  61. package/lib/commands/x.js +50 -0
  62. package/lib/core/announce.js +86 -0
  63. package/lib/core/auth.js +20 -2
  64. package/lib/core/callMe.js +118 -0
  65. package/lib/core/client.js +27 -1
  66. package/lib/core/commandRegistrars.js +8 -0
  67. package/lib/core/config.js +28 -2
  68. package/lib/core/confirm.js +68 -7
  69. package/lib/core/crossOrgApprovals.js +69 -0
  70. package/lib/core/errors.js +52 -1
  71. package/lib/core/governedSession.js +135 -5
  72. package/lib/core/inboxResolution.js +3 -2
  73. package/lib/core/loginGuard.js +73 -0
  74. package/lib/core/offload.js +37 -0
  75. package/lib/core/org-override.js +5 -1
  76. package/lib/core/output.js +1 -1
  77. package/lib/core/profileTrust.js +182 -0
  78. package/lib/core/quoxglyph.js +22 -1
  79. package/lib/core/router.js +14 -0
  80. package/lib/core/stepUpPrompt.js +99 -0
  81. package/lib/core/version.js +1 -1
  82. package/lib/core/walkthrough.js +9 -5
  83. package/lib/dragon/select.js +2 -2
  84. package/lib/quoxon/EXTENSIONS.md +142 -19
  85. package/lib/quoxon/api.js +185 -34
  86. package/lib/quoxon/app.js +1032 -152
  87. package/lib/quoxon/approvalBatch.js +117 -0
  88. package/lib/quoxon/approvalFacts.js +56 -2
  89. package/lib/quoxon/approvalSentence.js +8 -4
  90. package/lib/quoxon/approvalsView.js +69 -11
  91. package/lib/quoxon/autoOpen.js +94 -0
  92. package/lib/quoxon/backgroundWork.js +59 -0
  93. package/lib/quoxon/bang.js +1 -1
  94. package/lib/quoxon/capabilities.js +7 -2
  95. package/lib/quoxon/commandShort.js +7 -0
  96. package/lib/quoxon/commands.js +40 -10
  97. package/lib/quoxon/connect.js +5 -3
  98. package/lib/quoxon/deckLazy.js +58 -0
  99. package/lib/quoxon/destructive.js +35 -0
  100. package/lib/quoxon/dnd.js +71 -0
  101. package/lib/quoxon/dragonCompanion.js +37 -7
  102. package/lib/quoxon/dragonMoments.js +78 -0
  103. package/lib/quoxon/echo.js +1 -0
  104. package/lib/quoxon/events.js +43 -7
  105. package/lib/quoxon/extensions.js +398 -38
  106. package/lib/quoxon/gripeQueue.js +135 -0
  107. package/lib/quoxon/inboxView.js +125 -22
  108. package/lib/quoxon/keys.js +2 -2
  109. package/lib/quoxon/loopCard.js +5 -1
  110. package/lib/quoxon/loopCards.js +17 -8
  111. package/lib/quoxon/loopStream.js +20 -9
  112. package/lib/quoxon/mailView.js +71 -0
  113. package/lib/quoxon/main.js +20 -5
  114. package/lib/quoxon/markdown.js +1 -1
  115. package/lib/quoxon/model.js +15 -0
  116. package/lib/quoxon/neverAnswered.js +20 -0
  117. package/lib/quoxon/objects/away.js +43 -5
  118. package/lib/quoxon/onboarding/connectServer.js +162 -0
  119. package/lib/quoxon/onboarding/copy.js +6 -0
  120. package/lib/quoxon/onboarding/index.js +5 -43
  121. package/lib/quoxon/onboarding/probe.js +20 -1
  122. package/lib/quoxon/onboarding/view.js +16 -3
  123. package/lib/quoxon/orgSwitch.js +22 -0
  124. package/lib/quoxon/print.js +2 -0
  125. package/lib/quoxon/profileSwitch.js +7 -0
  126. package/lib/quoxon/promptHistory.js +3 -1
  127. package/lib/quoxon/render.js +63 -14
  128. package/lib/quoxon/roster.js +10 -5
  129. package/lib/quoxon/rosterSessions.js +44 -0
  130. package/lib/quoxon/screenContext.js +1 -1
  131. package/lib/quoxon/secretShape.js +86 -0
  132. package/lib/quoxon/slash/agents.js +2 -1
  133. package/lib/quoxon/slash/argSources.js +9 -2
  134. package/lib/quoxon/slash/args.js +4 -2
  135. package/lib/quoxon/slash/broadcast.js +18 -5
  136. package/lib/quoxon/slash/broadcastRun.js +13 -2
  137. package/lib/quoxon/slash/cliLeaves.json +63 -1
  138. package/lib/quoxon/slash/hatWalk.js +1 -1
  139. package/lib/quoxon/slash/palette.js +22 -1
  140. package/lib/quoxon/slash/paletteController.js +10 -4
  141. package/lib/quoxon/slash/restart.js +7 -4
  142. package/lib/quoxon/slash/stepUp.js +5 -5
  143. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  144. package/lib/quoxon/slashSpinner.js +35 -0
  145. package/lib/quoxon/slotAttach.js +8 -0
  146. package/lib/quoxon/slotChat.js +17 -0
  147. package/lib/quoxon/slotFormat.js +1 -1
  148. package/lib/quoxon/slotOutbox.js +113 -0
  149. package/lib/quoxon/suggestCommand.js +1 -1
  150. package/lib/quoxon/term.js +25 -1
  151. package/lib/quoxon/transcript.js +2 -1
  152. package/lib/quoxon/welcome.js +1 -1
  153. package/lib/quoxon/whatsnew.js +1 -1
  154. package/lib/quoxon/whatsnew.jsonl +36 -0
  155. package/lib/server/installFlow.js +168 -0
  156. package/lib/server/status.js +15 -1
  157. package/lib/toolOutputShape.js +55 -0
  158. package/package.json +3 -2
  159. package/vendor/dragonlab/SOURCE +3 -3
  160. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  161. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  162. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  163. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  164. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  165. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  166. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  167. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  168. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  169. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  170. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  171. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  172. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  173. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  174. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  175. 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
22
  import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
20
23
  import { turnTokens } from './indicator.js';
21
24
  import {
22
- Composer, RingComposer, Pending, ApprovalCard, 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,7 +54,9 @@ 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';
54
61
  import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
55
62
  import { packageVersion } from './welcome.js';
@@ -59,7 +66,8 @@ import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // P
59
66
  import { echoLine } from './echo.js';
60
67
  import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, looksLikeAddress, sayWhoText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
61
68
  import { pushHistory } from './promptHistory.js';
62
- import { termRows } from './term.js';
69
+ import { secretKinds, redactSecrets } from './secretShape.js';
70
+ import { termRows, liveCols } from './term.js';
63
71
  import { cardTallerThanScreen } from './objects/view.js';
64
72
  import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows, confirmLanAllow } from './connect.js';
65
73
  import { ConnectCard } from './connectCard.js';
@@ -68,8 +76,9 @@ import { createLoginController, LoginCard, SIGNED_OUT_LINE, signedOutCard, isSes
68
76
  import { createStepUp } from './slash/stepUp.js';
69
77
  import { StepUpCard, PresentBar } from './slash/stepUpCard.js';
70
78
  import { createStepUpCmds, declinedText } from './slash/stepUpCmds.js';
79
+ import { createConfirm } from './slash/confirm.js';
71
80
  import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
72
- import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
81
+ import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, limitedOffer, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
73
82
  import { humanSessionLines } from '../core/sessionsOverview.js';
74
83
  import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
75
84
  import { capabilityLines, offerFor, findCapability } from './capabilities.js';
@@ -90,7 +99,7 @@ import { parseBroadcastText } from './slash/broadcast.js';
90
99
  import { isOwnBroadcast } from './ownBroadcast.js';
91
100
  import { orgLabel, isOrgId } from './orgLabel.js';
92
101
  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';
102
+ import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, withSenderSlot, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine, reattachSlowLine } from './slotChat.js';
94
103
  import { homedir as osHome } from 'node:os';
95
104
  import { createRowCmd } from './slash/rowCmd.js';
96
105
  import { createKillerCmds } from './slash/killer.js';
@@ -98,7 +107,8 @@ import { createUndoWish } from './slash/undoWish.js';
98
107
  import { createHatWalk } from './slash/hatWalk.js';
99
108
  import { createRowEngine, runShell } from './rows.js';
100
109
  import { qpeCommand, qpeRowsSetting, QPE_GRID } from './slash/qpeCmd.js';
101
- import { createExtensionHost, pickDraft as deckPickDraft, readExtList, extSetEnabled, extAdd } from './extensions.js';
110
+ import { deckOffer, runDeckOffer, declineText } from './deckLazy.js';
111
+ import { createExtensionHost, pickDraft as deckPickDraft, readExtList, extSetEnabled, extAdd, panelKeyName } from './extensions.js';
102
112
  import { createLoopCards, rowsWithoutLoopCards } from './loopCards.js';
103
113
  import { initLoop, applySnapshot as applyLoopSnapshot, cardLines } from './loopCard.js';
104
114
  import { createCompanionController, companionCommand, DragonCompanionLive, takeGreeting, detachGreeting } from './dragonCompanion.js';
@@ -122,6 +132,8 @@ import { supportsHyperlinks } from './markdown.js';
122
132
  import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
123
133
  import { intentPanel } from './intent.js';
124
134
  import { awayDue, parseSince } from './away.js';
135
+ import { parseSlashArgs as parseCallMeSlashArgs, statusLines as callMeStatusLines } from '../core/callMe.js';
136
+ import { parseDndArgs, untilEpoch, sleepIntent, afkMeansSleep, newCollected, morningLines, waitingCount, DND_NOTE } from './dnd.js';
125
137
  import { sitrepData } from './objects/sitrep.js';
126
138
  import { mirrorErrorNote } from './objects/mirror.js';
127
139
  import { launchEgg } from './egg.js';
@@ -130,12 +142,17 @@ import { Clock, FRAME_MS, SECOND_MS, motionAllowed, createFrames } from './motio
130
142
  import { FrameContext, useFrame } from './motion/frame.js';
131
143
  import { ringMode, ringAnimating } from './motion/ring.js';
132
144
  import { TIMING, revealSpan } from './motion/reveal.js';
133
- import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
145
+ import { matchOrg, orgLines, orgRows, orgCandidates } from './orgSwitch.js';
134
146
  import { INSTALL_TOOLS, SUGGEST_LINE, planLine, offerQuestion, runInstall, missingTools } from './installCli.js';
135
147
  import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
148
+ import { slotOutbox, isConnectivityFailure, queuedLine, sentLine, fullLine } from './slotOutbox.js';
136
149
  import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
150
+ import { gripeFiledLine, gripeFailedLine, gripeQueuedFiledLine } from '../core/quoxglyph.js';
151
+ import { crossOrgTuiLines } from '../core/crossOrgApprovals.js';
152
+ const gripeLead = '📣 GRIPE │';
137
153
 
138
154
  const h = React.createElement;
155
+ const BARE_COMMAND_WORDS = new Set(['clear', 'exit', 'quit', 'help', 'back', 'detach', 'restart', 'inbox', 'approvals']); // a message that is exactly one of these is offered as the command first
139
156
  const PASTE_GUARD_MS = 40;
140
157
  const ASK_ECHO_MS = 1500; // TUI-INPUT: how long an Enter after a single-key ask answer is treated as the confirm habit
141
158
  const MAX_ROSTER = 4;
@@ -169,8 +186,14 @@ export const friendlyError = (e, profile) =>
169
186
  const byId = (id) => CHOICES.find(x => x.id === id) || null;
170
187
  // the status line shows the new ask until the server answers
171
188
  const NO_RAN = { provider: null, model: null, endpoint: null };
189
+ // /gripe keeps the newlines of what was pasted: the whole body goes into the issue, not a space-joined line.
190
+ const GRIPE_LINE = /^\/gripe!?(\s|$)/i;
191
+ const isOneLineCommand = (t) => t.startsWith('/') && (!t.includes('\n') || GRIPE_LINE.test(t));
172
192
 
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 }) {
193
+ export const INBOX_BAR_MAX_MS = 300000; // the bar never waits longer than this between reads
194
+ export const INBOX_BAR_COST_X = 20; // wait 20x the last read's duration (a 3s read -> 60s; a fast read keeps the poll cadence)
195
+
196
+ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, restartDeclined = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], startupGraceMs = 60000, startupRetryMs = 3000, neverAnswered = false, onConnect = null, restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, restartCooloffMs = null, presenceRetryDelays = [2000, 5000, 10000, 20000, 30000, 60000], presencePollMs = 30000, autoIdleMs = null, autoTickMs = 1000, autoResumeMs = 3000, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, startupSwitchOffer = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, outboxPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined }) {
174
197
  // Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
175
198
  // one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
176
199
  // PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
@@ -196,12 +219,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
196
219
  const exit = (afterMs = 0) => {
197
220
  const R0 = R.current;
198
221
  if (R0.exiting) return undefined;
199
- try { DK.current?.unloadAll(); } catch { /* closing the deck never blocks exit */ }
222
+ let extClosing = null;
223
+ try { extClosing = DK.current?.unloadAll(); } catch { /* closing the deck never blocks exit */ } // async close() is awaited up to 500 ms
200
224
  R0.exiting = true; R0.sleep = null; R0.fly = null;
201
225
  R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
202
226
  resetDragon();
203
227
  bump(n => n + 1);
204
- return setTimeout(() => inkExit(), 60 + afterMs);
228
+ return setTimeout(() => { Promise.resolve(extClosing).then(() => inkExit(), () => inkExit()); }, 60 + afterMs);
205
229
  };
206
230
  const [epoch, setEpoch] = React.useState(0);
207
231
 
@@ -246,7 +270,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
246
270
  // Drop-in reveals: history items enter <Static> only once their reveal has ended (at most ~1 s); until then
247
271
  // they render in the live region. committedRef = how many history items <Static> has been given.
248
272
  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)
273
+ const staticRef = React.useRef({ list: [], seen: 0, deferred: [], epoch: 0 }); // what <Static> was given: committed items in order, minus loop cards still running (they join when they settle)
250
274
  const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
251
275
  const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
252
276
  const K = React.useRef({ keys: initialKeyState(), lastKeyAt: null, lastKeyCh: null, lastDataAt: 0, lastPasteAt: 0, lastEscAt: 0, ctrlC: 0, guard: null, dismissed: new Set(), cardFocus: false, confirm: false, cardGoneAt: 0, typed: '', danglingTimer: null });
@@ -255,7 +279,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
255
279
  const R = React.useRef({ abort: null, histIdx: -1, history: [], stash: [], reviewing: false, fallbackTimer: null, ctrlCTimer: null, banner: null, closed: false, pollTimers: new Set(), caps: { steer: true, interrupt: true }, confirmMulti: null, roster: { rows: [], all: false, allRows: [], alerts: [], sources: null, on: true, focus: false, sel: 0, peek: false, confirm: null, stopping: false } });
256
280
  // QUOXON-HISTORY: Up recalls what was typed in earlier runs too (most recent first); main.js passes the file store.
257
281
  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 */ } } };
282
+ // remember: a /verify-email line with a 6-digit run (a PIN typed by mistake) is stored as the verb only
283
+ const remember = (t0) => { const t = /^\/verify-email(\s|$)/i.test(String(t0).trim()) && /(^|\D)\d{6}(\D|$)/.test(String(t0)) ? '/verify-email' : redactSecrets(t0); if (pushHistory(R.current.history, t)) { try { historyStore?.add?.(t); } catch { /* history is a convenience */ } } };
259
284
  // E5: when this profile was last seen (read once at launch, before it is refreshed), and a throttled touch.
260
285
  const AWAY = React.useRef({ last: null, touched: 0 });
261
286
  const touchSeen = (force = false) => {
@@ -267,10 +292,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
267
292
 
268
293
  const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
269
294
  /** 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)); };
295
+ const queueOf = (st) => { const all = orderApprovals([...st.approvals, ...st.waiting], LC.current.all().map((c) => c.id)); const live = new Set(all.map(x => x.callId)); for (const id of K.current.dismissed) if (!live.has(id)) K.current.dismissed.delete(id); if (AWAY.current.afk) return []; /* AFK-QUIET-CARDS: away = no card, no armed answer keys; the count rides the notify bar until /back */ return all.filter(x => !K.current.dismissed.has(x.callId)); };
271
296
  const headCard = (st) => queueOf(st)[0] || null;
272
297
  const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
273
298
  const dispatch = (a) => {
299
+ // A slash command whose request failed for want of a user session: one signed-out line plus the single
300
+ // sign-in offer (never the plain-CLI service-key paragraph). Covers every command's error path.
301
+ if (a.type === 'error' && NO_SESSION_RE.test(String(a.text || ''))) { noSessionAnswer(); return; }
274
302
  if (a.type === 'note') SCX.current.notice(a.text, a.tone);
275
303
  else if (a.type === 'error') SCX.current.error(a.text);
276
304
  else if (a.type === 'event' && a.ev) {
@@ -278,8 +306,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
278
306
  else if (a.ev.type === 'turn.finished' && a.ev.status && !['ok', 'success', 'completed'].includes(a.ev.status)) SCX.current.failedTurn(a.ev.status);
279
307
  }
280
308
  transcript?.action(a.redact !== undefined ? { ...a, text: a.redact } : a); // dev transcript capture (off by default; see transcript.js); `redact` is what the capture sees instead of a secret-bearing text
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)
309
+ const prevS = S.current; S.current = reduce(prevS, { now: Date.now(), ...a }); if (!AWAY.current.afk) SND.current.observe(prevS, S.current, a); // DND: no sound while away
310
+ if (!AWAY.current.afk && motionOn && !mono() && feats().moods) { const m = DC.current.moodFor(prevS, a, S.current); if (m) playDragon(m); }
311
+ if (!AWAY.current.afk && (S.current.waiting?.length || 0) > (prevS.waiting?.length || 0)) DC.current.waitingCards(prevS, S.current).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); // another session's approval from a dragon agent: its dragon (the Q dragon only reacts to this conversation's cards, so never two) // cinematic: the dragon reacts (one moment engine: an attached companion owns the moment instead, dragonMoments.js)
283
312
  // Per shown card: remember the key that was already down when it appeared, and restart the typed word.
284
313
  const c0 = headCard(S.current);
285
314
  if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
@@ -297,6 +326,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
297
326
  // (never printed to scrollback while open); reconcileAsks settles it to one line once it is no longer open.
298
327
  const askSeq = React.useRef(0);
299
328
  const say = (text, tone) => {
329
+ R.current.spinStop?.(); // INSTANT-FEEDBACK: the first printed line replaces the spinner
300
330
  if (tone !== 'ask') return dispatch({ type: 'note', text, tone });
301
331
  const id = `ask-${++askSeq.current}`;
302
332
  R.current.lastAsk = { text, question: askQuestion(text), id };
@@ -349,10 +379,23 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
349
379
  const CN = React.useRef(null);
350
380
  CN.current ??= createConnectController({ api, note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), refresh: () => bump(n => n + 1) });
351
381
  // /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'); };
382
+ // Signed out: offer the fix as y/N (y = the same flow as /login), not only instruct. Same 2-minute ask window as the other offers.
383
+ const offerSignIn = () => { R.current.loginAsk = { at: Date.now() }; say(askText('Sign in now?', { title: `Signed out of ${profile}.`, action: 'open the sign-in card (same as /login)' }), 'ask'); };
384
+ // Once per sign-out: a ref (not state) so concurrent polls in one tick and the stale-credentials retry cannot re-announce.
385
+ const soNoticed = React.useRef(Boolean(signedOutProp));
386
+ const NO_SESSION_RE = /No authentication token|QUOX_SERVICE_KEY|service key|valid jwt|session expired|not signed in/i;
387
+ const noSessionAnswer = () => {
388
+ const first = !soNoticed.current;
389
+ setSignedOut(signedOutRef.current || { reason: null }); soNoticed.current = true;
390
+ say(SIGNED_OUT_LINE, 'error');
391
+ const live = R.current.loginAsk && Date.now() - R.current.loginAsk.at < 120000;
392
+ if (first && !live) offerSignIn();
393
+ };
394
+ const markSignedOut = (reason = null) => { if (BL.current) return; setSignedOut({ reason }); if (soNoticed.current) return; soNoticed.current = true; say(signedOutCard(profile, reason), 'error'); offerSignIn(); };
395
+ React.useEffect(() => { if (signedOutProp) offerSignIn(); }, []); // a window that started signed out gets the same offer
353
396
  const LI = React.useRef(null);
354
397
  LI.current ??= createLoginController({ api, note: (t, tone) => say(t, tone), refresh: () => bump(n => n + 1), onSignedIn: () => {
355
- setSignedOut(null); BL.current = blockedProp || null;
398
+ R.current.loginAsk = null; soNoticed.current = false; setSignedOut(null); BL.current = blockedProp || null;
356
399
  R.current.notify = { fails: 0, authLost: false };
357
400
  dispatch({ type: 'notify', patch: { error: null } });
358
401
  refreshNotify(); refreshRoster();
@@ -361,9 +404,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
361
404
  // QUOXON-STEPUP: the PIN card. The digits live in its closure box only: never dispatched, buffered, in history,
362
405
  // undo, the transcript or any log. Agent logins never get a PIN field (slash/stepUp.js).
363
406
  const SU = React.useRef(null);
407
+ const AO = React.useRef(null); AO.current ??= { st: createAutoOpen({ enabled: autoOn(api.settings?.()) }), timer: null, noticeAt: 0, othersDone: false, otherCounts: new Map(), waitSaid: false, dead: false }; // APPROVALS-AUTO: new approvals open by themselves
364
408
  SU.current ??= createStepUp({ api, agentLogin: () => WHO.current.agentLogin, onAgentLogin: () => offerSwitch(), refresh: () => bump(n => n + 1), note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), settingsUrl: async () => { try { const b = await api.dashboardBase?.(); return b ? `${String(b).replace(/\/$/, '')}/settings` : null; } catch { return null; } } });
365
409
  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 });
410
+ SUC.current ??= createStepUpCmds({ api, stepUp: SU.current, say: (t, tone) => say(t, tone), confirm: createConfirm({ say: (t, tone) => say(t, tone) }), lastBlock: () => R.current.lastBlock || null, orgOf: () => api.orgId?.() || null });
367
411
  // L0 palette: opens under the composer while the draft is a bare /word (slash/paletteController.js).
368
412
  const PL = React.useRef(null);
369
413
  // QUOXON-FW6: model favourites + default (server-side user preferences; profile = cache).
@@ -397,6 +441,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
397
441
  const SPN = React.useRef(null);
398
442
  SPN.current ??= createSpawnCmd({ api, say: (t, tone) => say(t, tone), session: () => SN.current.name || S.current.ctx.conversationId || 'quox', from: () => senderLabel({ slot: where.current.slot || null, display: myDisplay() }), statePath: spawnStatePath });
399
443
  React.useEffect(() => { if (!blocked && !demoLabel && api.slotSay) SPN.current.sweep().catch(() => {}); }, []); // release this session's overdue VMs
444
+ // GRIPE-QUEUE: gripes saved while Quox did not answer are filed on start and then at most once per 2 minutes (the queue itself enforces the gap).
445
+ const gripeSweep = React.useRef(null);
446
+ gripeSweep.current = async (force = false) => {
447
+ if (!api.gripeRetry) return;
448
+ let r; try { r = await api.gripeRetry({ force }); } catch { return; }
449
+ for (const it of r?.filed || []) say(gripeQueuedFiledLine(it.url));
450
+ for (const it of r?.expired || []) say(`Gripe not filed after 24 h, giving up: "${clip(it.text, Math.max(20, cols - 50))}" (re-file it with /gripe!)`, 'warn');
451
+ };
452
+ React.useEffect(() => {
453
+ if (blocked || demoLabel || !api.gripeRetry) return undefined;
454
+ gripeSweep.current();
455
+ const t = setInterval(() => gripeSweep.current(), 125000);
456
+ return () => clearInterval(t);
457
+ }, []);
400
458
  // QUOXON-FW17: custom rows above the status line (rows.js engine, slash/rowCmd.js).
401
459
  const RW = React.useRef(null);
402
460
  RW.current ??= createRowCmd({
@@ -414,15 +472,21 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
414
472
  env,
415
473
  getSettings: () => api.settings?.() || {},
416
474
  handlers: {
417
- openApproval: (n) => { command(`/approvals ${n}`); },
418
- preselect: (n, option) => {
475
+ openApproval: (n, id) => {
476
+ if (id != null) { const i = (R.current.deckItems || []).findIndex(x => String(x?.id) === id); if (i < 0) return false; n = i + 1; } // contract v1: the id decides, a gone id is refused
477
+ command(`/approvals ${n}`);
478
+ return true;
479
+ },
480
+ preselect: (n, option, id) => {
481
+ if (id != null) { const i = (R.current.deckItems || []).findIndex(x => String(x?.id) === id); if (i < 0) return false; n = i + 1; }
419
482
  const it = (R.current.deckItems || [])[n - 1];
420
483
  const draft = deckPickDraft(it, n, option);
421
484
  if (!draft) return say(`Extension: no such option "${option}" for [${n}].`, 'warn');
422
485
  edit({ type: 'set', text: draft }); // preselected only: the answer goes when the user presses Enter
423
486
  say(`Preselected: ${draft.trim()}. Press Enter to confirm.`, 'accent');
424
487
  },
425
- changed: () => bump(n => n + 1)
488
+ note: (t, tone) => say(t, tone),
489
+ changed: () => { setExtEntries(DK.current.commands()); bump(n => n + 1); }
426
490
  },
427
491
  log: () => {}
428
492
  });
@@ -454,6 +518,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
454
518
  loops: LC.current.all().map(c => ({ id: c.state.id, title: c.state.title, turn: c.state.iteration ?? c.state.turn, max: c.state.maxTurns, status: c.state.done ? (c.state.state === 'failed' ? 'failed' : 'done') : 'running' })),
455
519
  fleet: R.current.fleetHosts || null,
456
520
  evidence: api.evidenceChain?.() || null, // quoxon holds no chain status today: 'unknown' unless the api provides one
521
+ readOkAt: (R.current.readOkAt ??= Date.now()), // last good approvals read: older than 60 s makes state.stale
457
522
  sessions: { active: rows.filter(r => r.state === 'running').length }
458
523
  });
459
524
  });
@@ -461,7 +526,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
461
526
  getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
462
527
  run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
463
528
  ctx: () => ({ api, caps: R.current.contract, blocked: BL.current }),
464
- source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all() }),
529
+ source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all(), getInbox: () => ({ list: () => R.current.inboxList || [], mutes: () => (api.settings?.() || R.current.sessionSettings || {}).inboxMutes }), getExt: () => ({ listed: () => readExtList().map(x => x.name), loaded: () => DK.current.loadedNames(), panels: () => DK.current.panels(), keys: (n) => DK.current.settingsInfo(n).defs || [] }) }),
465
530
  refresh: () => bump(n => n + 1),
466
531
  // MENTIONS: agents, hosts and tasks fetched once per session on the first @; receipts are this session's own tool envelopes
467
532
  mentions: blocked ? null : { cache: createMentionCache({ load: () => loadMentions(api), refresh: () => bump(n => n + 1) }), local: () => receiptMentions([...S.current.history, ...S.current.blocks]) }
@@ -481,7 +546,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
481
546
  }, []);
482
547
  const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
483
548
  // /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 };
549
+ const AR = React.useRef(null); AR.current ??= { on: (api.settings?.() || {}).restartAuto === true, newId: null, cancelledId: null, timer: null, endsAt: 0, busyAt: 0, lastRestartAt: Number((api.settings?.() || {}).lastRestartAt) || 0, quietId: null, mountedAt: Date.now() };
550
+ // COALESCE (owner 2026-10-27): after a restart, no auto-restart for a cool-off (prop, else setting restartCooloffMin, else 30 min); a build landing
551
+ // inside it shows one quiet line (Enter restarts); afterwards the countdown also waits for autoIdleMs (default 2 min) without a keypress.
552
+ const cooloffMs = () => { if (restartCooloffMs != null) return restartCooloffMs; const m = (api.settings?.() || {}).restartCooloffMin; return (m != null && Number.isFinite(Number(m)) && Number(m) >= 0 ? Number(m) : 30) * 60000; };
553
+ const idleNeededMs = () => autoIdleMs ?? 120000;
554
+ const inCooloff = () => AR.current.lastRestartAt > 0 && Date.now() - AR.current.lastRestartAt < cooloffMs();
485
555
  // Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
486
556
  const countdownMs = () => { if (autoRestartMs != null) return autoRestartMs; const n = Number(env.QUOX_RESTART_COUNTDOWN); return (env.QUOX_RESTART_COUNTDOWN != null && env.QUOX_RESTART_COUNTDOWN !== '' && Number.isFinite(n) && n >= 0 ? n : 10) * 1000; };
487
557
  const cdLog = (phase, seconds = null) => transcript?.action({ type: 'restart.countdown', phase, seconds, build: AR.current.newId }); // lifecycle in the dev transcript
@@ -495,6 +565,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
495
565
  const token = {}; a.waiting = token; // set first: cancels and the idle check see a restart in flight
496
566
  Promise.resolve().then(() => serverUp()).catch(() => null).then((first) => {
497
567
  if (AR.current.waiting !== token || R.current.closed) return;
568
+ if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); return; } // a turn started while the health probe ran: never kill it
498
569
  if (first?.ok === true) { stopCountdown(); cdLog('timed-out'); doRestart(); return; } // healthy: no waiting line, as before
499
570
  beginWait(token);
500
571
  });
@@ -517,15 +588,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
517
588
  } }) : undefined), []);
518
589
  const autoIdle = () => { // nothing for a restart to interrupt
519
590
  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
591
+ // AWAY-RESTART (2026-11-03): away/DND is the ideal time to restart (nothing to interrupt, DND restores from the server), so the dragon
592
+ // sleep screen (/sleep opens it) and approvals queued for the morning (cards are closed while away) do not count as busy.
593
+ const isAway = Boolean(AWAY.current.afk);
594
+ return !s.turn && (isAway || !s.approvals.length) && !s.pending.length && B.current.text === '' && !R.current.pendingWalk && !R.current.bang && (isAway || !R.current.sleep)
521
595
  && !R.current.restarting && !R.current.exiting && !LI.current.isOpen() && !SU.current.isOpen() && !CN.current.isOpen() && !PK.current.isOpen() && !blocked;
522
596
  };
523
597
  React.useEffect(() => {
524
598
  const id = setInterval(() => {
525
599
  const a = AR.current;
526
- if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting || (a.waiting && !a.timer)) return;
600
+ if (!autoIdle() && B.current.text === '') a.busyAt = Date.now(); // a turn, approval or modal is open: the restart resumes autoResumeMs after it clears (composer text just holds while present)
601
+ if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting) return;
602
+ if (!a.timer && !a.waiting && inCooloff()) { if (a.quietId !== a.newId) { a.quietId = a.newId; say('↻ new version ready · Enter on an empty composer to restart', 'dim'); } return; } // coalesced: one quiet line, no countdown
603
+ if (!a.timer && !a.waiting && a.lastRestartAt > 0 && Date.now() - (K.current.lastKeyAt ?? a.mountedAt) < idleNeededMs()) return; // cool-off over: wait for a quiet window
604
+ if (a.waiting && !a.timer) { if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); } return; } // health probe in flight: a turn that started meanwhile cancels the restart
527
605
  if (a.timer) { if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); if (R.current.pendingWalk) say('Restart held: it waits until the walkthrough ends.', 'dim'); } else bump(n => n + 1); return; } // ticks the visible countdown
528
- if (!autoIdle()) return;
606
+ if (!autoIdle() || Date.now() - a.busyAt < autoResumeMs) return;
529
607
  const ms = countdownMs();
530
608
  if (ms <= 0) { cdLog('timed-out', 0); autoFire(); return; }
531
609
  a.endsAt = Date.now() + ms;
@@ -569,6 +647,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
569
647
  const att = SN.current.agent ? `agent:${SN.current.agent}` : null;
570
648
  if (t.id !== curId) R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
571
649
  R0.orgId = done.id; R0.orgName = done.name;
650
+ R0.inboxEpoch = (R0.inboxEpoch || 0) + 1; R0.inboxSeen = null; R0.inboxList = []; R0.inboxAt = 0; // re-baseline: the new org's existing unread are not arrivals
651
+ dispatch({ type: 'notify', patch: { inboxBar: '' } });
572
652
  dispatch({ type: 'ctx', ctx: { conversationId: null } });
573
653
  SN.current.name = null; SN.current.saved = null;
574
654
  dispatch({ type: 'history.reset' });
@@ -595,7 +675,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
595
675
  ask(`Reattach to slot ${nm}?`, { action: `/slot ${nm} --take` });
596
676
  return;
597
677
  }
598
- if (i === 0) say(`slot ${nm}: reattaching after the restart is slow (${r.message}); retrying.`, 'dim');
678
+ if (i === 0) say(reattachSlowLine({ slot: nm, message: r.message, restartSeenAt: R.current.restartSeenAt, lastGoodAt: R.current.lastGoodAt }), 'dim');
599
679
  await new Promise((res) => { const t = setTimeout(res, slotRetryDelays[i]); t.unref?.(); });
600
680
  }
601
681
  }
@@ -606,7 +686,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
606
686
  return () => clearInterval(id);
607
687
  }, []);
608
688
  // ---- 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.
689
+ // Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): attach state + real stream events; art and reducer live in dragonlab.
610
690
  const DC = React.useRef(null); DC.current ??= createCompanionController({ settings: () => api.settings?.() || {}, isTTY: Boolean(stdout?.isTTY), typing: () => B.current.text !== '', jobs: createDragonJobs({ reads: api.dragonReads || {}, context: () => ({ envelopeId: receiptMentions([...S.current.history, ...S.current.blocks])[0]?.value || null }) }) }); // each dragon's one job: a real read (dragon/jobs.js)
611
691
  const dispatchEv = (a) => { DC.current.tap(a); dispatch(a); };
612
692
  // QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
@@ -625,9 +705,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
625
705
  open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: label }, signal),
626
706
  wait: api.slotWait ? ({ since }) => api.slotWait(nm, { since, excludeFrom: label }) : null,
627
707
  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));
708
+ // REPLY-ADDRESS: any message (say, broadcast, relayed) without a stamped from_slot gets its sender's held slot looked up
709
+ if (m0.from_slot || !api.slots) return handle(m0);
710
+ Promise.resolve().then(() => api.slots()).then(rows => handle(withSenderSlot(rows, m0)), () => handle(m0));
631
711
  },
632
712
  onState: (st) => {
633
713
  if (st.error && st.failures === 1 && !R.current.closed) { paused = true; streamPausedNote(`slot ${nm}`, st.error); }
@@ -640,9 +720,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
640
720
  noteReply(m); noteSender(m);
641
721
  if (R.current.closed || SC.current.slot !== nm || (label && m.from === label)) return;
642
722
  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 }); }
723
+ if (holdSlotMsg(m)) { rememberSender(m); return; } // DND: collected, no line, no bell, no run card
724
+ { const ln = slotLine(m); const bc = parseBroadcastText(m.text); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() }); if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: bc ? { ...ln, urgent: bc.urgent, broadcast: true } : ln });
725
+ DC.current.slotMessage(m, { broadcast: Boolean(bc), run: Boolean(rq) }).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); } // a session with a dragon speaks to you: its dragon appears (dragonMoments.js sender moments; mode on, not while typing, cooled per sender)
644
726
  rememberSender(m);
645
- if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
727
+ if ((api.settings?.() || {}).slotBell !== 'off' && !AWAY.current.afk) { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
646
728
  }
647
729
  }
648
730
  SC.current.listener = l; l.start({});
@@ -693,7 +775,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
693
775
  const wait = SC.current.waits ??= new Map();
694
776
  clearTimeout(wait.get(slot)); wait.delete(slot);
695
777
  if (p.listening) {
696
- const t = setTimeout(() => { wait.delete(slot); if (!R.current.closed) say(`no reply from ${slot} yet`, 'warn'); }, noReplyMs);
778
+ const t = setTimeout(() => { wait.delete(slot); if (R.current.closed) return; const heard = SC.current.heard?.get(String(slot).toLowerCase()); if (heard && Date.now() - heard < 10 * 60000) return; say(`no reply from ${slot} yet`, 'dim'); }, noReplyMs);
697
779
  t.unref?.(); wait.set(slot, t);
698
780
  } else if (r.presence?.state === 'not_listening') {
699
781
  // Transcript fp 6a482d60fe83 (2026-10-16): an attached window got nothing for 4 minutes after "not listening".
@@ -718,8 +800,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
718
800
  ttlMs: replyWatchMs,
719
801
  skipSlot: (nm) => nm === where.current.slot || SC.current.rooms?.has(nm),
720
802
  isMine: (m) => { const l = myLabel(); return Boolean(l && m?.from === l); },
721
- onReply: (nm, m) => {
803
+ onReply: async (nm, m0) => {
722
804
  if (R.current.closed) return;
805
+ let m = m0;
806
+ if (!m0.from_slot && api.slots) { try { m = withSenderSlot(await api.slots(), m0); } catch { m = m0; } if (R.current.closed) return; }
723
807
  noteReply(m); noteSender(m);
724
808
  const ln = slotLine(m); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() });
725
809
  if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: ln });
@@ -733,6 +817,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
733
817
  api.bindHolderSession?.(() => slotSession());
734
818
  /** Any inbound line from a slot we are waiting on cancels its "no reply" nudge. */
735
819
  function noteReply(m) {
820
+ const heard = SC.current.heard ??= new Map();
821
+ for (const k of [m?.from_slot, String(m?.from || '').split(' (')[0]]) if (k) heard.set(String(k).toLowerCase(), Date.now());
736
822
  const waits = SC.current.waits; if (!waits?.size) return;
737
823
  for (const [slot, t] of waits) if (isHolderLine(m, slot)) { clearTimeout(t); waits.delete(slot); } // case-insensitive: LUCID is the holder of lucid
738
824
  }
@@ -760,7 +846,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
760
846
  }
761
847
  /** A dropped slot stream: a server restart (connection cut) is routine and says so calmly; anything else keeps the error. */
762
848
  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');
849
+ if (/terminated|ECONNRESET|ECONNREFUSED|socket hang up|fetch failed|other side closed|502|503/i.test(String(err || ''))) { R.current.restartSeenAt = Date.now(); return say(`${where}: Quox server restarting, reconnecting (nothing is lost).`, 'dim'); }
764
850
  return say(`${where}: live messages paused (${err}); retrying.`, 'warn');
765
851
  }
766
852
  /** QUOXON-REPLY (owner 2026-10-15: "click the sender to reply"; no mouse tracking, so /r): who last messaged me. */
@@ -784,16 +870,69 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
784
870
  for (const n of new Set(list.map(r => r.human_presence?.away && r.human_presence.notice).filter(Boolean))) lines.push(`⏸ ${n}`); // PRESENCE
785
871
  return say(lines.join('\n'));
786
872
  }
787
- async function sendToSlot(slot, text) {
873
+ // ---- SLOT-OUTBOX: a send that failed because QuoxCORE did not answer is kept on disk and sent when it answers (slotOutbox.js) ----
874
+ const OB = React.useRef(null);
875
+ OB.current ??= slotOutbox(outboxPath);
876
+ const obProbe = React.useRef({ timer: null, i: 0 });
877
+ // Put text back in the prompt, in front of anything typed since (same shape as a failed steer).
878
+ const restoreDraft = (d) => { if (!d) return; const cur = B.current.text; edit({ type: 'set', text: cur ? `${d}\n${cur}` : d }); };
879
+ const noteAnswering = () => { // one "answering again" line, whichever probe saw it first
880
+ const t = Date.now(); if (t - (R.current.answeringAt || 0) < 5000) return;
881
+ R.current.answeringAt = t; say('✔ QuoxCORE is answering again', 'accent');
882
+ };
883
+ async function flushOutbox({ explicit = false } = {}) {
884
+ if (!api.slotSay) return { sent: [], failed: [], pending: 0 };
885
+ const r = await OB.current.flush(async (it) => {
886
+ const res = await api.slotSay(it.slot, it.text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
887
+ if (res.ok) return { ok: true };
888
+ return { ok: false, retryable: isConnectivityFailure(res), error: res.message };
889
+ }, { profile, includeUnsure: explicit });
890
+ if (R.current.closed) return r;
891
+ for (const it of r.sent) say(sentLine(it.slot), 'ok');
892
+ for (const it of r.failed) say(`Queued message to ${it.slot} was refused (${it.error}); it stays in /outbox (drop it with /outbox drop N).`, 'warn');
893
+ if (r.pending > 0 && !r.busy) armOutboxProbe();
894
+ return r;
895
+ }
896
+ function armOutboxProbe() {
897
+ const P = obProbe.current;
898
+ if (P.timer || R.current.closed) return;
899
+ const delays = healthRecheckDelays?.length ? healthRecheckDelays : [5000];
900
+ P.timer = setTimeout(async () => {
901
+ P.timer = null;
902
+ if (R.current.closed) return;
903
+ let hl = null; try { hl = await (healthRecheck ? healthRecheck() : api.health?.(5000)); } catch { hl = null; }
904
+ if (R.current.closed) return;
905
+ if (hl?.ok) { P.i = 0; noteAnswering(); flushOutbox(); return; }
906
+ P.i = Math.min(P.i + 1, delays.length - 1);
907
+ armOutboxProbe();
908
+ }, delays[Math.min(P.i, delays.length - 1)]);
909
+ }
910
+ React.useEffect(() => {
911
+ if (blocked || demoLabel || !api.slotSay) return undefined;
912
+ const open = OB.current.list({ profile });
913
+ const queued = open.filter(i => i.status === 'queued').length, unsure = open.filter(i => i.status === 'unsure').length;
914
+ if (queued) { say(`⏸ ${queued} message${queued === 1 ? '' : 's'} queued from earlier: sending when QuoxCORE answers · /outbox`, 'warn'); flushOutbox(); }
915
+ if (unsure) say(`⏸ ${unsure} earlier message${unsure === 1 ? '' : 's'} may or may not have been delivered (the window closed mid-send). /outbox lists them; /outbox send retries.`, 'warn');
916
+ return () => { clearTimeout(obProbe.current.timer); obProbe.current.timer = null; };
917
+ }, []);
918
+ async function sendToSlot(slot, text, { draft = `/say ${slot} ${text}` } = {}) {
788
919
  const target = await sayTarget(slot, { senders: true });
789
920
  slot = target.slot;
790
921
  if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
791
922
  if (SC.current.rooms.has(slot)) return postRoom(slot, text); // a joined room posts as a member
792
923
  const r = await api.slotSay(slot, text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
924
+ if (!r.ok && isConnectivityFailure(r) && !demoLabel) { // QuoxCORE did not answer: keep the message, never lose it
925
+ const q = OB.current.enqueue({ slot, text, profile });
926
+ if (q.ok) { obProbe.current.i = 0; say(queuedLine(slot, q.count), 'warn'); armOutboxProbe(); return undefined; }
927
+ say(q.full ? fullLine() : `slot ${slot}: ${r.message}. Could not save it to the outbox either; your text is back in the prompt.`, 'warn');
928
+ restoreDraft(draft); return undefined;
929
+ }
930
+ if (!r.ok) restoreDraft(draft); // a refusal (4xx, no such slot): show it and keep the text for editing
793
931
  if (!r.ok && r.status === 404 && api.slots) { let rows = []; try { rows = await api.slots(); } catch { /* the list is a hint only */ } return say(sayMissText(slot, rows), 'warn'); }
932
+ if (!r.ok && r.ambiguous) return say(r.message, 'warn');
794
933
  if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
795
934
  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');
935
+ if (r.viaOrgId) say(`${slot} lives in ${r.viaOrgName || 'another of your orgs'}; sent there.`, 'dim');
797
936
  const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
798
937
  dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
799
938
  announceDelivery(slot, r);
@@ -828,10 +967,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
828
967
  onMessage: (m) => {
829
968
  noteReply(m); noteSender(m);
830
969
  if (R.current.closed || !SC.current.rooms.has(nm) || isMine(m)) return;
970
+ if (holdSlotMsg(m, `#${nm}`)) { rememberSender(m); return; } // DND
831
971
  if (SC.current.active !== nm) entry.unread += 1; // before the dispatch, so the status line's count is current
832
972
  dispatch({ type: 'slotmsg', line: roomLine(nm, m) });
833
973
  rememberSender(m);
834
- if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
974
+ if ((api.settings?.() || {}).slotBell !== 'off' && !AWAY.current.afk) { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
835
975
  },
836
976
  onState: (st) => { if (st.error && st.failures === 1 && !R.current.closed) streamPausedNote(`#${nm}`, st.error); },
837
977
  });
@@ -915,13 +1055,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
915
1055
  SC.current.pendingFile = { slot, f, at: Date.now() };
916
1056
  return ask(`Send it to ${slot} with ${f.hits === 1 ? 'that' : 'those'} redacted?`, { action: `send ${f.name} to slot ${slot}, ${f.hits} value${f.hits === 1 ? '' : 's'} replaced by [redacted]`, reason: `${f.name} has ${f.hits} secret-shaped value${f.hits === 1 ? '' : 's'}.` });
917
1057
  }
918
- return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }));
1058
+ return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }), { draft: null });
919
1059
  }
920
1060
  // Startup: the slot this profile last held in this directory, free or stale (or still under our old label), is offered once.
921
1061
  React.useEffect(() => {
922
1062
  if (blocked || demoLabel || restartSlot || !api.checkoutSlot || !api.slots) return;
923
1063
  const rec = loadLastSlot({ profile, cwd: realCwd(), path: slotRecordPath });
924
1064
  if (!rec) return;
1065
+ if (restartDeclined && restartDeclined === rec.slot) return; // this window already said no; a restart must not re-ask (a fresh launch has no carry)
925
1066
  api.slots().then((rows) => {
926
1067
  const row = (Array.isArray(rows) ? rows : []).find(x => String(x.name) === rec.slot);
927
1068
  if (!row || SC.current.slot || R.current.closed) return; // only this window's own checkout suppresses the ask, not an inherited one
@@ -975,6 +1116,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
975
1116
  const shown = showWhatsNew();
976
1117
  if (tipStartup && !welcome && !shown) showTip({ startup: true });
977
1118
  };
1119
+ React.useEffect(() => { // STARTUP OFFER: agent login + human logins on this machine => ask once to restart as the human
1120
+ if (!startupSwitchOffer || blocked || demoLabel || R.current.startupSwitchOffered) return;
1121
+ R.current.startupSwitchOffered = true;
1122
+ offerStartupSwitch();
1123
+ }, []);
978
1124
  React.useEffect(() => { // LAZY-AI-CLI: first launch after setup: one line, then a y/N card per missing CLI (admins only; never a wall)
979
1125
  if (blocked || !welcome || !api.providerCliStatus || !api.providerCliPlan) return;
980
1126
  api.providerCliStatus().then(async (st) => {
@@ -999,7 +1145,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
999
1145
  // Only the live region repaints at the new width (R3 D2): the stdout proxy (term.js) erases the old frame
1000
1146
  // by its reflowed height, so no ghost composers, and history is never reprinted into scrollback.
1001
1147
  const onResize = () => setCols(clampCols((stdout.columns || 80) - 1));
1148
+ onResize.resizeFirst = true; // term.js runs Ink's own resize repaint a beat later, after this commits the new width
1002
1149
  stdout?.on?.('resize', onResize);
1150
+ onResize(); // a resize between first render and this subscription (a restart replay) must not leave the old width
1003
1151
  return () => stdout?.off?.('resize', onResize);
1004
1152
  }, [stdout, columns]);
1005
1153
 
@@ -1029,17 +1177,49 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1029
1177
  } catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
1030
1178
  };
1031
1179
  // 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 () => {
1180
+ // The inbox list read can be heavy on the auth service (seconds per call), and every window polls: the bar backs off in
1181
+ // proportion to the last read's cost (INBOX_BAR_COST_X, capped at INBOX_BAR_MAX_MS), reads 10 items, one read in flight
1182
+ // per window (2026-10-27 auth overload: every window read 30 items every 4s at ~3.4s each and pegged auth).
1183
+ const inboxMutes = () => activeMutes((api.settings ? api.settings() : (R.current.sessionSettings ||= {})).inboxMutes); // per profile, in the CLI settings
1184
+ const barOpts = () => ({ mutes: inboxMutes(), announced: (R.current.inboxAnnounced ||= new Map()) });
1185
+ const refreshInboxBar = async ({ force = false } = {}) => {
1033
1186
  if (!api.inboxItems) return;
1187
+ const t = Date.now();
1188
+ const gap = Math.min(INBOX_BAR_MAX_MS, (R.current.inboxCost || 0) * INBOX_BAR_COST_X);
1189
+ if (R.current.inboxBusy || (!force && t - (R.current.inboxAt || 0) < gap)) return;
1190
+ R.current.inboxBusy = true; R.current.inboxAt = t;
1191
+ const epoch = R.current.inboxEpoch || 0;
1034
1192
  try {
1035
- const items = await api.inboxItems({ limit: 30 });
1193
+ const items = await api.inboxItems({ limit: 10 });
1194
+ R.current.inboxCost = Date.now() - t;
1195
+ if (epoch !== (R.current.inboxEpoch || 0)) return; // an org switch landed while this read was in flight: it belongs to the old org
1196
+ if (!R.current.inboxSeen && items.length) { try { R.current.inboxSeen = loadSeen(api.settings?.(), R.current.orgId ?? api.orgId?.() ?? null); } catch { /* no saved watermark: baseline from this read */ } }
1197
+ const before = R.current.inboxSeen?.size || 0;
1036
1198
  const a = arrivals(items, R.current.inboxSeen || null);
1037
1199
  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 */ }
1200
+ if (a.ids.size !== before) { try { api.saveSetting?.('inboxSeenByOrg', withSeen(api.settings?.(), R.current.orgId ?? api.orgId?.() ?? null, a.ids)); } catch { /* persisting is best effort */ } }
1201
+ const top = newestFirst(items).slice(0, 10);
1202
+ R.current.inboxList = top;
1203
+ const numOf = (it) => top.findIndex((x) => x.id === it.id) + 1; // each arrival names its own /inbox N (0: not in the latest 10)
1204
+ const ann = announceFresh(a.fresh, barOpts().announced, { mutes: inboxMutes() }); // repeats of an announced title and muted sources print nothing
1205
+ const lines = collapseArrivals(ann.print, numOf, arrivalLine); // APPROVAL-BURST: a burst of one agent's same-tool approvals is ONE line
1206
+ if (AWAY.current.afk) { const col = (AWAY.current.col ||= newCollected()); for (const it of a.fresh) if (!isApprovalItem(it) && !isMuted(it, inboxMutes())) col.inbox.push(it); } // DND: collected for /morningreport, never announced
1207
+ if (!AWAY.current.afk) for (const l of lines.slice(0, 3)) say(l, 'warn'); // AFK-QUIET-CARDS: away keeps notices quiet
1208
+ if (lines.length > 3 && !AWAY.current.afk) say(`📨 ${lines.length - 3} more · /inbox`, 'warn');
1209
+ dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
1210
+ } catch { R.current.inboxCost = Math.max(R.current.inboxCost || 0, Date.now() - t, 3000); /* a failed read backs off too; the last bar stays */ } finally { R.current.inboxBusy = false; }
1211
+ };
1212
+ // ONE pending set (transcript HIGH c80bcdf776d2): the bar count, the roster's approval row(s) and the all-clear all read the
1213
+ // approvals poll's items. The roster endpoint's own approval rows are replaced by rows derived from that set, atomically.
1214
+ const syncApprovalRows = () => {
1215
+ const ro = R.current.roster, open = R.current.openItems;
1216
+ if (!open || !ro.allRows || !api.roster) return; // no roster source in this window: the bar count alone speaks
1217
+ const now = Date.now();
1218
+ const keep = (x) => x.kind !== 'approval';
1219
+ const next = [...ro.allRows.filter(keep), ...open.map((it) => fromApproval(it, now)).filter(Boolean)];
1220
+ ro.allRows = next; ro.rows = selectRows(next, { all: ro.all });
1221
+ if (!ro.rows.length) { ro.focus = false; ro.peek = false; ro.confirm = null; }
1222
+ ro.sel = Math.min(ro.sel, Math.max(0, ro.rows.length - 1));
1043
1223
  };
1044
1224
  const refreshNotify = async () => {
1045
1225
  if (BL.current) return;
@@ -1048,10 +1228,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1048
1228
  const items = await api.approvals();
1049
1229
  if (N.authLost) say('Signed in again: approvals are back.', 'ok');
1050
1230
  else if (N.slow) say('Quox server is back.', 'ok');
1051
- N.fails = 0; N.authLost = false; N.slow = false;
1231
+ N.fails = 0; N.authLost = false; N.slow = false; R.current.readOkAt = Date.now();
1052
1232
  R.current.deckItems = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); // QUOXDECK: numbered as /approvals N
1053
- dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
1233
+ R.current.openItems = items; syncApprovalRows();
1234
+ dispatch({ type: 'notify', patch: { approvals: items.length, error: null, slow: false } });
1054
1235
  refreshInboxBar(); // HITL-INBOX-BAR: rides the same poll, never blocks it
1236
+ if (api.otherOrgApprovals && !R.current.othersBusy) { // other orgs' counts: the helper's own 60s cache bounds the cost; never awaited, never blocks
1237
+ R.current.othersBusy = true;
1238
+ Promise.resolve().then(() => api.otherOrgApprovals()).then(async (v) => { if (v?.checked) { dispatch({ type: 'notify', patch: { others: v.waiting || [] } }); await autoOthers(v); } }, () => {}).finally(() => { R.current.othersBusy = false; });
1239
+ }
1055
1240
  retryOrgName();
1056
1241
  dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
1057
1242
  R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
@@ -1060,6 +1245,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1060
1245
  const item = await enrichPlanItem(raw, { objectiveGet: api.objectiveGet, agents: api.agents, orgs: api.orgs, cache: (R.current.planCache ||= new Map()) });
1061
1246
  dispatchEv({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
1062
1247
  }
1248
+ autoArrive(items); // APPROVALS-AUTO: after the loop, so this conversation's own inline cards are already known (and skipped)
1063
1249
  } catch (e) {
1064
1250
  N.fails++; // counted apart from an empty inbox
1065
1251
  const msg = e?.message || 'failed';
@@ -1070,7 +1256,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1070
1256
  return;
1071
1257
  }
1072
1258
  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 } });
1259
+ // APPROVALS-POLL: one or two misses keep the last-known count on screen; only a sustained failure blanks it.
1260
+ if (approvalsPollStale({ fails: N.fails, lastOkAt: R.current.readOkAt ?? R.current.startedAt, now: Date.now() })) dispatch({ type: 'notify', patch: { error: msg, slow: true } });
1074
1261
  }
1075
1262
  };
1076
1263
  React.useEffect(() => {
@@ -1107,7 +1294,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1107
1294
  // model's provider, else the provider the last turn ran on. Unknown yet: say nothing; a failing turn says why.
1108
1295
  const mine = String(MC.current?.provider || S.current?.ctx?.provider || '').toLowerCase();
1109
1296
  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`) } });
1297
+ if (hits) {
1298
+ // Limited / out of credits on the model in use: offer a working one (y/N), never switch on its own.
1299
+ // Dismissed (n/Esc/chat) stays dismissed until the health state changes.
1300
+ let offer = null;
1301
+ try {
1302
+ const sig = JSON.stringify(list.map(e => [e.provider, e.state, e.resets_at || null]));
1303
+ if (R.current.limitedSig !== sig) { R.current.limitedSig = sig; R.current.limitedDismissed = false; }
1304
+ if (hits.length && !R.current.limitedDismissed && api.connections) {
1305
+ const [res, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
1306
+ offer = limitedOffer(list, MC.current, res, caps, mine);
1307
+ }
1308
+ } catch { /* no offer is the safe answer when the server cannot say what is ready */ }
1309
+ R.current.limitedAsk = offer ? { ...offer, at: Date.now() } : null;
1310
+ const lines = hits.map(e => `⚠ ${e.line || e.provider + ' ' + e.state}: chat on it fails until then`);
1311
+ R.current.limitedBase = lines.slice();
1312
+ if (offer) lines.push(askText(`Switch to ${offer.id} (${offer.why})?`));
1313
+ dispatch({ type: 'notify', patch: { providerLines: lines } });
1314
+ }
1111
1315
  }
1112
1316
  if (!stopped) timer = setTimeout(tick, 60000);
1113
1317
  };
@@ -1125,11 +1329,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1125
1329
  applyProgress(r.rows, (R0.progress ||= new Map()), Date.now());
1126
1330
  R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
1127
1331
  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; }
1332
+ R0.rows = r.rows; syncApprovalRows(); R0.at = Date.now(); // when these rows were last read: a sign-out shows them as "as of" this time
1333
+ if (!AWAY.current.afk) DC.current.watchRoster(rowsWithoutLoopCards(R0.allRows, (id) => LC.current.has(id))).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); // an objective or loop that just finished or failed summons its dragon (companion mode on, not while typing)
1334
+ const at = R0.rows.findIndex(x => x.key === prevKey);
1335
+ R0.sel = at >= 0 ? at : Math.min(R0.sel, Math.max(0, R0.rows.length - 1));
1336
+ if (!R0.rows.length) { R0.focus = false; R0.peek = false; R0.confirm = null; }
1133
1337
  if (!R.current.closed) bump(n => n + 1);
1134
1338
  } catch { /* api.roster handles per-source failures; nothing to show */ }
1135
1339
  };
@@ -1262,7 +1466,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1262
1466
  if (r?.status === 'denied') return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'denied' });
1263
1467
  if (r && (r.status === 'approved' || r.status === 'error') && r.executed_at) {
1264
1468
  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 });
1469
+ // Command reached the host and exited >= 1 (not a connect/auth failure): say "ran, exit N", not "failed".
1470
+ const rr = r.result && typeof r.result === 'object' ? r.result : null;
1471
+ const exit = r.status === 'error' && rr && Number.isInteger(rr.exit_code) && rr.exit_code >= 1 && !/handshake failed|unable to authenticate|no supported methods|failed to connect|connection refused|econnrefused|no route to host|connect etimedout/i.test(String(rr.error || '')) ? rr.exit_code : undefined;
1472
+ return dispatch({ type: 'approval.result', callId: run.callId, outcome: r.status === 'error' ? 'failed' : 'done', text, ...(exit !== undefined ? { exit } : {}) });
1266
1473
  }
1267
1474
  } catch { /* keep polling until the deadline */ }
1268
1475
  }
@@ -1293,7 +1500,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1293
1500
  };
1294
1501
  // QUOXON-STEPUP: the server decides what needs a person. We try with any live step-up token; on 403 step_up_required
1295
1502
  // we ask for the PIN (masked card) and retry once. `proactive` (/authorise) asks first. Deny never needs a PIN.
1503
+ // PRESENCE (owner 2026-11-04): approving, denying or answering a card is the owner being here, PIN or not: close an open away
1504
+ // window server-side and stamp last-seen, so the next "While you were away" covers real absence only.
1505
+ const ownerActed = (action) => { touchSeen(true); markPresent(action, { quiet: true }); };
1296
1506
  const respondWithStepUp = async (card, action, { proactive = false, reason = null, bulk = false } = {}) => {
1507
+ ownerActed(action === 'approve' ? 'approval' : 'deny');
1297
1508
  const via = `tty ${sessionLabel()}`;
1298
1509
  const subject = { line1: `${action === 'approve' ? 'approve' : 'reject'} ${String(card.tool || card.inboxItemId || 'item')}${card.target ? ` · ${String(card.target)}` : ''}`, raisedBy: card.raisedBy || null, risk: card.risk || null };
1299
1510
  const ask = async () => {
@@ -1366,10 +1577,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1366
1577
  catch (e) { say(`⚠ couldn't re-check approvals (${clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80)}); /approvals to look again`, 'warn'); return; }
1367
1578
  const gone = walkAnsweredIds();
1368
1579
  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; }
1580
+ // the closing line repeats the other-org count + switch hint the walk opened with (the helper's own cache: no new token mints)
1581
+ const sayOthers = async () => { try { const v = api.otherOrgApprovals ? await api.otherOrgApprovals() : null; for (const l of crossOrgTuiLines(v)) say(l, 'warn'); } catch { /* the hint is optional */ } };
1582
+ if (!open.length) { say('✔ Nothing else is currently waiting for you.', 'ok'); await sayOthers(); return; }
1370
1583
  const fresh = open.filter(x => !W.ids.includes(x.id)).length;
1371
- if (fresh) say(`⏸ ${fresh} more arrived while you were answering · /approvals`, 'warn');
1584
+ AO.current.noticeAt = Date.now(); // APPROVALS-AUTO: a bare y/n right after this notice is the card's, never chat
1585
+ const added = AO.current.st.enabled ? autoArrive(open).added.length : 0; // only what is NEW since this window opened pops; older items stay a count
1586
+ if (added) { say(`⏸ ${added} more arrived while you were answering · /approvals`, 'warn'); say(`Opening ${added === 1 ? 'it' : 'them'} next by itself${fresh > added ? `; ${fresh - added} older still waiting` : ''}.`, 'dim'); }
1587
+ else if (fresh && W.auto) say(`⏸ ${fresh} older still waiting · /approvals`, 'warn'); // an auto-opened walk held only the new ones: the rest were there before
1588
+ else if (fresh) say(`⏸ ${fresh} more arrived while you were answering · /approvals`, 'warn');
1372
1589
  else say(`⏸ ${open.length} still waiting (skipped) · /approvals`, 'warn');
1590
+ await sayOthers();
1373
1591
  };
1374
1592
  const walkShow = () => {
1375
1593
  endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
@@ -1389,9 +1607,23 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1389
1607
  const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
1390
1608
  W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
1391
1609
  say(' ');
1610
+ if (it.__orgName) say(`in ${it.__orgName} · answered there directly; this window stays in ${orgShown() || 'its own org'}`, 'warn'); // APPROVALS-AUTO: another org's card says whose it is
1392
1611
  c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
1393
1612
  ask('Your choice, with an optional note', { who: it.payload.session || '', action: '', reason: '', keys: '· Esc stops', plain: true });
1394
1613
  };
1614
+ // DESTRUCTIVE-CONFIRM: a delete is approved only by typing the object's name. y opens this step; Esc or n goes back.
1615
+ const startDelConfirm = (obj, run) => {
1616
+ R.current.delConfirm = { name: obj.name, run, at: Date.now() };
1617
+ say(askText(`Type the name to delete it: ${obj.name}`, { keys: 'Esc or n cancels', action: `delete ${obj.label}`, reason: 'A delete is not approved by a single key' }), 'ask');
1618
+ bump(n => n + 1);
1619
+ };
1620
+ const cancelDelConfirm = () => { R.current.delConfirm = null; B.current = initialBuffer(); say('Cancelled; nothing deleted. The card is still open.', 'dim'); bump(n => n + 1); };
1621
+ const delConfirmLine = (text) => {
1622
+ const d = R.current.delConfirm; const t = String(text ?? '').trim();
1623
+ if (t === d.name) { R.current.delConfirm = null; d.run(); return; }
1624
+ if (!t || /^n(o)?$/i.test(t)) { cancelDelConfirm(); return; }
1625
+ say(`That is not the name, so nothing was deleted. Type ${d.name} exactly, or Esc to cancel.`, 'warn');
1626
+ };
1395
1627
  const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
1396
1628
  let decs = (list || []).filter(isDecision);
1397
1629
  if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
@@ -1412,6 +1644,89 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1412
1644
  walkShow();
1413
1645
  return undefined;
1414
1646
  };
1647
+ // APPROVALS-AUTO (owner 2026-11-06: "just make approvals pop up, i am tired of opening them"): a NEW approval or decision
1648
+ // opens by itself as the walkthrough card. It only OPENS; it never answers. A draft, typing in the last 2s, a streaming
1649
+ // turn or another card on screen holds it until the next idle point; an open walk simply gains the card.
1650
+ const autoSkip = () => new Set([...S.current.approvals.map(x => x.inboxItemId), ...walkAnsweredIds(), ...(R.current.pendingWalk ? R.current.pendingWalk.ids : [])].filter(Boolean));
1651
+ const autoBusy = () => Boolean(askOpen() || headCard(S.current) || SU.current.isOpen() || LI.current?.isOpen?.() || CN.current?.isOpen?.() || PK.current?.isOpen?.() || R.current.rs || R.current.noteAsk || R.current.delConfirm || R.current.denyReason || K.current.cardFocus || BL.current || AWAY.current.afk);
1652
+ const autoOpenItems = (items, how) => {
1653
+ const skip = autoSkip(); const list = items.filter(x => !skip.has(x.id));
1654
+ if (!list.length) return;
1655
+ if (how === 'open') { const n = list.length; say(' '); say(`⏸ ${n === 1 ? 'A new approval just arrived; it is open below' : `${n} new approvals just arrived; they are open below, one at a time`}. Esc stops · /approvals auto off stops them opening by themselves.`, 'warn'); }
1656
+ const had = R.current.pendingWalk;
1657
+ walkStart(list.map(approvalAsWalkItem), { fresh: true, quiet: true });
1658
+ if (!had && R.current.pendingWalk) R.current.pendingWalk.auto = true;
1659
+ };
1660
+ const autoTry = () => {
1661
+ const A = AO.current; clearTimeout(A.timer); A.timer = null;
1662
+ if (A.dead) return;
1663
+ const d = openDecision(A.st, { draft: B.current.text, lastKeyAt: K.current.lastKeyAt || 0, now: Date.now(), streaming: Boolean(S.current.turn), walkOpen: Boolean(R.current.pendingWalk), askOpen: autoBusy() });
1664
+ if (!d) return;
1665
+ if (d === 'open' || d === 'join') { A.waitSaid = false; autoOpenItems(takeQueue(A.st), d); return; }
1666
+ if (d === 'wait-draft' && !A.waitSaid) { A.waitSaid = true; say(`⏸ ${A.st.queue.length === 1 ? 'A new approval is' : `${A.st.queue.length} new approvals are`} waiting; it opens once your draft is empty.`, 'dim'); }
1667
+ A.timer = setTimeout(autoTry, 400); A.timer.unref?.();
1668
+ };
1669
+ const autoArrive = (items, opts = {}) => { const A = AO.current; const r = noteArrivals(A.st, items, { skipIds: autoSkip(), ...opts }); if (A.st.queue.length) autoTry(); return r; };
1670
+ // Other orgs: read an org's open items only when its count changed (the counts helper caches 60s), with the user's own token.
1671
+ const autoOthers = async (v) => {
1672
+ const A = AO.current; const first = !A.othersDone; A.othersDone = true;
1673
+ if (!api.approvalsInOrg) return;
1674
+ const waiting = v?.waiting || []; const ids = new Set(waiting.map(w => w.id));
1675
+ A.st.queue = A.st.queue.filter(x => !x.__orgId || ids.has(x.__orgId)); // nothing waiting there any more
1676
+ for (const [id] of A.otherCounts) if (!ids.has(id)) A.otherCounts.delete(id);
1677
+ for (const w of waiting) {
1678
+ if (A.otherCounts.get(w.id) === w.count) continue;
1679
+ try {
1680
+ const items = await api.approvalsInOrg(w.id);
1681
+ A.otherCounts.set(w.id, w.count);
1682
+ autoArrive(items, { orgId: w.id, orgName: w.name, seed: first ? true : (A.st.seeded.has(w.id) ? null : false) });
1683
+ } catch { /* unreadable: the "N more waiting in" line still says so */ }
1684
+ }
1685
+ };
1686
+ // Another org's card in the walk: answered via that org's own URL (the server checks membership there). A high-risk yes
1687
+ // still takes the PIN (a one-shot token minted for THAT org); a privileged yes still needs its explicit confirm there.
1688
+ const walkCrossOrg = async (W, it, rep) => {
1689
+ const org = it.__orgName || it.__orgId; const c = it.payload?.approval_context || {};
1690
+ const keep = () => { W.at = Date.now(); if (W.card) W.card.at = W.at; return true; };
1691
+ if (it.__approval && rep.key === 'y' && isPrivileged({ tool: c.method?.tool, risk: c.risk, flag: it.payload?.privileged === true || c.privileged === true, title: it.title })) {
1692
+ say(`This one is privileged, so a yes needs its explicit confirm in ${org}: ${mark(`/org switch ${org}`)} then ${mark('/approvals')}. n denies it from here; s skips.`, 'warn');
1693
+ return keep();
1694
+ }
1695
+ const label = it.payload.options.find(o => o.key === rep.key)?.label || rep.key;
1696
+ const subject = { line1: `${it.__approval ? (rep.key === 'y' ? 'approve' : 'deny') : 'answer'} ${String(it.title || '')} (in ${org})`, raisedBy: it.payload.session || null, risk: 'high' };
1697
+ let stepUp = {};
1698
+ const pin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: it.__orgId, fresh: true, subject }); if (!r.ok) { const e = new Error(declinedText(r.reason)); e.stepUpDeclined = true; throw e; } stepUp = { 'x-step-up-token': r.token }; };
1699
+ const go = () => { ownerActed('answer'); return it.__approval
1700
+ ? api.respond(it.id, rep.key === 'y' ? 'approve' : 'reject', { via: 'walkthrough', reason: rep.note || null, org: it.__orgId, stepUp })
1701
+ : api.answerDecision(it.id, rep.key, { note: rep.note || null, org: it.__orgId, stepUp }); };
1702
+ R.current.walkSend = true;
1703
+ try {
1704
+ if (needsPin(it) && rep.key !== 'n') await pin();
1705
+ try { await go(); }
1706
+ catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; await pin(); await go(); }
1707
+ } catch (e) {
1708
+ if (e?.stepUpDeclined) { say(`Not answered: ${e.message}.`, 'warn'); return keep(); }
1709
+ if (e?.status === 404 || e?.status === 400 || e?.status === 409) { say(`"${clip(it.title || it.id, 60)}" is no longer waiting in ${org}. Moving on.`, 'dim'); W.done.add(it.id); walkAnsweredIds().add(it.id); if (R.current.pendingWalk === W) walkShow(); return true; }
1710
+ say(`Not answered in ${org} (${clip(String(e?.message || e), 80)}). The question stays: say a choice again, s to skip, or ${mark(`/org switch ${org}`)} to answer it there.`, 'warn');
1711
+ return keep();
1712
+ } finally { R.current.walkSend = false; }
1713
+ say(`✔ answered in ${org}: ${clip(decodeEntities(it.title || it.id), Math.max(20, cols - 40))} -> (${rep.key}) ${decodeEntities(label)}${rep.note ? ` (note: ${clip(rep.note, 40)})` : ''}`, 'ok');
1714
+ walkAnsweredIds().add(it.id); W.done.add(it.id); W.answered.push({ title: it.title, word: `${label} (in ${org})${rep.note ? ` (note: ${rep.note.length} characters)` : ''}` });
1715
+ if (R.current.pendingWalk === W) walkShow();
1716
+ return true;
1717
+ };
1718
+ // The stray y (2026-11-06: a y typed after "1 more arrived" went to triage-q as chat): a bare y/n while a card is queued,
1719
+ // or just after a "more arrived" notice, opens that card NOW with the key kept in the box. A key typed before the card
1720
+ // was on screen never approves what it has not shown: Enter then answers the card you can see.
1721
+ const autoStray = (text) => {
1722
+ const A = AO.current; const t = String(text).trim();
1723
+ const queued = takeQueue(A.st);
1724
+ const kept = () => { edit({ type: 'set', text: t }); say(`Your "${t}" was not sent as chat. It is kept in the box: press Enter to answer the card above.`, 'warn'); };
1725
+ if (queued.length) { autoOpenItems(queued, 'open'); if (R.current.pendingWalk) kept(); return; }
1726
+ A.noticeAt = 0;
1727
+ Promise.resolve(command('/approvals walkthrough')).then(() => { if (R.current.pendingWalk) kept(); else say(`Your "${t}" was not sent as chat: the approval it was for is no longer waiting.`, 'dim'); });
1728
+ };
1729
+ React.useEffect(() => () => { AO.current.dead = true; clearTimeout(AO.current.timer); }, []);
1415
1730
  // NOTE-CONFIRM (transcript review 2026-10-17): an answer with a note can contradict its option, so every note is confirmed first, unless it starts with "fyi".
1416
1731
  const noteNeedsConfirm = (note) => { const t = String(note ?? '').trim(); return Boolean(t && !/^fyi\b/i.test(t) && /\b(not|no|never|without|only|except|instead|unless|limit\w*|max\w*|at most|at least)\b|n't\b|\d/i.test(t)); }; // only a note that could change the choice asks first; a plain comment goes through with the note attached
1417
1732
  const noteCard = (st) => {
@@ -1445,7 +1760,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1445
1760
  if (rep.notYesNo) { say(walkNotYesNo(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1446
1761
  if (rep.defer) { W.done.add(it.id); W.skipped.push(it.id); walkShow(); return true; }
1447
1762
  if (rep.ask) { W.at = Date.now(); submit(`Explain this decision to me in two plain sentences and say what you would do, about THIS one only. Title: ${it.title}. Asked by: ${it.payload.session || 'a session'}. Why: ${it.payload.why || 'not given'}. Options: ${it.payload.options.map(o => `${o.label} (${o.key})`).join('; ')}. Decision id: ${it.id}.`); return true; }
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
1763
+ if (it.__approval && rep.key === 'y' && isDestructive(it) && R.current.delBypass !== it.id) { // a delete is confirmed by typing its name, then this same yes goes through
1764
+ W.at = Date.now(); if (W.card) W.card.at = W.at;
1765
+ startDelConfirm(destructiveObject(it), async () => { R.current.delBypass = it.id; try { await walkHandle('y'); } finally { R.current.delBypass = null; } });
1766
+ return true;
1767
+ }
1768
+ if (it.__orgId) return walkCrossOrg(W, it, rep); // APPROVALS-AUTO: another org's card is answered in THAT org, no switch
1769
+ let n = S.current.inbox.findIndex(x => x.id === it.id) + 1; // routed by id; the position is only looked up at the moment of sending
1770
+ if (!n) { // APPROVALS-AUTO: a card that opened by itself was never listed by /approvals: read the same list, in the same order, now
1771
+ try { const pl = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: pl }); R.current.shownIds = pl.map(x => x.id); n = pl.findIndex(x => x.id === it.id) + 1; }
1772
+ catch (e) { say(`Could not re-read approvals (${clip(String(e?.message || e), 80)}); nothing was sent. Say the choice again.`, 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1773
+ }
1449
1774
  if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
1450
1775
  if (askExpiry(it) && askExpiry(it) <= Date.now()) { W.done.add(it.id); (W.dropped ||= new Set()).add(it.id); refreshNotify(); say(expiredText(it), 'accent'); walkShow(); return true; }
1451
1776
  if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
@@ -1477,7 +1802,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1477
1802
  // The demo's stream carries on to its own tool result, so no "running, result later" note, no poll, no stale count.
1478
1803
  dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
1479
1804
  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 });
1805
+ if (action === 'approve' && card.tool !== 'hat.switch' && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1481
1806
  return { ok: true };
1482
1807
  } catch (e) {
1483
1808
  if (e?.status === 404 || (e?.status === 400 && /not open for response/i.test(`${e?.code || ''} ${e?.body?.error || ''} ${e?.message || ''}`))) { // already resolved elsewhere (another window, /approve all, the dashboard)
@@ -1490,6 +1815,35 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1490
1815
  finally { R.current.answering.delete(card.callId); }
1491
1816
  };
1492
1817
 
1818
+ // APPROVAL-BURST: y / n on a batch card answers every item through the normal per-item API (one call each, recorded per item).
1819
+ const answerBatch = async (b, action) => {
1820
+ if (R.current.batchBusy) return;
1821
+ R.current.batchBusy = true;
1822
+ const word = action === 'approve' ? 'approved' : 'denied'; const total = b.cards.length; let done = 0, gone = 0; const failed = [];
1823
+ try {
1824
+ for (const c of b.cards) {
1825
+ try {
1826
+ const r = await answer(c, action, { batch: true, bulk: action === 'approve' });
1827
+ if (r?.ok) done++; else if (r?.gone) gone++; else failed.push(batchLabel(c));
1828
+ } catch (e) { failed.push(`${batchLabel(c)} (${clip(String(e?.message || e), 40)})`); if (e?.stepUpDeclined) break; }
1829
+ }
1830
+ } finally { R.current.batchBusy = false; }
1831
+ const rest = total - done - gone - failed.length; // left unattempted (step-up declined)
1832
+ if (rest > 0) for (const c of b.cards.slice(total - rest)) failed.push(batchLabel(c));
1833
+ say(`${word} ${done} of ${total}${gone ? ` (${gone} already handled elsewhere)` : ''}${failed.length ? `. Not ${word}: ${clip(failed.join(', '), Math.max(30, cols - 40))}` : ''}`, failed.length ? 'warn' : 'ok');
1834
+ };
1835
+ // p on a batch card: the existing one-by-one walk, for just those items.
1836
+ const walkBatch = async (b) => {
1837
+ let items;
1838
+ try { items = await api.approvals(); } catch (e) { say(`Could not read the approvals (${clip(String(e?.message || e), 80)}); nothing changed.`, 'warn'); return; }
1839
+ const flat = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item));
1840
+ dispatch({ type: 'inbox', items: flat }); R.current.shownIds = flat.map(x => x.id);
1841
+ const ids = new Set(b.cards.map(c => c.inboxItemId));
1842
+ for (const x of queueOf(S.current)) K.current.dismissed.add(x.callId); // the walk owns the keys, as with Tab
1843
+ K.current.confirm = false; bump(n => n + 1);
1844
+ await walkStart(flat.filter(x => ids.has(x.id)).map(approvalAsWalkItem), { fresh: true });
1845
+ };
1846
+
1493
1847
  // ---- turn runner ---------------------------------------------------------------------------
1494
1848
  // Z2H-R2B: with no AI connected a turn is answered here, whole and wrapped, without a server call.
1495
1849
  async function aiConnected() {
@@ -1768,7 +2122,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1768
2122
  }
1769
2123
 
1770
2124
  React.useEffect(() => {
1771
- if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length) wakeDragon(); // an approval always wakes it
2125
+ if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length && !AWAY.current.afk) wakeDragon(); // an approval always wakes it
1772
2126
  const s = S.current;
1773
2127
  if (!s.turn && s.autoDrain && s.pending.length) {
1774
2128
  const next = s.pending[0];
@@ -1779,7 +2133,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1779
2133
 
1780
2134
  // A queued or steered draft that is a slash command runs as that command when its turn comes; it is never sent as chat.
1781
2135
  function runQueued(text, { checked = false } = {}) {
1782
- if (text.startsWith('/') && !text.includes('\n')) { command(text); return; }
2136
+ if (isOneLineCommand(text)) { command(text); return; }
1783
2137
  const stray = checked ? null : strayCardAnswer(text, commandNames()); // TUI-INPUT: "n/help" = a card answer that leaked into the composer
1784
2138
  if (stray) { R.current.strayAsk = { text, command: stray.command, at: Date.now() }; ask(strayQuestion(stray)); return; }
1785
2139
  dispatch({ type: 'user', text, queued: true });
@@ -1793,30 +2147,91 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1793
2147
 
1794
2148
  // PRESENCE (owner 2026-10-16): /afk and /sleep tell the server, so sessions and LLMs know you are not at this
1795
2149
  // terminal (approvals still queue; nothing is approved). /back or any typed turn marks you present.
1796
- async function setPresence(state, note) {
2150
+ async function setPresence(state, note, { human = false, dnd = false } = {}) {
1797
2151
  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 }); }
2152
+ R.current.presenceSetAt = Date.now();
2153
+ const r0 = await setPresenceRaw(state, note, { human, dnd }); R.current.presenceUnsynced = !r0.ok; return r0;
2154
+ }
2155
+ async function setPresenceRaw(state, note, { human = false, dnd = false } = {}) {
2156
+ try { return await api.setPresence(state, { note: note || undefined, human, dnd, slots: where.current.slot ? [where.current.slot] : undefined }); }
1799
2157
  catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
1800
2158
  }
1801
2159
  const presenceFail = (r) => say(`Sessions can't see that (${r.status ? `HTTP ${r.status}` : r.message || 'failed'}): this window still collects for /back, but other sessions do not know.`, 'warn');
1802
- function markPresent(action = 'chat') {
1803
- if (/^\/(afk|sleep|back|away)(\s|$)/.test(String(action).replace(/^slash /, ''))) return;
2160
+ // DO-NOT-DISTURB (owner 2026-10-28, "sleep means sleep"): while AWAY.afk is set (/dnd, /afk, /sleep) the window collects
2161
+ // instead of announcing: no notice lines, cards, bells or dragon moments. /morningreport is the grouped digest.
2162
+ const dndNote = (until) => DND_NOTE + (until ? ` until ${String(new Date(until).getHours()).padStart(2, '0')}:${String(new Date(until).getMinutes()).padStart(2, '0')}` : '');
2163
+ function holdSlotMsg(m, label) {
2164
+ if (!AWAY.current.afk) return false;
2165
+ (AWAY.current.col ||= newCollected()).slots.push({ from: label || m.from || 'slot', text: String(m.text || '') });
2166
+ bump(n => n + 1);
2167
+ return true;
2168
+ }
2169
+ async function dndOn(until = null) {
2170
+ const at = Date.now(); const untilMs = until ? untilEpoch(until, at) : null;
2171
+ const note = dndNote(untilMs);
2172
+ AWAY.current.last = at; AWAY.current.afk = { at, note, dnd: true, explicit: true, until: untilMs }; AWAY.current.col ||= newCollected();
2173
+ try { away?.write?.(profile, at); } catch { /* in-memory mark still works */ }
2174
+ say(`🌙 Do not disturb is on${untilMs ? ` until ${note.slice(-5)}` : ''}. Everything is collected for the morning: /morningreport shows it, /dnd off or /back ends it.`, 'ok');
2175
+ const pr = await setPresence('sleeping', note, { dnd: true });
2176
+ if (!pr.ok) presenceFail(pr);
2177
+ bump(n => n + 1);
2178
+ }
2179
+ // MORNINGREPORT-WINDOW (owner 2026-11-03): ending DND/away must not erase what it collected. The most recent window
2180
+ // (start, end, collected) is kept so /morningreport answers about it whether or not DND is on at the moment of asking.
2181
+ function archiveWindow() {
2182
+ const a = AWAY.current.afk, c = AWAY.current.col;
2183
+ if (!a && !c) return;
2184
+ AWAY.current.lastWindow = { since: c?.since || a?.at || Date.now(), until: Date.now(), col: c || newCollected() };
2185
+ }
2186
+ async function dndOff() {
2187
+ archiveWindow();
2188
+ AWAY.current.afk = null;
2189
+ const n = waitingCount(AWAY.current.col, 0); AWAY.current.col = null; // the archived window keeps it; the next window starts empty
2190
+ say(`Do not disturb is off.${n ? ` ${n} collected while it was on: /morningreport shows them.` : ''}`, 'ok');
2191
+ const pr = await setPresence('present', undefined, { human: true });
2192
+ if (!pr.ok) presenceFail(pr);
2193
+ bump(n2 => n2 + 1);
2194
+ }
2195
+ async function sleepNow() { // owner 2026-10-29: one step to bed: sleeping AND do-not-disturb
2196
+ const at = Date.now(); const note = dndNote(null);
2197
+ AWAY.current.last = at; AWAY.current.afk = { at, note, dnd: true, explicit: true, until: null }; AWAY.current.col ||= newCollected();
2198
+ try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
2199
+ say("🌙 Asleep, do not disturb on: everything collects for the morning. /morningreport or /back when you're up.", 'ok');
2200
+ const pr = await setPresence('sleeping', note, { dnd: true });
2201
+ if (!pr.ok) presenceFail(pr);
2202
+ bump(n => n + 1);
2203
+ }
2204
+ function offerDndToo() {
2205
+ if (R.current.dndAsk) return;
2206
+ R.current.dndAsk = { at: Date.now(), off: false };
2207
+ say(askText('Turn on do not disturb too? (y/N)', { action: 'collect arrivals quietly until you are back (same as /dnd on)' }), 'ask');
2208
+ }
2209
+ function offerDnd() {
2210
+ if (AWAY.current.afk || R.current.dndAsk) return;
2211
+ R.current.dndAsk = { at: Date.now(), off: false };
2212
+ say(askText('Turn on do not disturb until morning?', { action: 'collect arrivals quietly until you are back (same as /dnd on)', title: 'Going to sleep?' }), 'ask');
2213
+ }
2214
+ function markPresent(action = 'chat', { quiet = false } = {}) {
2215
+ if (/^\/(afk|sleep|back|away|dnd|donotdisturb|morningreport)(\s|$)/.test(String(action).replace(/^slash /, ''))) return;
1804
2216
  if (AWAY.current.afk) { // this window set the flag: a typed turn here is the owner back (explicit, as before)
2217
+ archiveWindow();
1805
2218
  AWAY.current.afk = null;
1806
- setPresence('present').then((r) => { if (!r.ok) presenceFail(r); }, () => {});
2219
+ setPresence('present', undefined, { human: true }).then((r) => { if (!r.ok) presenceFail(r); }, () => {});
1807
2220
  return;
1808
2221
  }
1809
2222
  // PRESENCE (owner 2026-10-19): a window that never set the flag (restarted TUI) still clears the server's stale one,
1810
2223
  // but only from a real terminal (api.noteActivity is a no-op for --print, pipes and proof runs). Welcome shows once.
1811
2224
  if (!api.noteActivity) return;
1812
- api.noteActivity(action).then((r) => {
2225
+ api.noteActivity(action, quiet ? { quiet: true } : undefined).then((r) => {
1813
2226
  if (r?.welcome?.away_text && !AWAY.current.welcomed) { AWAY.current.welcomed = true; say(`✔ Welcome back, you were away ${r.welcome.away_text}`, 'ok'); }
1814
2227
  }, () => {});
1815
2228
  }
1816
2229
  function submit(text) {
1817
2230
  touchSeen();
2231
+ AR.current.busyAt = Date.now(); // a just-sent turn is in flight before S.turn shows it: auto-restart must not fire in that gap
1818
2232
  markPresent(/^\//.test(text.trim()) ? `slash ${text.trim().split(/\s+/)[0]}` : 'chat');
1819
2233
  dispatch({ type: 'user', text });
2234
+ if (!/^\//.test(text.trim()) && sleepIntent(text)) offerDnd(); // never silently: it asks
1820
2235
  const tip = intentHint(text); // a hint only: the turn still goes to the model
1821
2236
  if (tip) say(tip);
1822
2237
  const panel = intentPanel(text); // the same panel as the command, from local facts, above the answer
@@ -1920,7 +2335,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1920
2335
  if (cmd === '/cost') return help(costModel(LG.current));
1921
2336
  if (cmd === '/status') {
1922
2337
  let hl = R.current.lastHealth || null;
1923
- if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; }
2338
+ if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; if (hl) R.current.lastGoodAt = Date.now(); }
1924
2339
  return help(statusModel({ ...base, profile, org: orgShown(), serverUrl: api.serverUrl?.() || hl?.url || null, health: hl, slot: where.current.slot, sessionName: SN.current.name, version, local: local && !hl }));
1925
2340
  }
1926
2341
  if (cmd === '/context') {
@@ -2199,6 +2614,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2199
2614
  const cand = loginCandidates.find(x => x !== me && okProfileName(x)) || 'me';
2200
2615
  return offerLogin(cand, `This window is an agent login (${me}) and no human login is signed in on this machine.`);
2201
2616
  }
2617
+ // STARTUP OFFER (owner 2026-10-28): `quox` opened on an agent login while human logins exist asks once, y/N, to
2618
+ // restart as the first human profile (the rest are in /profile). N keeps the agent window; the intro warning stays.
2619
+ function offerStartupSwitch() {
2620
+ const { profile: me, agentLogin: isAgent } = WHO.current;
2621
+ if (!isAgent) return undefined;
2622
+ const human = humanProfiles.find(x => x !== me && okProfileName(x));
2623
+ if (!human) return undefined;
2624
+ R.current.profileAsk = { name: human, at: Date.now() };
2625
+ return say(startupSwitchAskLine(me, human, humanProfiles.filter(x => x !== me && x !== human).length), 'ask');
2626
+ }
2627
+ // Plain-language slot attach: a known slot gets the /slot offer (y runs it); anything else is ordinary chat.
2628
+ async function offerSlotAttach(nm, text) {
2629
+ let rows = null;
2630
+ try { rows = await api.slots(); } catch { rows = null; }
2631
+ const known = Array.isArray(rows) && rows.find(x => String(x.name).toLowerCase() === nm.toLowerCase());
2632
+ if (!known) { submit(text); return; }
2633
+ const line = `/slot ${known.name}`;
2634
+ dispatch({ type: 'user', text });
2635
+ R.current.suggestAsk = { line, at: Date.now() };
2636
+ ask(`Run ${line}?`, { action: line, reason: `"${clip(text, 60)}" is a request to attach to that slot` });
2637
+ }
2202
2638
  function offerLogin(name, lead) {
2203
2639
  return armRun(loginRunRequest(name, `${lead} Sign ${name} in, then /profile ${name} switches this window to it.`, runNow()), { who: 'Quox', glyph: 'Ⓠ', text: '' });
2204
2640
  }
@@ -2241,17 +2677,37 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2241
2677
  target = c.onPath || undefined;
2242
2678
  }
2243
2679
  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 });
2680
+ AR.current.lastRestartAt = Date.now(); api.saveSetting?.('lastRestartAt', AR.current.lastRestartAt); // the next window reads it: restarts coalesce
2681
+ onRestart({ conversationId: S.current.ctx.conversationId || null, name: SN.current.name || null, slot: where.current.slot || null, attach: SN.current.agent ? `agent:${SN.current.agent}` : (SN.current.slotTarget ? `slot:${SN.current.slotTarget}` : null), org: api.windowOrgId?.() || null, ...(MD.current === 'plan' ? { mode: 'plan' } : {}), ...(R.current.declinedSlot || restartDeclined ? { declined: R.current.declinedSlot || restartDeclined } : {}), bin: target, ...extra });
2245
2682
  return exit(30);
2246
2683
  }
2247
2684
 
2685
+ // INSTANT-FEEDBACK (owner 2026-10-26): every slash command goes through here, so any async handler shows `⠋ <verb>…`
2686
+ // within 100 ms until its result or error line. A command run from inside another one reuses the outer spinner.
2248
2687
  async function command(line) {
2688
+ if (R.current.spinOn || !/^\s*\/(?!\/)/.test(String(line))) return commandRun(line);
2689
+ R.current.spinOn = true;
2690
+ try {
2691
+ return await runWithSpinner(() => commandRun(line), {
2692
+ verb: spinnerVerb(line),
2693
+ show: (t) => dispatch({ type: 'notify', patch: { spinner: t } }),
2694
+ hide: () => dispatch({ type: 'notify', patch: { spinner: '' } }),
2695
+ register: (stop) => { R.current.spinStop = stop; }
2696
+ });
2697
+ } finally { R.current.spinOn = false; R.current.spinStop = null; }
2698
+ }
2699
+ // A command the TUI dispatches from inside another one (/sleep -> /dragon sleep) is not the owner typing: it never marks present.
2700
+ async function commandRun(line) {
2701
+ const nested = (R.current.cmdDepth || 0) > 0; R.current.cmdDepth = (R.current.cmdDepth || 0) + 1;
2702
+ try { return await commandBody(line, nested); } finally { R.current.cmdDepth -= 1; }
2703
+ }
2704
+ async function commandBody(line, nested) {
2249
2705
  if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/devnote');
2250
2706
  const [cmd0, arg] = line.trim().split(/\s+/);
2251
2707
  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
2708
+ if (!nested && !['/afk', '/sleep', '/back', '/away', '/dnd', '/donotdisturb', '/morningreport'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
2253
2709
  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
2710
+ if (cmd !== '/devnote') transcript?.command((/^\/(authenticate|authorise|authorize)$/.test(cmd) && !/^[\d,\s-]*$/.test(line.trim().replace(/^\S+\s*/, ''))) || (cmd === '/verify-email' && /(^|\D)\d{6}(\D|$)/.test(line)) ? cmd : line); // QUOXON-STEPUP: never record a stray argument that might be a PIN
2255
2711
  const restArg = line.trim().replace(/^\S+\s*/, '');
2256
2712
  touchSeen();
2257
2713
  if (TIPS.some(x => tryCommand(x) === cmd) && !TP.current.used.includes(cmd)) { TP.current.used = [...TP.current.used, cmd]; api.saveSetting?.('tipsUsed', TP.current.used); } // using a thing retires its tip
@@ -2312,10 +2768,16 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2312
2768
  const first = to[0] || m[1].toLowerCase();
2313
2769
  let rows = [];
2314
2770
  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);
2771
+ let known = !looksLikeAddress(m[1]) && !!resolveSayTarget(first, Array.isArray(rows) ? rows : [], SC.current.senders);
2316
2772
  const last = R.current.lastSay;
2773
+ let lookupFailed = false;
2774
+ // Not in this org's list: a slot in another of the user's own orgs (or name@org) is still a target, not message text.
2775
+ if (!known && last && !looksLikeAddress(m[1]) && api.slotLocate) {
2776
+ try { known = !!(await api.slotLocate(first))?.found; } catch { lookupFailed = true; }
2777
+ }
2317
2778
  if (!known && (looksLikeAddress(m[1]) || last)) {
2318
2779
  if (last && Date.now() - last.at < 2 * 3600000) {
2780
+ if (lookupFailed) say(`Could not check your other orgs for "${first}", so this goes to ${last.slot}, the last target.`, 'warn');
2319
2781
  R.current.sayLastAsk = { slot: last.slot, text: restArg.trim(), at: Date.now() };
2320
2782
  return say(askText(`Send to ${last.slot}?`, { keys: 'y send · n cancel', action: `send the whole message to ${last.slot}` }), 'ask');
2321
2783
  }
@@ -2326,13 +2788,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2326
2788
  // QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
2327
2789
  const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
2328
2790
  const head = `(to ${slots.join(', ')}) `;
2329
- for (const sl of slots) await sendToSlot(sl, head + m[2].trim());
2791
+ for (const sl of slots) await sendToSlot(sl, head + m[2].trim(), { draft: null });
2330
2792
  return undefined;
2331
2793
  }
2332
2794
  case '/join': { const nm = roomName(restArg); if (!nm) return say('Usage: /join <slot> (or #<slot>)', 'warn'); return joinRoom(nm); }
2333
2795
  case '/leave': { const nm = roomName(restArg); if (!nm) return say('Usage: /leave <slot>', 'warn'); return leaveRoom(nm); }
2334
2796
  case '/rooms': case '/groups': return showRooms();
2335
2797
  case '/broadcast': case '/page':
2798
+ if (cmd === '/broadcast' && sleepIntent(restArg)) queueMicrotask(offerDnd); // never silently: it asks, after the card
2336
2799
  return runBroadcast({ api, kind: cmd === '/page' ? 'page' : 'broadcast', arg: restArg, from: myLabel(), say, card: (m) => dispatch({ type: 'bcard', card: m }), isClosed: () => R.current.closed });
2337
2800
  case '/ack': return runAck({ api, arg: restArg, slot: where.current.slot || null, say });
2338
2801
  case '/send': { // QUOXON-MESH: a local text file into a slot (inline, 64 KB cap, secrets scrubbed on a yes)
@@ -2409,7 +2872,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2409
2872
  }
2410
2873
  SN.current.slotTarget = null;
2411
2874
  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'); });
2875
+ DC.current.attach(m.agent.id, { greet: greetThis }).then(() => { const n = DC.current.takeArtNotice() || DC.current.narrowNotice({ cols, rows: termRows(stdout) ?? 24 }); if (n) say(n, 'warn'); });
2413
2876
  say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.`, 'ok');
2414
2877
  if (both && pt.kind !== 'agent') say(`${pt.name} is also a slot: /attach slot:${pt.name} for that`, 'warn');
2415
2878
  if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
@@ -2569,20 +3032,41 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2569
3032
  }
2570
3033
  case '/deck': { // provided by the quoxdeck extension when loaded; otherwise says where it comes from (the registry entry carries ext)
2571
3034
  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');
3035
+ const dw = restArg.trim().split(/\s+/)[0];
3036
+ if (dw && dw !== 'on') return say('/deck comes from the quoxdeck extension, which is not loaded. /deck on loads it.', 'warn');
3037
+ const lr = await DK.current.load('quoxdeck'); // LAZY PATH: not loaded -> find out why and OFFER (y/N), never a raw build error
3038
+ if (lr.ok) { extAdd('quoxdeck'); say(lr.text, 'ok'); return undefined; }
3039
+ const offer = await deckOffer({ isInstalled: async () => !lr.notFound });
3040
+ if (!offer) return say(lr.text, 'warn');
3041
+ if (!offer.steps) return say(offer.text, 'warn');
3042
+ R.current.deckAsk = { offer, at: Date.now() };
3043
+ return ask(offer.question, { action: offer.steps.map(s => s.cmd).join(' && '), reason: 'sudo, if shown, asks for your own password in this terminal' });
2573
3044
  }
2574
- case '/ext': { // EXTENSIONS: /ext list|on|off <name>
2575
- const [w, nm] = restArg.trim().split(/\s+/);
3045
+ case '/ext': { // EXTENSIONS: /ext list|on|off|tab|set|settings <name>
3046
+ const parts = restArg.trim().split(/\s+/).filter(Boolean);
3047
+ const [w, nm] = parts;
2576
3048
  const list = readExtList();
2577
3049
  if (!w || w === 'list') {
2578
3050
  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'));
3051
+ return say(list.map(x => `${x.name}: ${DK.current.isLoaded(x.name) ? `on (found: ${DK.current.where(x.name)})` : DK.current.failure(x.name) ? `failed: ${DK.current.failure(x.name)}` : x.enabled ? 'listed, not loaded' : 'off'}`).join('\n'));
2580
3052
  }
2581
3053
  if ((w === 'on' || w === 'off') && nm) {
2582
3054
  if (w === 'on') { if (!list.some(x => x.name === nm)) extAdd(nm); else extSetEnabled(nm, true); await extLoad(nm, false); return undefined; }
2583
3055
  extSetEnabled(nm, false); const r = DK.current.unload(nm); return say(r.text, 'ok');
2584
3056
  }
2585
- return say('Usage: /ext list|on|off <name>', 'warn');
3057
+ if (w === 'tab') { // focus a panel; off collapses; alone cycles
3058
+ if (nm === 'off') { DK.current.hidePanel(); return say('Extension panel closed.'); }
3059
+ if (!nm) { const n = DK.current.cycle(); return n ? say(`${n} panel focused. Esc returns to the composer.`, 'ok') : say('No extension has a panel. /ext list', 'warn'); }
3060
+ const r = DK.current.focusPanel(nm); return say(r.text, r.ok ? 'ok' : 'warn');
3061
+ }
3062
+ if (w === 'settings' && nm) {
3063
+ const r = DK.current.settingsInfo(nm); if (!r.ok) return say(r.text, 'warn');
3064
+ return say(r.defs.map(d => `${d.key} = ${JSON.stringify(r.values[d.key])} (${d.type}${d.choices ? `: ${d.choices.join('|')}` : ''}, default ${JSON.stringify(d.default)}) ${d.help}`).join('\n'));
3065
+ }
3066
+ if (w === 'set' && nm && parts.length >= 4) {
3067
+ const r = DK.current.setSetting(nm, parts[2], parts.slice(3).join(' ')); return say(r.text, r.ok ? 'ok' : 'warn');
3068
+ }
3069
+ return say('Usage: /ext list | on|off <name> | tab [<name>|off] | settings <name> | set <name> <key> <value>', 'warn');
2586
3070
  }
2587
3071
  case '/watch': return KC.current.watch(restArg);
2588
3072
  case '/automate': return KC.current.automate(restArg);
@@ -2679,18 +3163,63 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2679
3163
  if (!text) return say(cmd === '/gripe!' ? 'Nothing to file. /gripe <text> first, or /gripe! <text>.' : 'Usage: /gripe <what is wrong> (/gripe! <text> files without asking)', 'warn');
2680
3164
  if (cmd === '/gripe') { // ask first: filing makes a public-ish issue
2681
3165
  R.current.pendingGripe = { text, at: Date.now() };
2682
- say(`File this as a GitHub issue via GRIPE? "${clip(text, Math.max(20, cols - 50))}"`);
3166
+ const nl = text.split('\n').length;
3167
+ say(`${gripeLead} File this as a GitHub issue via GRIPE? "${clip(text.split('\n')[0], Math.max(20, cols - 50))}"${nl > 1 ? ` (+${nl - 1} more lines, all of it is filed)` : ''}`);
2683
3168
  return say('/gripe! files it · /gripe <new text> replaces it · anything else leaves it unfiled', 'dim');
2684
3169
  }
2685
3170
  R.current.pendingGripe = null;
2686
3171
  say('Filing via GRIPE…', 'dim');
2687
3172
  const g = await api.gripe(text.slice(0, 4000), { yes: true });
2688
- if (!g.ok) return say(`Gripe not filed: ${g.message}`, 'warn');
3173
+ if (!g.ok && g.queued) return say(`${g.saved || "Saved; I'll file it when Quox answers again (/gripes shows the queue)"} (${clip(g.message, 60)})`, 'warn');
3174
+ if (!g.ok) return say(gripeFailedLine(text, g.message), 'warn');
2689
3175
  if (!g.superseded && g.response && !g.url) for (const l of String(g.response).split('\n').filter(Boolean).slice(0, 6)) say(clip(l, cols - 4), 'dim');
2690
3176
  for (const n of g.notes || []) {
2691
- if (n.kind === 'created') say(`✔ GRIPE │ issue filed │ ${n.url}`, 'ok');
3177
+ if (n.kind === 'created') { say(gripeFiledLine(n.url, text)); say(n.url, 'dim'); }
2692
3178
  else say(clip(n.text, cols * 3), n.kind === 'warn' ? 'warn' : 'dim');
2693
3179
  }
3180
+ if (g.url) gripeSweep.current(); // Quox just answered: file anything saved earlier
3181
+ return undefined;
3182
+ }
3183
+ case '/outbox': { // SLOT-OUTBOX: messages saved while QuoxCORE did not answer
3184
+ const [sub, ...rest] = restArg.trim().split(/\s+/).filter(Boolean);
3185
+ const rows = () => OB.current.list({ profile });
3186
+ if (!sub) {
3187
+ const list = rows();
3188
+ if (!list.length) return say('Outbox is empty. A /say that fails because QuoxCORE is not answering waits here and is sent when it answers.', 'dim');
3189
+ say(`Outbox: ${list.length} message${list.length === 1 ? '' : 's'}. /outbox send retries now, /outbox drop N removes one.`, 'dim');
3190
+ list.forEach((it, i) => {
3191
+ const t = new Date(it.created_at); const hm = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
3192
+ const st = it.status === 'queued' ? '⏸ queued' : it.status === 'sending' ? '◐ sending' : it.status === 'unsure' ? '⚠ may already be delivered (/outbox send retries)' : `✖ refused (${it.error || 'refused'})`;
3193
+ say(`${i + 1}. ${it.slot} · ${hm} │ ${clip(String(it.text).replace(/\s+/g, ' '), Math.max(20, cols - 60))} │ ${st}`, it.status === 'queued' ? 'dim' : 'warn');
3194
+ });
3195
+ return undefined;
3196
+ }
3197
+ if (sub === 'drop') {
3198
+ const n = Number(rest[0]);
3199
+ if (!Number.isInteger(n) || n < 1) return say('Usage: /outbox drop N (N is the number /outbox shows)', 'warn');
3200
+ const it = OB.current.drop(n, { profile });
3201
+ return say(it ? `Dropped the message to ${it.slot} (${clip(String(it.text).replace(/\s+/g, ' '), 40)}); it will not be sent.` : `No message ${n} in the outbox. /outbox lists them.`, it ? 'ok' : 'warn');
3202
+ }
3203
+ if (sub === 'send') {
3204
+ if (!rows().length) return say('Outbox is empty.', 'dim');
3205
+ const r = await flushOutbox({ explicit: true });
3206
+ if (r.busy) return say('A send is already in progress.', 'dim');
3207
+ if (r.pending > 0) say(`QuoxCORE is still not answering (${r.pending} queued). They will be sent when it does.`, 'warn');
3208
+ else if (!r.sent.length && !r.failed.length) say('Nothing to send.', 'dim');
3209
+ return undefined;
3210
+ }
3211
+ return say('Usage: /outbox | /outbox drop N | /outbox send', 'warn');
3212
+ }
3213
+ case '/gripes': {
3214
+ if (!api.gripes) return say('/gripes is not available in this mode.', 'warn');
3215
+ const rows = api.gripes();
3216
+ if (!rows.length) return say('No queued or recent gripes. /gripe! files one.', 'dim');
3217
+ const queued = rows.filter(g => g.status === 'queued').length;
3218
+ say(`Gripes: ${queued} waiting to be filed, ${rows.length - queued} recent`, 'dim');
3219
+ for (const g of rows.slice(-12)) {
3220
+ const where = g.status === 'filed' ? g.url : g.status === 'queued' ? 'queued, will retry' : g.status === 'expired' ? 'not filed (gave up after 24 h)' : `not filed (${g.error || 'refused'})`;
3221
+ say(`${g.status === 'filed' ? '✔' : '◐'} ${clip(g.text, Math.max(20, cols - 70))} │ ${where}`, g.status === 'filed' ? 'ok' : 'warn');
3222
+ }
2694
3223
  return undefined;
2695
3224
  }
2696
3225
  case '/context':
@@ -2926,8 +3455,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2926
3455
  const q = restArg.trim();
2927
3456
  const m = matchOrg(q, list);
2928
3457
  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)));
3458
+ const part = orgCandidates(q, list); // verb stripped, best-matching token: never a dead end on a typo
2931
3459
  if (!part.length) return say(`No org of yours matches "${q}". /org lists them.`, 'warn');
2932
3460
  openOrgPicker(part, `Orgs matching "${q}"`);
2933
3461
  return undefined;
@@ -3000,6 +3528,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3000
3528
  return undefined;
3001
3529
  } catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
3002
3530
  }
3531
+ case '/callmeforapprovals': case '/callme': {
3532
+ if (!api.callMe) return say('This server has no call-me setting.', 'warn');
3533
+ try {
3534
+ const { data, changed } = await api.callMe(parseCallMeSlashArgs(restArg));
3535
+ const lines = callMeStatusLines(data);
3536
+ return say(`${changed ? 'Updated. ' : ''}${lines.join('\n')}`);
3537
+ } catch (e) { return say(`Call me for approvals: ${e?.headline || e?.message || 'failed'}`, 'warn'); }
3538
+ }
3003
3539
  case '/plugin': case '/extensions': case '/extension': case '/addons': case '/apps': case '/plugins': {
3004
3540
  if (!api.pluginList) return say('This server has no plugin list.', 'warn');
3005
3541
  const want = /^(list|ls|all)?$/i.test(restArg.trim()) ? '' : restArg.trim().split(/\s+/)[0]; // "/extension list" is just the list
@@ -3090,6 +3626,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3090
3626
  for (const src of ro.sources || []) {
3091
3627
  say(`${src.state === 'connected' ? '✔' : src.state === 'error' ? '✖' : '·'} ${src.label}: ${src.state === 'connected' ? `${src.count} shown` : `${src.state}${src.note ? ` (${src.note})` : ''}`}`, src.state === 'error' ? 'warn' : undefined);
3092
3628
  }
3629
+ for (const src of ro.sources || []) {
3630
+ if (src.id === 'agents' && src.state === 'connected') for (const l of attachLines(src.items, Date.now())) say(l);
3631
+ if (src.id === 'boxsessions' && src.state === 'connected') for (const l of sessionLines(src.items)) say(l, /x\d/.test(l) ? 'warn' : undefined);
3632
+ }
3093
3633
  if (ro.sources) say('Down on an empty composer focuses the roster. Enter peeks, s stops (y confirms), Esc returns.');
3094
3634
  return undefined;
3095
3635
  }
@@ -3116,17 +3656,65 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3116
3656
  return dispatch({ type: 'object', obj: { type: 'sitrep', data: sitrepData(r.situation, r.approvals) } });
3117
3657
  }
3118
3658
  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 || '' };
3659
+ if (afkMeansSleep(restArg)) { await sleepNow(); return command('/dragon sleep'); } // owner 2026-10-29: "/afk going to bed" is /sleep
3660
+ const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: restArg || '', dnd: true }; AWAY.current.col ||= newCollected();
3120
3661
  try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
3121
3662
  say(`Away from ${new Date(at).toISOString().slice(11, 16)} UTC${restArg ? ` (${clip(restArg, 80)})` : ''}. Approvals and messages keep collecting; when you're back, /back shows everything since, grouped.`, 'ok');
3122
- const pr = await setPresence('away', restArg);
3663
+ const pr = await setPresence('away', restArg, { dnd: true });
3123
3664
  if (!pr.ok) presenceFail(pr);
3124
3665
  else if (!pr.local) say(`Sessions now see: ${pr.notice || 'you are away'}. Nothing is approved while you are away; /back or any message marks you present.`, 'dim');
3666
+ offerDndToo();
3667
+ return undefined;
3668
+ }
3669
+ case '/donotdisturb': case '/dnd': {
3670
+ const p = parseDndArgs(restArg);
3671
+ if (!p.ok) return say(p.error, 'warn');
3672
+ // owner 2026-10-29: a bare /dnd never turns DND off. The old toggle read /afk's away mark as "DND on" and reported "off" on the first press.
3673
+ if (p.op === 'toggle' && AWAY.current.afk?.explicit) return say('Do not disturb is already on. /dnd off turns it off, /back ends it.', 'dim');
3674
+ if (p.op === 'off') return AWAY.current.afk ? dndOff() : say('Do not disturb is off.', 'dim');
3675
+ return dndOn(p.until);
3676
+ }
3677
+ case '/morningreport': {
3678
+ // The report is about the most recent DND/away window (start to end, or now if still on), not the current on/off state.
3679
+ const live = AWAY.current.afk ? { since: AWAY.current.col?.since, col: AWAY.current.col } : null;
3680
+ const win = live || AWAY.current.lastWindow || (AWAY.current.col ? { since: AWAY.current.col.since, col: AWAY.current.col } : null);
3681
+ const held = (c) => Boolean(c && ((c.inbox?.length || 0) + (c.slots?.length || 0)));
3682
+ if (!win || (!AWAY.current.afk && !held(win.col))) { // nothing held in the last window: same since-last-seen digest as /away, honest note first
3683
+ say(win ? 'Nothing was held in your last do not disturb window. Here is what happened since you were last here.' : 'Nothing was held: do not disturb was off. Here is what happened since you were last here.', 'dim');
3684
+ return command('/away');
3685
+ }
3686
+ const col = win.col;
3687
+ const approvals = Math.max(S.current.notify?.approvals || 0, [...S.current.approvals, ...S.current.waiting].length);
3688
+ const sessions = (R.current.roster.allRows || []).filter((x) => x.state === 'waiting').map((x) => String(x.name || x.key || 'a session')).slice(0, 8);
3689
+ const hm = (ms) => new Date(ms).toISOString().slice(11, 16);
3690
+ say(`🌙 Morning report${win.since ? ` since ${hm(win.since)} UTC` : ''}${win.until ? ` until ${hm(win.until)} UTC` : ''}`, 'ok');
3691
+ for (const [t, tone] of morningLines(col || newCollected(), { approvals, sessions })) say(t, tone);
3692
+ if (api.away && win.since) { try { dispatch({ type: 'object', obj: { type: 'away', data: await api.away(new Date(win.since).toISOString()) } }); } catch { /* the local digest above stands */ } }
3693
+ if (AWAY.current.afk) {
3694
+ AWAY.current.col = newCollected();
3695
+ R.current.dndAsk = { at: Date.now(), off: true }; say(askText('Good morning. Turn off do not disturb?', { action: 'end do not disturb and show approvals again (same as /dnd off)' }), 'ask');
3696
+ }
3125
3697
  return undefined;
3126
3698
  }
3127
- case '/back': { // PRESENCE: present on the server too, then the digest
3128
- AWAY.current.afk = null;
3129
- const pr = await setPresence('present');
3699
+ case '/back': { // PRESENCE: present on the server too, then the digest (also ends do not disturb)
3700
+ // Presence is owner-level: if another window's first act already ended this away, the server's since/until win.
3701
+ let ended = null;
3702
+ if (AWAY.current.afk && api.getPresence) {
3703
+ try {
3704
+ const sp = await api.getPresence();
3705
+ const ea = sp?.ok && sp.presence && sp.presence.away !== true ? sp.presence.ended_away : null;
3706
+ if (ea && Date.parse(ea.until) >= AWAY.current.afk.at - 60000) ended = ea;
3707
+ } catch { /* unreadable: fall through to this window's own marks */ }
3708
+ }
3709
+ archiveWindow();
3710
+ AWAY.current.afk = null; AWAY.current.col = null;
3711
+ if (ended && api.away) {
3712
+ say(`✔ Welcome back, you were already marked present at ${new Date(ended.until).toISOString().slice(11, 16)} UTC (${ended.via || 'activity'} in another window).`, 'ok');
3713
+ try { dispatch({ type: 'object', obj: { type: 'away', data: { ...(await api.away(ended.since)), endedAt: ended.until } } }); }
3714
+ catch (e) { say(`away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). Nothing is assumed.`, 'warn'); }
3715
+ return undefined;
3716
+ }
3717
+ const pr = await setPresence('present', undefined, { human: true });
3130
3718
  if (!pr.ok) presenceFail(pr);
3131
3719
  return command('/away');
3132
3720
  }
@@ -3136,7 +3724,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3136
3724
  if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
3137
3725
  const r = await api.slotSay('quoxon', restArg.trim(), { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
3138
3726
  if (!r.ok) return say(`observe: not delivered (${r.message})`, 'warn');
3139
- return say('✔ noted for the observer (quoxon)', 'ok');
3727
+ return say(`✔ noted for the observer (quoxon${r.viaOrgName ? ` in ${r.viaOrgName}` : ''})`, 'ok');
3140
3728
  }
3141
3729
  case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
3142
3730
  const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
@@ -3175,17 +3763,31 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3175
3763
  try { items = await api.inboxItems({ limit: 30 }); }
3176
3764
  catch (e) { return dispatch({ type: 'error', text: `Could not read the inbox (${e.message}). That does not mean it is empty; try again or run: quox inbox list` }); }
3177
3765
  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) } });
3766
+ R.current.inboxList = top; R.current.inboxSeen = arrivals(items, R.current.inboxSeen || null).ids;
3767
+ dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
3768
+ if (q.kind === 'mutes') { for (const l of mutesLines(inboxMutes())) say(l.text, l.tone); return undefined; }
3769
+ if (q.kind === 'mute' || q.kind === 'unmute') {
3770
+ const key = q.source.toLowerCase(); const next = { ...inboxMutes() };
3771
+ if (q.kind === 'mute') next[key] = Date.now() + q.ms; else if (!next[key]) return say(`${q.source} is not muted. ${mark('/inbox mutes')} lists them.`, 'warn'); else delete next[key];
3772
+ if (api.saveSetting) api.saveSetting('inboxMutes', next); else (R.current.sessionSettings ||= {}).inboxMutes = next;
3773
+ dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
3774
+ return say(q.kind === 'mute' ? `✔ muted ${q.source} for ${Math.round(q.ms / 360000) / 10}h: no arrival lines, out of the bar count, still listed in /inbox. Approvals are never muted. ${mark(`/inbox unmute ${q.source}`)} undoes it.` : `✔ unmuted ${q.source}`, 'ok');
3775
+ }
3180
3776
  const pick = (n) => { const it = top[n - 1]; if (!it) say(`No item ${n}: the list has ${top.length}. ${mark('/inbox')} shows it.`, 'warn'); return it; };
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();
3777
+ if (q.kind === 'list') { for (const l of inboxTable(items, Date.now(), cols, 10, inboxMutes())) say(l.text, l.tone === 'accent' ? 'warn' : l.tone); return undefined; }
3778
+ if (q.kind === 'read' || q.kind === 'unread') {
3779
+ const markRead = q.kind === 'read';
3780
+ let targets;
3781
+ if (q.all) { // "all" is ALL unread, paged through the API, not just the 10 listed
3782
+ let every; try { every = api.inboxAllItems ? await api.inboxAllItems() : await api.inboxItems({ limit: 100 }); } catch (e) { return dispatch({ type: 'error', text: `Could not read the whole inbox (${e.message}). Nothing was marked ${q.kind}.` }); }
3783
+ targets = markRead ? unreadItems(every) : readItems(every);
3784
+ } else targets = (q.ns || [q.n]).map(pick).filter(Boolean);
3785
+ if (!targets.length) return q.all ? say(markRead ? 'Nothing unread.' : 'Nothing read to mark unread.', 'dim') : undefined;
3786
+ let ok = 0; const bad = []; let next = 0;
3787
+ const worker = async () => { while (next < targets.length) { const it = targets[next++]; try { await api.inboxMarkRead(it.id, markRead); ok++; } catch (e) { bad.push(e?.message || 'failed'); } } };
3788
+ await Promise.all(Array.from({ length: Math.min(5, targets.length) }, worker));
3789
+ if (bad.length) say(`Marked ${ok} ${q.kind}; ${bad.length} failed (${clip(bad[0], 60)}).`, 'warn'); else say(`✔ marked ${ok} ${q.kind}`, 'ok');
3790
+ refreshInboxBar({ force: true });
3189
3791
  return undefined;
3190
3792
  }
3191
3793
  const it = pick(q.n); if (!it) return undefined;
@@ -3195,21 +3797,52 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3195
3797
  return command(at >= 0 ? `/approvals ${at + 1}` : '/approvals');
3196
3798
  }
3197
3799
  let full = it; try { full = (await api.inboxItem(it.id)) || it; } catch (e) { say(`Could not load the full item (${clip(e?.message || 'failed', 60)}); showing the list row.`, 'warn'); }
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();
3800
+ for (const l of inboxDetail(full, Date.now(), q.n)) say(l.text, l.tone === 'accent' ? 'ok' : l.tone === 'text' ? undefined : l.tone);
3801
+ refreshInboxBar({ force: true });
3802
+ return undefined;
3803
+ }
3804
+ case '/mail': { // TUI-MAIL: triaged InboxQ mail, read only (same reads as `quox mail list`)
3805
+ if (!api.mailMessages) return say('Mail is not available in this mode.', 'warn');
3806
+ const q = parseMail(restArg);
3807
+ if (q.kind === 'usage') return say(MAIL_USAGE, 'warn');
3808
+ let list;
3809
+ try { list = mailNewestFirst(await api.mailMessages({ limit: 30, zone: q.zone })).slice(0, 10); }
3810
+ catch (e) { return dispatch({ type: 'error', text: `Could not read your mail (${e.message}). That does not mean it is empty; try again or run: quox mail list` }); }
3811
+ if (!list.length && !q.zone) {
3812
+ let accts = null; try { accts = await api.mailAccounts(); } catch { /* unknown: fall through to the plain empty line */ }
3813
+ if (accts && !accts.length) { for (const l of noMailboxLines()) say(l.text, l.tone); return undefined; }
3814
+ }
3815
+ if (q.kind === 'list') { for (const l of mailTable(list, Date.now(), cols, 10)) say(l.text, l.tone === 'accent' ? 'warn' : l.tone); return undefined; }
3816
+ const m = list[q.n - 1]; if (!m) return say(`No message ${q.n}: the list has ${list.length}. ${mark('/mail')} shows it.`, 'warn');
3817
+ let full = m; try { full = (await api.mailMessage(m.id)) || m; } catch (e) { say(`Could not load the full message (${clip(e?.message || 'failed', 60)}); showing the list row.`, 'warn'); }
3818
+ for (const l of mailDetail(full, Date.now(), q.n)) say(l.text, l.tone === 'accent' ? 'ok' : l.tone === 'text' ? undefined : l.tone);
3201
3819
  return undefined;
3202
3820
  }
3203
3821
  case '/approvals': {
3204
3822
  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')})`);
3823
+ say(`${mark('/approve N')} ${mark('/deny N <reason>')} ${mark('/approvals N')} detail ${mark('/approvals walkthrough')} one at a time ${mark('/approvals mine|agents|old|<agent>')} filter ${mark('/approvals auto on|off')} open new ones by themselves (privileged: ${mark('/approve N confirm')})`);
3206
3824
  return undefined;
3207
3825
  }
3826
+ if (/^auto\b/i.test(restArg.trim())) { // APPROVALS-AUTO: the real verb behind "/approvals instant-interrupt"
3827
+ const a = parseApprovalsArg(restArg);
3828
+ if (a.kind !== 'auto') return say(`Unknown option "${restArg.trim()}" for /approvals. Use ${mark('/approvals auto on')} or ${mark('/approvals auto off')}.`, 'warn');
3829
+ const st = AO.current.st;
3830
+ if (a.set === null) return say(`New approvals open by themselves: ${st.enabled ? 'on' : 'off'}. ${mark(`/approvals auto ${st.enabled ? 'off' : 'on'}`)} changes it.`);
3831
+ st.enabled = a.set;
3832
+ const saved = api.saveSetting ? api.saveSetting('approvalsAuto', a.set) : false;
3833
+ return say(a.set ? `✔ New approvals and questions now open by themselves as a card (after your draft or turn, never mid-typing). Nothing is ever answered for you.${saved ? '' : ' (Not saved to this profile: this window only.)'}` : `✔ New approvals no longer open by themselves; the ⏸ line and ${mark('/approvals')} still show them.${saved ? '' : ' (Not saved to this profile: this window only.)'}`, 'ok');
3834
+ }
3208
3835
  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
3836
  let stats = null; let statsErr = null;
3212
- if (api.approvalStats) { try { stats = await api.approvalStats(); } catch (e) { statsErr = e.message; } }
3837
+ // the list and the org-wide count are independent reads: ask together (the first decision waited on both in turn)
3838
+ const statsP = api.approvalStats ? api.approvalStats().then((v) => { stats = v; }, (e) => { statsErr = e.message; }) : Promise.resolve();
3839
+ // approvals waiting in the user's OTHER orgs: a count and the switch command, never their contents
3840
+ let moreOrgs = [];
3841
+ const moreP = api.otherOrgApprovals ? api.otherOrgApprovals().then((v) => { moreOrgs = crossOrgTuiLines(v); }, () => {}) : Promise.resolve();
3842
+ try { items = await api.approvals(); }
3843
+ catch (e) { await statsP; return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}). That does not mean nothing is waiting; try again or run: quox inbox list` }); }
3844
+ await Promise.all([statsP, moreP]);
3845
+ const sayMoreOrgs = () => { for (const l of moreOrgs) say(l, 'warn'); };
3213
3846
  const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
3214
3847
  const groups = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds });
3215
3848
  const flat = groups.flatMap(g => g.items.map(x => x.item));
@@ -3217,11 +3850,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3217
3850
  R.current.shownIds = flat.map(x => x.id);
3218
3851
  dispatch({ type: 'notify', patch: { approvals: flat.length, error: null } });
3219
3852
  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
3853
  const fArg = restArg.trim();
3854
+ { const pk = parseApprovalsArg(fArg, { items: flat }); if (pk.kind === 'unknown') return say(`Unknown option "${fArg}" for /approvals. Options: ${APPROVALS_OPTIONS}.${/instant|interrupt|pop|auto/i.test(fArg) ? ` To have new ones open by themselves: ${mark('/approvals auto on')}.` : ''}`, 'warn'); }
3855
+ if (!flat.length && !hidden && !statsErr) { say('Nothing waiting for you.', 'ok'); sayMoreOrgs(); return undefined; }
3222
3856
  if (/^walkthrough$/i.test(fArg)) { // plain approvals walk as cards too, after any session questions
3223
3857
  const plain = flat.filter(x => !isDecision(x));
3224
3858
  if (plain.length && plain.length === flat.length) say(`No session questions; ${plain.length} approval${plain.length === 1 ? '' : 's'} waiting, starting with the first.`, 'warn');
3859
+ sayMoreOrgs();
3225
3860
  return walkStart(flat.map(approvalAsWalkItem), { fresh: true });
3226
3861
  }
3227
3862
  if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
@@ -3258,6 +3893,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3258
3893
  say(`${hidden} more approval${hidden === 1 ? ' is' : 's are'} waiting org-wide. This window is signed in as ${whoTxt}.${others.length ? ` Quit and run: quox -p ${others[0]}${others.length > 1 ? ` (human profiles found: ${others.join(', ')})` : ''}. Or approve in the dashboard Approvals page, or: quox inbox -p ${others[0]} respond <id> --action approve|reject` : ' Approve in the dashboard Approvals page.'}`, 'warn');
3259
3894
  if (agentLogin) offerSwitch(); // one keypress instead of quit and relaunch
3260
3895
  }
3896
+ sayMoreOrgs();
3261
3897
  if (statsErr) say(`Could not check the org-wide approval count (${statsErr}); the list above is what the inbox returned.`, 'warn');
3262
3898
  return undefined;
3263
3899
  }
@@ -3295,12 +3931,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3295
3931
  return say(m.text, m.tone || undefined);
3296
3932
  }
3297
3933
  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);
3934
+ await sleepNow();
3302
3935
  return command('/dragon sleep');
3303
3936
  }
3937
+ case '/broadcast-active': return command(`/broadcast --active${restArg ? ` ${restArg}` : ''}`);
3304
3938
  case '/roar': return command(`/broadcast${restArg ? ` ${restArg}` : ''}`);
3305
3939
  case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
3306
3940
  case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
@@ -3334,6 +3968,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3334
3968
  if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
3335
3969
  await SUC.current.authenticate(); bump(n => n + 1); return undefined;
3336
3970
  }
3971
+ case '/verify-email': return SUC.current.verifyEmail(restArg); // the TUI front of `quox admin users verify-email`: preview, y/N, hidden PIN card, same endpoint
3337
3972
  case '/override': {
3338
3973
  const res = await SUC.current.override(restArg);
3339
3974
  if (res?.override_id && res.enforced) R.current.override = { id: res.override_id, until: Date.parse(res.expires_at) || Date.now() + 300000 };
@@ -3347,7 +3982,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3347
3982
  if (!li.length) { try { li = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: li }); } catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); } }
3348
3983
  const needs = li.map((x, k) => ((isDecision(x) ? x.payload.high_risk === true && x.payload.options.some(o => o.key === 'y') : (x.payload?.approval_context?.risk === 'high' || x.payload?.high_risk === true)) ? k + 1 : 0)).filter(Boolean);
3349
3984
  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');
3985
+ else if (needs.length) return say(`${needs.length} items need your PIN, so say which: ${needs.map(k => mark(`/authorise ${k}`)).join(' ')}`, 'warn');
3986
+ else if (li.length === 1) authArg = '1'; // DS-010: one thing is waiting; the server decides if it needs the PIN, and /approve asks for it
3987
+ else if (li.length) return say(`${li.length} items are waiting and none is marked as needing a PIN up front, so say which to approve with your PIN: ${li.slice(0, 5).map((_, k) => mark(`/authorise ${k + 1}`)).join(' ')}${li.length > 5 ? ' ...' : ''} (the server asks for the PIN on anything an agent raised)`, 'warn');
3988
+ else return say(`Nothing is waiting for your approval, so there is nothing to authorise. ${mark('/approvals')} lists what is open. /authenticate opens a human-present window ahead of time.`, 'warn');
3351
3989
  }
3352
3990
  R.current.authorising = true; // /approve's answer() asks for the PIN first when there is no live step-up
3353
3991
  try { await command(`/approve ${authArg}`); } finally { R.current.authorising = false; }
@@ -3396,7 +4034,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3396
4034
  const okIds = []; const failedK = [];
3397
4035
  for (const k of pa.ns) {
3398
4036
  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 });
4037
+ const go = () => { ownerActed('answer'); return api.answerDecision(x.id, keyOf(k), { note: pa.note || null, stepUp: SU.current.headers('approve'), bulk: bulkAns }); };
3400
4038
  try {
3401
4039
  try { await go(); }
3402
4040
  catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
@@ -3419,7 +4057,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3419
4057
  }
3420
4058
  const it = list[pa.n - 1];
3421
4059
  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');
4060
+ if (!isDecision(it)) { // DS-010 / #1191: a yes or no on an approval goes to /approve or /deny, which asks for the PIN itself
4061
+ if (pa.key === 'y') return command(`/approve ${pa.n}`);
4062
+ if (pa.key === 'n') return command(`/deny ${pa.n}${pa.note ? ` ${pa.note}` : ''}`);
4063
+ return say(`[${pa.n}] is an approval, not a question. Use ${mark(`/approve ${pa.n}`)} or ${mark(`/deny ${pa.n} <reason>`)}.`, 'warn');
4064
+ }
3423
4065
  if (!it.payload.options.some(o => o.key === pa.key)) return say(`[${pa.n}] has no option "${pa.key}". Options: ${it.payload.options.map(o => `(${o.key}) ${o.label}`).join(' ')}`, 'warn');
3424
4066
  if (pa.note && noteNeedsConfirm(pa.note) && !R.current.noteBypass) { noteCard({ walk: false, cmd: `${pa.n} ${pa.key}`, key: pa.key, label: it.payload.options.find(o => o.key === pa.key).label, note: pa.note, id: it.id }); return undefined; }
3425
4067
  say(`Answering: ${clip(it.title || it.id, Math.max(20, cols - 30))} → ${it.payload.options.find(o => o.key === pa.key).label} (${pa.key})${pa.note ? `, with your note (${pa.note.length} characters)` : ''}`, 'dim'); // DECISION-LABELS: say which question and which option BEFORE it is sent
@@ -3428,7 +4070,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3428
4070
  const subject = { line1: `answer ${String(it.title || "")}`, raisedBy: it.payload.session || null, risk: 'high' };
3429
4071
  const askPin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject }); if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; } };
3430
4072
  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') });
4073
+ const go = () => { ownerActed('answer'); return api.answerDecision(it.id, pa.key, { note: pa.note || null, stepUp: SU.current.headers('approve') }); };
3432
4074
  try { await go(); }
3433
4075
  catch (e) {
3434
4076
  if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
@@ -3493,12 +4135,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3493
4135
  if (!targets.length) return say(`Nothing waiting from "${agent}". ${mark('/approvals agents')} shows who is asking.`, 'warn');
3494
4136
  }
3495
4137
  const privOf = (it) => isPrivileged({ tool: it.payload?.approval_context?.method?.tool, risk: it.payload?.approval_context?.risk, flag: it.payload?.privileged === true || it.payload?.approval_context?.privileged === true, title: it.title });
3496
- const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = [];
4138
+ const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = []; const heldDel = [];
3497
4139
  const heldDecisions = []; const done = []; const heldPriv = []; const failed = []; const missing = []; const gone = [];
3498
4140
  const label = (k) => { const it = list[k - 1]; return `[${k}] ${approvalSourceLabel(it)}: ${approvalWhat(it)}`; }; // owner 2026-10-15: no repeated who, no cut at all (lines wrap); the server caps summary length
3499
4141
  for (const k of targets) {
3500
4142
  const it = list[k - 1];
3501
4143
  if (!it) { missing.push(k); continue; }
4144
+ if (verb === 'approve' && isDestructive(it)) { // a delete is never part of a bulk yes, and a single one needs its name typed
4145
+ if (bulkish) { heldDel.push(k); continue; }
4146
+ if (R.current.delBypass !== it.id) {
4147
+ startDelConfirm(destructiveObject(it), async () => { R.current.delBypass = it.id; try { const n2 = S.current.inbox.findIndex(x => x.id === it.id) + 1; await command(`${cmd} ${n2 || k}`); } finally { R.current.delBypass = null; } });
4148
+ continue;
4149
+ }
4150
+ }
3502
4151
  if (isDecision(it)) {
3503
4152
  if (all || agent) { heldDecisions.push(k); continue; } // a bulk approve never answers a human question for you
3504
4153
  if (verb === 'approve' && bulkish && isHeldFromBulk(it)) { heldRisky.push(k); continue; } // risky asks are never part of a bulk yes
@@ -3510,7 +4159,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3510
4159
  const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
3511
4160
  const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
3512
4161
  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}`); }
4162
+ } catch (e) {
4163
+ failed.push(`[${k}] ${e.message}`);
4164
+ if (e?.stepUpDeclined) { const rest = targets.slice(targets.indexOf(k) + 1).filter(j => list[j - 1]); if (rest.length) failed.push(`${rest.map(j => `[${j}]`).join(' ')} not tried: the PIN was not given, so nothing more was sent`); break; } // DS-010: one PIN ask per batch, a "no" ends it
4165
+ }
3514
4166
  }
3515
4167
  if (done.length || gone.length || failed.length || missing.length) say(' ');
3516
4168
  for (const k of done) say(`${verb === 'approve' ? '✔ approved' : '✔ denied'} ${label(k)}${reason ? ` (reason: ${clip(reason, 40)})` : ''}`, 'ok');
@@ -3531,13 +4183,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3531
4183
  if (ynShown.length) say(`${ynShown.length} decision${ynShown.length === 1 ? '' : 's'} left for you to pick: ${ynShown.map(k => `/answer ${k} ${list[k - 1].payload.options.map(o => o.key).join('|')}`).join(', ')} (or /deny ${ynShown.join(',')} <reason>)`, 'warn');
3532
4184
  if (ynHeld.length) { // ONE card for the LOW-RISK y/N decisions only, each listed in full; risky ones were held back for the walkthrough
3533
4185
  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 };
4186
+ R.current.pendingGrant = { cmd, verb, items: ynHeld.map(k => list[k - 1]), ks: ynHeld, decisions: true, hi: 0, at: Date.now(), thenWalk: walkKs.length ? walkKs.map(k => approvalAsWalkItem(list[k - 1])) : null };
3535
4187
  ask(`Answer yes to ${ynHeld.length === 1 ? 'it' : `all ${ynHeld.length}`}?`, { who: '', action: `/answer ${ynHeld.join(',')} y`, reason: `${walkKs.length ? `${walkKs.length} risky or lettered held back for the walkthrough. ` : ''}Each one listed above is answered yes.`, keys: 'y answers yes to these, N cancels' });
3536
4188
  }
3537
4189
  if (walkKs.length) {
3538
4190
  say(`${walkKs.length} question${walkKs.length === 1 ? '' : 's'} held back from any bulk yes (${[...new Set(walkKs.map(k => heldReason(list[k - 1]) || 'needs a real choice'))].join(', ')}). I will take ${walkKs.length === 1 ? 'it' : 'them'} one at a time, with the full text, each with your own yes.`, 'warn');
3539
- if (!ynHeld.length) walkStart(walkKs.map(k => list[k - 1])); // with a bulk card up, the walkthrough starts once the card is answered
4191
+ if (!ynHeld.length) walkStart(walkKs.map(k => approvalAsWalkItem(list[k - 1]))); // with a bulk card up, the walkthrough starts once the card is answered
3540
4192
  }
4193
+ if (heldDel.length) say(`${heldDel.length} delete${heldDel.length === 1 ? '' : 's'} left for you to confirm one by one: ${heldDel.map(k => `${cmd} ${k}`).join(', ')}`, 'warn');
3541
4194
  if (heldPriv.length && (all || agent)) say(`${heldPriv.length} privileged left: ${heldPriv.map(k => `${cmd} ${k} confirm`).join(', ')}`, 'warn');
3542
4195
  else if (heldPriv.length) { // ask inline instead of sending the user to a second command
3543
4196
  R.current.pendingGrant = { cmd, verb, items: heldPriv.map(k => list[k - 1]), at: Date.now() };
@@ -3599,10 +4252,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3599
4252
  function openAskStates() {
3600
4253
  const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
3601
4254
  const W = R.current.pendingWalk; const walkCardSt = W && W.card && Date.now() - W.at < WALK_TTL ? W.card : null; // WALK-FIXES: one card per decision, open as long as the walk (30 min), not the 2-minute ask clock
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);
4255
+ return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.sayLastAsk, R.current.profileAsk, R.current.loginAsk, R.current.neverAsk, R.current.dndAsk, R.current.secretAsk, R.current.limitedAsk, R.current.cliAsk, R.current.deckAsk, R.current.installAsk, R.current.updateAsk, R.current.orgPending, R.current.hatPending, HW.current.card(), RW.current.walk.card(), R.current.suggestAsk && !R.current.suggestLine ? R.current.suggestAsk : null, R.current.objectiveAsk, R.current.handoffAsk, R.current.askQuox, R.current.strayAsk, R.current.planAsk, SC.current.pendingFile, SC.current.reattach, CH.current.pending, SPN.current.pending].map(fresh)].filter(Boolean);
3603
4256
  }
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?.(); }
4257
+ const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || SUC.current.pending() || UW.current.pending?.() || runAskOpen();
4258
+ function touchAsks() { for (const x of openAskStates()) x.at = Date.now(); KC.current.touch?.(); SUC.current.touch(); UW.current.touch?.(); }
3606
4259
  // ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): each live ask card is
3607
4260
  // bound to the open state it asks about; once that state is no longer open the card goes and ONE line stays:
3608
4261
  // "✔ Wake it now? yes (00:44)", "⌛ ... expired, not answered", "↷ ... no longer open". Run requests likewise.
@@ -3632,6 +4285,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3632
4285
  if (askStateOpen(st)) continue;
3633
4286
  bind.delete(cd.id);
3634
4287
  const expired = st && typeof st === 'object' ? now - (st.at ?? 0) >= ASK_TTL : false;
4288
+ if (expired && st === SC.current.reattach && st.slot) R.current.declinedSlot = st.slot; // an unanswered reattach counts as a decline across the auto-restart of this window
3635
4289
  if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
3636
4290
  takeAnswer();
3637
4291
  if (st.walkWhy === 'stop') dispatch({ type: 'ask.settle', id: cd.id, text: 'The question closed before you answered.', tone: 'plain' });
@@ -3669,6 +4323,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3669
4323
  if (now - K.current.lastPasteAt < PASTE_GUARD_MS) return; // post-paste guard
3670
4324
  if (now - K.current.cardGoneAt < CARD_GONE_MS) return; // keys meant for the card must not send a turn
3671
4325
  let buf = B.current;
4326
+ if (R.current.delConfirm) { // DESTRUCTIVE-CONFIRM: this line is the typed name (a slash command leaves the step)
4327
+ const t = buf.text.trim();
4328
+ if (!t.startsWith('/')) { B.current = initialBuffer(); bump(n => n + 1); delConfirmLine(t); return; }
4329
+ R.current.delConfirm = null;
4330
+ }
4331
+ if (!buf.text.trim() && R.current.secretAsk) { B.current = { ...initialBuffer(), text: 'y', cursor: 1 }; buf = B.current; } // SECRET-GUARD: Enter alone keeps the credentials out (never "send anyway")
3672
4332
  // y/N/c: Enter alone answers an open ask "no" (the default), as typing n does.
3673
4333
  if (!buf.text.trim() && R.current.pendingWalk && !R.current.denyReason) { say('Type a choice for the card above (s skips, Esc stops). Enter alone answers nothing.', 'dim'); return; } // WALK-FIXES: an empty Enter must never answer "no"
3674
4334
  if (!buf.text.trim() && !R.current.denyReason && !S.current.turn && askOpen()) { B.current = { ...initialBuffer(), text: 'n', cursor: 1 }; buf = B.current; }
@@ -3684,7 +4344,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3684
4344
  if (!chk.ok) { B.current = { ...chk.buffer, undo: buf.undo, redo: buf.redo }; say(chk.note, 'warn'); bump(n => n + 1); return; }
3685
4345
  let text = chk.text.trim();
3686
4346
  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'); }
4347
+ if (wrapped && !GRIPE_LINE.test(text)) { text = wrapped.text; say(`joined ${wrapped.lines} lines`, 'dim'); }
4348
+ if (SUC.current.pending() && !text.startsWith('/')) { // the /verify-email y/N preview owns the next plain line: never chat, never a turn, never history (a PIN typed early must go nowhere)
4349
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.histIdx = -1;
4350
+ if (/^(y(es)?|no?)$/i.test(text)) SUC.current.answer(text);
4351
+ else { SUC.current.touch(); say('Answer y or n for /verify-email (n cancels). Nothing was sent. The PIN is asked for in a private card after y, never here.', 'warn'); }
4352
+ bump(n => n + 1); return;
4353
+ }
3688
4354
  const keyHit = looksLikeKey(text);
3689
4355
  if (keyHit) { B.current = initialBuffer(); R.current.confirmMulti = null; say(keyWarning(keyHit), 'warn'); return; } // never sent, never in history
3690
4356
  if (text.startsWith('!') && text.includes('\n')) { // owner 2026-10-15: a wrapped `!git ... push origin\nHEAD:main` ran as two commands
@@ -3693,6 +4359,29 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3693
4359
  say(`That ! command arrived on ${text.split('\n').filter(l => l.trim()).length} lines (a wrapped paste?). Joined into one line in the composer: check it, then Enter runs it.`, 'warn');
3694
4360
  bump(n => n + 1); return;
3695
4361
  }
4362
+ // QUOXON-SECRET-GUARD: credential-shaped text is never sent, saved or remembered without an explicit "send anyway"
4363
+ const sa = R.current.secretAsk;
4364
+ if (sa) {
4365
+ const ans = askAnswer(text);
4366
+ if (ans === 'yes' || ans === 'no') {
4367
+ R.current.secretAsk = null; R.current.askAnswer = { kind: ans, at: Date.now() }; queueMicrotask(() => reconcileAsks());
4368
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.histIdx = -1;
4369
+ if (ans === 'yes' && Date.now() - sa.at < 120000) { say('Not sent. Nothing was saved anywhere.', 'ok'); bump(n => n + 1); return; }
4370
+ if (ans === 'no') { R.current.secretOk = sa.text; if (sa.text.startsWith('/') && sa.text.includes('\n')) R.current.confirmMulti = true; B.current = { ...initialBuffer(), text: sa.text, cursor: sa.text.length }; bump(n => n + 1); onEnter(now); return; }
4371
+ say('That question expired, so nothing was sent. Paste it again if you still mean to.', 'dim'); bump(n => n + 1); return;
4372
+ }
4373
+ R.current.secretAsk = null; // anything else moves on; the new line is checked on its own below
4374
+ }
4375
+ if (R.current.secretOk === text) R.current.secretOk = null;
4376
+ else {
4377
+ R.current.secretOk = null;
4378
+ const kinds = secretKinds(text);
4379
+ if (kinds.length) {
4380
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.secretAsk = { at: Date.now(), text };
4381
+ say(askText(`This looks like credentials (${kinds.join(', ')}). Don't send it?`, { keys: 'y keeps it out (recommended) · n send anyway', action: 'discard it, nothing is sent or saved', title: 'Credentials in your message' }), 'ask');
4382
+ bump(n => n + 1); return;
4383
+ }
4384
+ }
3696
4385
  const bang = parseBang(text);
3697
4386
  if (bang) { // QUOXON-FW8: the user's own command on their own machine; never a turn, never the server
3698
4387
  if (bang.kind !== 'reserved' && bang.kind !== 'empty' && R.current.bang) { say('A command is still running. Ctrl+C stops it; your draft is kept.', 'warn'); return; }
@@ -3700,16 +4389,61 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3700
4389
  echoCmd(text); bangCommand(bang, { echoed: true }); bump(n => n + 1); return;
3701
4390
  }
3702
4391
  const s = S.current;
3703
- const isCommand = text.startsWith('/') && !text.includes('\n');
4392
+ const isCommand = isOneLineCommand(text);
3704
4393
  const ws = R.current.wokeSwallowed; R.current.wokeSwallowed = null;
3705
4394
  if (ws && !isCommand && !text.startsWith('!') && now - ws.at < 15000 && !askOpen() && !R.current.pendingWalk) { // the key that woke the screen was not typed: this line may be missing its first letter, so nothing goes to the model unless the user means it
3706
4395
  B.current = { ...initialBuffer(), text, cursor: text.length };
3707
4396
  say('Not sent: the key that woke the screen was not typed, so this line may be missing its first letter. Check it, then press Enter to send.', 'warn'); bump(n => n + 1); return;
3708
4397
  }
4398
+ if (R.current.dndAsk) { // DND offers: "Turn on do not disturb until morning?" and "Good morning. Turn off do not disturb?"
4399
+ const a = R.current.dndAsk;
4400
+ const ans = askAnswer(text);
4401
+ if (ans === 'yes' || ans === 'no') {
4402
+ R.current.dndAsk = null; R.current.askAnswer = { kind: ans, at: Date.now() }; queueMicrotask(() => reconcileAsks());
4403
+ B.current = initialBuffer(); R.current.histIdx = -1;
4404
+ if (ans === 'yes' && Date.now() - a.at < 600000) { (a.off ? dndOff() : dndOn()).catch(() => {}); return; }
4405
+ say(ans === 'yes' ? 'That offer expired. /dnd on turns it on.' : (a.off ? 'Staying on do not disturb. /dnd off ends it.' : 'Not now. /dnd on whenever you like.'), 'dim'); bump(n => n + 1); return;
4406
+ }
4407
+ if (isCommand) R.current.dndAsk = null;
4408
+ }
4409
+ if (R.current.neverAsk) { // ONBOARD-P2: "Quox at <url> has never answered. Connect to a different server? y/N", then the address
4410
+ const a = R.current.neverAsk;
4411
+ if (a.stage === 'offer') {
4412
+ const ans = askAnswer(text);
4413
+ if (ans === 'yes') {
4414
+ R.current.neverAsk = { ...a, stage: 'url', at: Date.now() };
4415
+ say(askText('Quox server address (for example https://192.0.2.10:3000)?', { keys: 'Enter or n cancels', action: 'check it is a Quox, then sign you in (the certificate is shown first)' }), 'ask'); return;
4416
+ }
4417
+ if (ans === 'no' || ans === 'chat') {
4418
+ R.current.neverAsk = null;
4419
+ say('Still trying quietly. To connect elsewhere any time, run: quox connect <url>', 'dim');
4420
+ if (healthRecheck) startupRecheck.current?.(0);
4421
+ return;
4422
+ }
4423
+ if (ans === 'command') R.current.neverAsk = null;
4424
+ } else {
4425
+ const t = text.trim();
4426
+ R.current.neverAsk = null;
4427
+ if (!t || /^no?$/i.test(t)) { say('Not connecting. To connect elsewhere any time, run: quox connect <url>', 'dim'); if (healthRecheck) startupRecheck.current?.(0); return; }
4428
+ if (!/^https?:\/\//i.test(t)) { say('That is not a web address. Run: quox connect <url> when you have it.', 'warn'); return; }
4429
+ say(`Connecting to ${t}…`, 'dim');
4430
+ onConnect(t); exit(); return;
4431
+ }
4432
+ }
4433
+ if (R.current.loginAsk) { // "Signed out of X. Sign in now? y/N": y is exactly /login; n or Enter alone dismisses; other chat falls to the signed-out refusal below
4434
+ const a = R.current.loginAsk;
4435
+ const ans = askAnswer(text);
4436
+ if (ans === 'yes' || ans === 'no') {
4437
+ R.current.loginAsk = null; R.current.askAnswer = { kind: ans === 'yes' ? 'yes' : 'no', at: Date.now() }; queueMicrotask(() => reconcileAsks());
4438
+ if (ans === 'yes' && Date.now() - a.at < 120000) { echoCmd('/login'); command('/login'); return; }
4439
+ say('Not now. /login signs in whenever you like.', 'dim'); return;
4440
+ }
4441
+ if (isCommand) R.current.loginAsk = null;
4442
+ }
3709
4443
  if (blocked && !isCommand) { // Z2H-R4: clear the box (else the next /login is appended and refused); Up recalls the message
3710
4444
  remember(text); R.current.histIdx = -1; B.current = initialBuffer(); say(blocked, 'warn'); bump(n => n + 1); return;
3711
4445
  }
3712
- if (text.startsWith('/') && text.includes('\n') && !R.current.confirmMulti) {
4446
+ if (text.startsWith('/') && text.includes('\n') && !isOneLineCommand(text) && !R.current.confirmMulti) {
3713
4447
  // commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter.
3714
4448
  // The second Enter sends it even after edits (R3 D3: requiring a byte-identical draft swallowed every later Enter).
3715
4449
  R.current.confirmMulti = true;
@@ -3737,13 +4471,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3737
4471
  let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
3738
4472
  if (askOpen() && !R.current.noteAsk) {
3739
4473
  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'];
4474
+ const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'sayLastAsk', 'profileAsk', 'loginAsk', 'neverAsk', 'dndAsk', 'secretAsk', 'limitedAsk', 'cliAsk', 'deckAsk', 'installAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
3741
4475
  const foreign = KC.current.pending?.() || UW.current.pending?.() || HW.current.card() || SC.current.pendingFile || SC.current.reattach || CH.current.pending || SPN.current.pending || runAskOpen();
3742
4476
  if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
3743
4477
  else if (o) {
3744
4478
  askDismissed = o.kind === 'dismissed';
3745
4479
  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
4480
+ if (askDismissed) { if (R.current.limitedAsk) { R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } }); } for (const k of OWN) R.current[k] = null; } // the offer is gone: the line below is plain chat, not an answer to anything
3747
4481
  }
3748
4482
  }
3749
4483
  // 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 +4489,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3755
4489
  say('Not sent. Your answer and note are back in the composer; press Enter to send it, or edit it first.', 'warn');
3756
4490
  return;
3757
4491
  }
4492
+ if (!R.current.pendingWalk && !askOpen() && !headCard(S.current) && strayAnswer(text, { queued: AO.current.st.queue.length, walkOpen: false, noticeAt: AO.current.noticeAt, now: Date.now() })) { autoStray(text); return; } // APPROVALS-AUTO: the stray y
3758
4493
  if (R.current.pendingWalk && !text.startsWith('/')) { walkHandle(text); return; } if (R.current.pendingWalk) walkEnd(`you ran ${text.split(/\s+/)[0]}`); // WALKTHROUGH: the line is the answer to the card on screen; a command ends the walk with its summary
3759
4494
  if (!askDismissed && !S.current.turn && !noAiNow() && askAnswer(text) === 'chat' && askOpen() && !(runAskOpen() && /^run$/i.test(text.trim()))) { chatAboutAsk(text); return; }
4495
+ if (SUC.current.answer(text)) return; // y/N after a /verify-email preview
3760
4496
  if (KC.current.answer(text)) return; // y/n after a /watch, /automate or /crystallize preview card
3761
4497
  if (UW.current.answer(text)) return; // y/n after a /undo or /wish card
3762
4498
  if (HW.current.active() && HW.current.answer(text)) return; // CUSTOM-HATS: the next line of a /hat new|edit|delete walk
@@ -3770,7 +4506,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3770
4506
  if (Date.now() - g.at < 120000 && !text.startsWith('/')) {
3771
4507
  if (/^y(es)?$/i.test(text.trim())) {
3772
4508
  (async () => {
3773
- if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it)); // defence: a risky ask never rides the bulk card
4509
+ if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it) && !isDestructive(it)); // defence: a risky ask or a delete never rides the bulk card
3774
4510
  if (g.decisions && g.hi) { // one PIN for the whole batch; the token then rides on every item
3775
4511
  const r = SU.current.token('approve') ? { ok: true } : await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject: { line1: `answer yes to ${g.items.length} decisions (${g.hi} high risk)`, raisedBy: null, risk: 'high' } });
3776
4512
  if (!r.ok) return say(`Not answered: ${declinedText(r.reason)}. Nothing was sent.`, 'warn');
@@ -3817,6 +4553,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3817
4553
  if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
3818
4554
  if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
3819
4555
  }
4556
+ if (R.current.limitedAsk) { // "Switch to <model>? y/N" under the limited banner: y is exactly /model <name>; anything else dismisses it
4557
+ const a = R.current.limitedAsk; R.current.limitedAsk = null;
4558
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { echoCmd(`/model ${a.id}`); command(`/model ${a.id}`); return; }
4559
+ R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } });
4560
+ if (!text.startsWith('/')) { say(`Staying on the current model. /model ${a.id} switches whenever you like.`, 'dim'); return; }
4561
+ }
3820
4562
  if (R.current.sayLastAsk) { // SAY-LAST-TARGET "Send to <slot>? y send · n cancel": y sends the whole text
3821
4563
  const a = R.current.sayLastAsk; R.current.sayLastAsk = null;
3822
4564
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { sendToSlot(a.slot, a.text); return; }
@@ -3842,6 +4584,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3842
4584
  }
3843
4585
  if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
3844
4586
  }
4587
+ if (R.current.deckAsk) { // LAZY PATH: "quoxdeck needs: ... Install now? y/N" (typing y is the authorization; sudo is the user's own)
4588
+ const a = R.current.deckAsk; R.current.deckAsk = null;
4589
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
4590
+ const run = (st) => new Promise((resolve) => {
4591
+ const hold = { deck: true }; R.current.bang = hold;
4592
+ setTimeout(() => {
4593
+ if (R.current.bang === hold) R.current.bang = null;
4594
+ const r = handOverRun({ cmd: st.cmd, env, cwd: process.cwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
4595
+ bump(n => n + 1); resolve({ code: r.error ? 1 : r.code, error: r.error });
4596
+ }, runHandOverDelayMs);
4597
+ });
4598
+ runDeckOffer(a.offer, { run: (st) => st.cmd === `quox ext add quoxdeck` ? Promise.resolve(extAdd('quoxdeck').ok ? { code: 0 } : { code: 1 }) : run(st), say: (t, tone) => say(t, tone || 'dim'), rerun: async () => { echoCmd('/deck on'); command('/deck on'); } }).catch(() => {});
4599
+ return;
4600
+ }
4601
+ if (!text.startsWith('/')) { say(declineText(a.offer), 'dim'); return; }
4602
+ }
3845
4603
  if (R.current.installAsk) { // LAZY-AI-CLI: "Claude Code isn't installed on this server. Install it now? y/N" (typing y is the authorization)
3846
4604
  const a = R.current.installAsk; R.current.installAsk = null;
3847
4605
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { installNow(a); return; }
@@ -3894,13 +4652,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3894
4652
  }
3895
4653
  if (SC.current.pendingFile) { // /send found secret-shaped values: "y/N" (send redacted)
3896
4654
  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; }
4655
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - pf.at < 120000) { sendToSlot(pf.slot, fileMessage({ name: pf.f.name, body: pf.f.redacted, size: Buffer.byteLength(pf.f.redacted) }), { draft: null }); return; }
3898
4656
  if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3899
4657
  }
3900
4658
  if (SC.current.reattach) { // startup "Reattach to slot X? y/N"
3901
4659
  const ra = SC.current.reattach; SC.current.reattach = null;
3902
4660
  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; }
4661
+ if (/^no?$/i.test(text.trim())) { R.current.declinedSlot = ra.slot; say('Left it.', 'dim'); return; }
3904
4662
  }
3905
4663
  if (SC.current.runAsk) { // SLOT-RUN: "Run it? y/N/c" (or "run" for a dangerous one) on a slot sender's command
3906
4664
  const ra = SC.current.runAsk;
@@ -3914,7 +4672,16 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3914
4672
  }
3915
4673
  { const ls = R.current.listShownAt; R.current.listShownAt = null; // APPROVALS-UX: y/n right after a list (not a card) is not chat to the agent
3916
4674
  if (ls && Date.now() - ls < 600000 && /^(y(es)?|no?)$/i.test(text.trim())) { remember(text); R.current.histIdx = -1; say('Which one? /approve 1 or /approvals 1 to see it.', 'warn'); R.current.listShownAt = ls; return; } }
3917
- if (text.startsWith('/') && !text.includes('\n')) { remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
4675
+ if (isOneLineCommand(text)) { PL.current.submitted(buf.text); remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
4676
+ if (!SC.current.active && BARE_COMMAND_WORDS.has(text.toLowerCase()) && findEntry('/' + text.toLowerCase())?.kind === 'command') { // owner 2026-10-26 18:22: "clear" went to the model as chat. Offer the command BEFORE anything is sent; n sends it as chat.
4677
+ const line = '/' + text.toLowerCase();
4678
+ remember(text); R.current.histIdx = -1;
4679
+ R.current.strayAsk = { text, command: line, at: Date.now() };
4680
+ ask(`Run ${line}?`, { action: line, reason: `"${text}" is a command word. n sends it to Quox as chat instead.` });
4681
+ return;
4682
+ }
4683
+ const attachSlot = !SC.current.active && !s.turn && api.slots ? parseSlotAttach(text) : null;
4684
+ if (attachSlot) { remember(text); R.current.histIdx = -1; offerSlotAttach(attachSlot, text); return; } // "attach to hitl slot": offer /slot hitl, no model turn
3918
4685
  const roomSend = api.slotSay ? parseRoomSend(text) : null;
3919
4686
  if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
3920
4687
  remember(text); R.current.histIdx = -1;
@@ -4047,7 +4814,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4047
4814
  k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50); k.askEchoTimer.unref?.();
4048
4815
  bump(n => n + 1); return true;
4049
4816
  }
4050
- if (!askOpen() || R.current.pendingWalk || R.current.denyReason || HW.current.active?.()) return false;
4817
+ if (!askOpen() || R.current.pendingWalk || R.current.denyReason || R.current.delConfirm || HW.current.active?.()) return false;
4051
4818
  if (/c/i.test(ch) && (S.current.turn || noAiNow())) { say('Quox is busy or off; c chats about this question once it can. y or n answers it now.', 'dim'); return true; }
4052
4819
  B.current = { ...initialBuffer(), text: ch, cursor: 1 };
4053
4820
  onEnter(now);
@@ -4074,6 +4841,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4074
4841
  const k = K.current;
4075
4842
  const g = k.guard;
4076
4843
  if (R.current.denyReason && Date.now() - R.current.denyReason.at < 120000) return false; // typing the deny reason: y/n are text
4844
+ if (R.current.delConfirm) return false; // typing the name to delete: every key is text
4077
4845
  if (B.current.text && !k.cardFocus) { k.confirm = false; return false; } // a draft in progress: the key is text (Tab focuses the card, draft kept)
4078
4846
  const armedAt = Math.max(card.displayedAt ?? card.shownAt ?? 0, g?.since ?? 0);
4079
4847
  const ok = () => approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: k.lastKeyAt, heldKey: g?.heldKey, ch });
@@ -4084,6 +4852,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4084
4852
  return true; // any other key cancels
4085
4853
  }
4086
4854
  const key = ch.toLowerCase();
4855
+ if (signedOutRef.current) { if (key === 'y' && ok()) { echoCmd('/login'); command('/login'); } return key === 'y' || key === 'n' || key === 'c'; } // signed out: the card is read-only, y signs in
4856
+ const burst = batchFor(queueOf(S.current), card); // APPROVAL-BURST: y and n apply to the whole batch, p walks it
4857
+ if (burst && (key === 'y' || key === 'n' || key === 'p')) {
4858
+ if (!ok()) return true;
4859
+ if (key === 'p') walkBatch(burst); else answerBatch(burst, key === 'y' ? 'approve' : 'reject');
4860
+ return true;
4861
+ }
4087
4862
  if (key === 'c' && !S.current.turn && !noAiNow()) { // y/N/c: chat about the approval first; the card stays
4088
4863
  if (!ok()) return true;
4089
4864
  const what = card.what || [card.tool, card.target].filter(Boolean).join(' ') || 'this approval';
@@ -4093,9 +4868,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4093
4868
  if (key !== 'y' && key !== 'n') return false; // ordinary typing: the composer keeps it
4094
4869
  if (!ok()) return true; // too early / key repeat: swallowed, never answered
4095
4870
  if (key === 'n' && k.cardFocus) { k.cardFocus = false; answer(card, 'reject'); return true; } // focused over a draft: the composer holds the draft, so no reason prompt
4096
- if (key === 'n') { // ask why in one line; Enter skips. The card stays until the answer is sent.
4871
+ if (key === 'n') { // ask why in one line; Enter skips, Esc backs out. The card stays until the answer is sent.
4097
4872
  R.current.denyReason = { card, at: Date.now() }; say('Reason? (Enter to skip)', 'warn'); bump(n => n + 1); return true;
4098
4873
  }
4874
+ if (card.destructive) { startDelConfirm(card.destructive, () => answer(card, 'approve')); return true; } // a delete is never one key
4099
4875
  if (card.privileged || card.undescribable) { k.confirm = true; bump(n => n + 1); return true; }
4100
4876
  answer(card, 'approve');
4101
4877
  return true;
@@ -4186,6 +4962,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4186
4962
  if (R.current.askArmed) { R.current.askArmed = false; R.current.dragonTalk = null; say('Dragon ask cancelled.', 'dim'); return; }
4187
4963
  if (HW.current.cancel()) return; // CUSTOM-HATS: Esc ends the hat walk, nothing sent
4188
4964
  if (RW.current.walk.cancel()) return; // ROWS: Esc ends the row walk, nothing saved
4965
+ if (R.current.limitedAsk && !B.current.text) { R.current.limitedAsk = null; R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } }); return; } // Esc says no to the switch offer
4189
4966
  if (R.current.pendingWalk) { walkEnd('Esc'); K.current.lastEscAt = now; return; } // WALK-FIXES: Esc leaves the walkthrough with a summary; the draft stays
4190
4967
  if (now - K.current.lastEscAt < ESC_ESC_MS && B.current.text) {
4191
4968
  if (looksLikeKey(B.current.text)) { edit({ type: 'clear' }); say('Draft cleared. It looked like a key, so it was not kept.'); }
@@ -4281,6 +5058,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4281
5058
  if (e.name === 'escape') { stopCountdown(); cdLog('cancelled'); AR.current.cancelledId = AR.current.newId; say("Restart cancelled. /restart when you're ready.", 'accent'); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4282
5059
  if (e.name === 'enter' && autoIdle()) { stopCountdown(); cdLog('restart-now'); doRestart(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4283
5060
  }
5061
+ if (e.type === 'key' && e.name === 'enter' && !AR.current.timer && !AR.current.waiting && AR.current.quietId && AR.current.quietId === AR.current.newId && autoIdle()) { cdLog('restart-now'); autoFire(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the quiet line's Enter: waits for collector /health like the countdown does
4284
5062
  if (R.current.sleep) { // the sleep screen eats the waking key (nothing is lost), except "/": slash commands always work asleep
4285
5063
  if (e.type === 'key' && e.name === 'ctrl-c') { exit(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4286
5064
  if (R.current.sleep.wakeAt != null) { /* already waking: later keys are typed as normal, so "/back" typed in one go is not cut short */ }
@@ -4297,6 +5075,16 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4297
5075
  if (PK.current.isOpen() && !S.current.approvals.length && PK.current.onEvent(e) !== false) { K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // /resume, /config (typing closes it and falls through)
4298
5076
  if (R.current.rs && (e.type === 'key' || e.type === 'text' || e.type === 'paste')) { rsEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4299
5077
  if (e.type === 'key' && e.name === 'ctrl-r' && !headCard(S.current) && R.current.history.length) { R.current.rs = { saved: B.current, q: '', skip: 0, hit: null }; rsShow(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
5078
+ if (e.type === 'key' && e.name === 'ctrl-]') { // EXT panels: Ctrl+] cycles focus through them
5079
+ if (!DK.current.cycle()) say(DK.current.armed() ? 'No extension has a panel. /ext list' : 'No extension is loaded. /ext list', 'dim');
5080
+ K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue;
5081
+ }
5082
+ if (DK.current.focused() && (e.type === 'text' || (e.type === 'key' && e.name !== 'ctrl-c' && e.name !== 'ctrl-d'))) { // a focused panel has the keys; Esc gives them back
5083
+ let took = false;
5084
+ for (const k of e.type === 'text' ? [...e.text].map(panelKeyName) : [panelKeyName(e)]) took = DK.current.key(k) || took;
5085
+ if (took) { K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
5086
+ }
5087
+ if (e.type === 'key' && e.name === 'escape' && R.current.delConfirm) { cancelDelConfirm(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // Esc backs out of the type-the-name step to the card
4300
5088
  const card = headCard(S.current);
4301
5089
  const g = K.current.guard;
4302
5090
  // any different key ends the "this key was already down" suppression
@@ -4304,6 +5092,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4304
5092
  // Tab (composer empty) puts the card away into the status line until a NEW approval arrives
4305
5093
  // RESTORE-TUI: Tab with a draft focuses the card (the draft is kept, never queued or sent); Esc returns to the draft
4306
5094
  if (card && e.type === 'key' && e.name === 'tab' && B.current.text) { K.current.cardFocus = true; K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
5095
+ if (card && e.type === 'key' && e.name === 'escape' && R.current.denyReason) { R.current.denyReason = null; B.current = initialBuffer(); say('Deny cancelled. The card is still open.', 'dim'); bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // Esc backs out of the Reason? prompt to y/n
4307
5096
  if (card && e.type === 'key' && e.name === 'escape' && K.current.cardFocus) { K.current.cardFocus = false; K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4308
5097
  if (card && e.type === 'key' && e.name === 'tab' && !B.current.text) { for (const x of queueOf(S.current)) K.current.dismissed.add(x.callId); K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
4309
5098
  if (card && K.current.confirm && e.type === 'key') { K.current.confirm = false; bump(n => n + 1); } // any other key cancels a pending privileged confirm
@@ -4365,6 +5154,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4365
5154
  }, (e) => { if (!live || R.current.closed) return; if (e?.reconnectGaveUp) { resumeGaveUp(resumeConversation); return; } dispatch({ type: 'error', text: `could not load conversation ${resumeConversation}: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); });
4366
5155
  return () => { live = false; };
4367
5156
  }, []);
5157
+ const startupRecheck = React.useRef(null);
4368
5158
  React.useEffect(() => {
4369
5159
  if (!startup) return undefined;
4370
5160
  let live = true;
@@ -4377,18 +5167,90 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4377
5167
  timer = null;
4378
5168
  let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
4379
5169
  if (!live) return;
4380
- if (hl?.ok) { say('✔ QuoxCORE is answering again', 'accent'); return; }
5170
+ if (hl?.ok) { noteAnswering(); flushOutbox(); return; }
4381
5171
  recheck(Math.min(i + 1, delays.length - 1));
4382
5172
  }, delays[Math.min(i, delays.length - 1)]);
4383
5173
  };
5174
+ // A fresh window that cannot reach QuoxCORE is most likely racing a collector restart: say so (spinner) for the grace
5175
+ // window and only then fall back to "No QuoxCORE running". Connecting inside it says nothing.
5176
+ const graceThenSay = (note) => {
5177
+ const t0 = Date.now(); let tm = null;
5178
+ const spin = (t) => dispatch({ type: 'notify', patch: { spinner: t } });
5179
+ spin('QuoxCORE is restarting, retrying…');
5180
+ const step = async () => {
5181
+ tm = null;
5182
+ let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
5183
+ if (!live) return;
5184
+ if (hl?.ok) { spin(''); return; }
5185
+ if (Date.now() - t0 >= startupGraceMs) { spin(''); say(note, 'warn'); return; }
5186
+ tm = setTimeout(step, startupRetryMs);
5187
+ };
5188
+ tm = setTimeout(step, startupRetryMs);
5189
+ stopGrace = () => { clearTimeout(tm); spin(''); };
5190
+ };
5191
+ let stopGrace = null;
5192
+ startupRecheck.current = recheck;
4384
5193
  Promise.resolve().then(startup).then((note) => {
4385
5194
  if (!live || !note) return;
5195
+ if (neverAnswered && onConnect && /^No QuoxCORE running/.test(note) && !R.current.neverAsk && !R.current.neverAsked) { // ONBOARD-P2: never answered + dead URL = wrong address, not an outage
5196
+ const url = (/ at (\S+)/.exec(note) || [])[1] || 'the configured server';
5197
+ R.current.neverAsked = true; R.current.neverAsk = { stage: 'offer', url, at: Date.now() };
5198
+ say(askText(`Quox at ${url} has never answered from this machine. Connect to a different server?`, { action: 'ask for its address, check it is a Quox, then sign you in' }), 'ask');
5199
+ return;
5200
+ }
5201
+ if (/^No QuoxCORE running/.test(note) && healthRecheck && startupGraceMs > 0) { graceThenSay(note); return; }
4386
5202
  if (slow(note)) { if (startup.said) return; startup.said = true; } // once per session, even if the probe is mounted twice
4387
5203
  say(note, 'warn');
4388
5204
  if (slow(note)) recheck(0);
4389
5205
  }, () => {});
5206
+ return () => { live = false; clearTimeout(timer); stopGrace?.(); };
5207
+ }, []);
5208
+ // The server holds presence per user: any window's /afk, /dnd, /sleep, /back lands there. A window that is already open
5209
+ // re-reads it (mount + every presencePollMs) so they all agree. An unreadable read changes nothing.
5210
+ function applyServerAway(p) {
5211
+ if (!p || AWAY.current.afk || !(p.away === true || p.state === 'away' || p.state === 'sleeping')) return;
5212
+ const note = p.note || (p.state === 'sleeping' ? 'sleeping' : '');
5213
+ const explicit = p.dnd === true || note.toLowerCase().includes(DND_NOTE); const um = /until (\d\d):(\d\d)/.exec(note); // DND restored from the server note
5214
+ const sMs = Date.parse(p.since); const sOk = Number.isFinite(sMs) && sMs <= Date.now(); // the server knows when this away began: carry it, not this window's start
5215
+ if (sOk) AWAY.current.last = sMs;
5216
+ AWAY.current.afk = { at: sOk ? sMs : Date.now(), ...(sOk ? {} : { sinceUnknown: true }), note, dnd: true, ...(explicit ? { explicit: true, until: um ? untilEpoch({ h: Number(um[1]), m: Number(um[2]) }) : null } : {}) }; AWAY.current.col ||= newCollected(); if (sOk) AWAY.current.col.since = sMs;
5217
+ bump(n => n + 1);
5218
+ }
5219
+ React.useEffect(() => { // PRESENCE-SYNC: /back (or /afk, /dnd) in ANOTHER window of this user reaches this one
5220
+ if (!api.getPresence || !(presencePollMs > 0)) return undefined;
5221
+ let live = true;
5222
+ const t = setInterval(() => {
5223
+ api.getPresence().then((r) => {
5224
+ if (!live || !r?.ok || !r.presence) return; // unreadable: never a verdict
5225
+ const p = r.presence; const isAway = p.away === true || p.state === 'away' || p.state === 'sleeping';
5226
+ if (isAway) { applyServerAway(p); return; }
5227
+ const a = AWAY.current.afk;
5228
+ // present on the server: a window still showing away was ended elsewhere. Not while this window's own write is in flight / failed.
5229
+ if (a && !R.current.presenceUnsynced && Date.now() - (R.current.presenceSetAt || 0) > 15000) { archiveWindow(); AWAY.current.afk = null; bump(n => n + 1); }
5230
+ }, () => {});
5231
+ }, presencePollMs);
5232
+ return () => { live = false; clearInterval(t); };
5233
+ }, []);
5234
+ React.useEffect(() => { // AFK-SURVIVES-RESTART (owner 2026-10-28): a restarted window forgets AWAY; the server still knows. Read-only, never marks present; unreadable = as today.
5235
+ if (!api.getPresence) return undefined;
5236
+ let live = true; let timer = null;
5237
+ // owner live 2026-10-30 10:16: a restart onto a slow collector read nothing and defaulted DND off. An UNREADABLE presence
5238
+ // (error, timeout) is retried with backoff and never means "present"; only a successful read can say so.
5239
+ const attempt = (i) => {
5240
+ const retry = () => { if (live && i < presenceRetryDelays.length) timer = setTimeout(() => attempt(i + 1), presenceRetryDelays[i]); };
5241
+ api.getPresence().then((r) => {
5242
+ if (!r?.ok) { retry(); return; }
5243
+ if (!live) return;
5244
+ applyServerAway(r.presence);
5245
+ }, retry);
5246
+ };
5247
+ attempt(0);
4390
5248
  return () => { live = false; clearTimeout(timer); };
4391
5249
  }, []);
5250
+ React.useEffect(() => { // DND "until HH:MM": the deadline ends it (the owner is back at that time; collected items stay for /morningreport)
5251
+ const t = setInterval(() => { const a = AWAY.current.afk; if (a?.explicit && a.until && Date.now() >= a.until) dndOff(); }, 15000);
5252
+ return () => clearInterval(t);
5253
+ }, []);
4392
5254
  React.useEffect(() => { // E5: "While you were away" under the intro, after more than 2 h away
4393
5255
  if (!away) return undefined;
4394
5256
  let live = true;
@@ -4484,10 +5346,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4484
5346
  }
4485
5347
  pendingHist = s.history.slice(committedRef.current);
4486
5348
  }
5349
+ const staticCols = columns != null ? cols : liveCols(stdout, cols); // history prints at the terminal's width NOW
4487
5350
  const staticItems = (() => { // QLOOP-LIVE: the committed history for <Static>; append-only, a settled loop card joins at the end
4488
5351
  let st = staticRef.current;
4489
5352
  const upto = Math.min(committedRef.current, s.history.length);
4490
- if (st.seen > s.history.length) st = staticRef.current = { list: [], seen: 0, deferred: [] };
5353
+ if (st.seen > s.history.length || st.epoch !== epoch) st = staticRef.current = { list: [], seen: 0, deferred: [], epoch }; // /clear bumps the epoch: a reset that leaves history no shorter than before (one welcome, then one note) must still start the list over
4491
5354
  let list = st.list;
4492
5355
  for (; st.seen < upto; st.seen++) { const it = s.history[st.seen]; if (LC.current.held(it)) st.deferred.push(it); else list = [...list, it]; }
4493
5356
  if (st.deferred.length) { const done = st.deferred.filter((it) => !LC.current.held(it)); if (done.length) { st.deferred = st.deferred.filter((it) => !done.includes(it)); list = [...list, ...done]; } }
@@ -4498,6 +5361,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4498
5361
  // Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
4499
5362
  const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
4500
5363
  const queue = queueOf(s);
5364
+ const burst0 = queue[0] && !signedOut ? batchFor(queue, queue[0]) : null; // APPROVAL-BURST: the head card's batch, if it has one
4501
5365
  const card = queue[0] || null;
4502
5366
  const armed = card ? Date.now() - Math.max(card.displayedAt ?? card.shownAt ?? 0, K.current.guard?.since ?? 0) >= 300 : false;
4503
5367
  R.current.cardMidAnim = Boolean(card && motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000); // this render draws the card mid-animation (appDue keeps it due until a settled draw)
@@ -4527,13 +5391,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4527
5391
  function approvalRowShown() {
4528
5392
  const ro = R.current.roster;
4529
5393
  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() });
5394
+ const v = rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40, max: MAX_ROSTER, on: ro.on, sel: ro.sel, focus: ro.focus, now: Date.now(), staleAt: signedOut ? ro.at || Date.now() : null });
4531
5395
  return v.mode === 'full' && v.lines.some(l => l.kind === 'approval');
4532
5396
  }
4533
5397
 
5398
+ /** EXT panels: the host-owned tab strip (only with 1+ panels) and, when one is open, its clipped lines above the composer. */
5399
+ function extPanelRows() {
5400
+ const ps = DK.current.panels(); if (!ps.length) return [];
5401
+ const open = DK.current.shown();
5402
+ const rowsN = DK.current.regionHeight(termRows(stdout));
5403
+ const theme = { name: mono() ? 'mono' : isLight() ? 'light' : 'dark', color: !mono(), unicode: /utf-?8/i.test(String(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '')) };
5404
+ const out = [h(Box, { key: 'ext-tabs', paddingLeft: 1 }, ...ps.map(p => h(Text, { key: p.name, color: c(p.focused ? 'accent' : 'dim'), bold: p.focused, inverse: p.focused }, ` ${p.title} `)), h(Text, { key: 'ext-hint', color: c('dim') }, DK.current.focused() ? ' Esc: composer Ctrl+]: next' : ' Ctrl+]: focus'))];
5405
+ if (open) {
5406
+ const lines = DK.current.renderPanel(open, { width: Math.max(1, cols - 2), height: rowsN, theme });
5407
+ out.push(h(Box, { key: 'ext-body', flexDirection: 'column', paddingLeft: 1 }, ...lines.map((l, i) => h(Text, { key: i, wrap: 'truncate-end' }, l || ' '))));
5408
+ }
5409
+ return out;
5410
+ }
4534
5411
  function rosterNode() {
4535
5412
  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() });
5413
+ const view = rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40, max: MAX_ROSTER, on: ro.on, sel: ro.sel, focus: ro.focus, now: Date.now(), motion: motionOn && !mono(), staleAt: signedOut ? ro.at || Date.now() : null });
4537
5414
  const row = ro.rows[ro.sel];
4538
5415
  return h(Roster, {
4539
5416
  view, cols,
@@ -4547,14 +5424,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4547
5424
  if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
4548
5425
  const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
4549
5426
  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)) }))),
5427
+ h(Static, { key: `static-${epoch}`, items: staticItems }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols: staticCols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
4551
5428
  h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
4552
5429
  }
4553
5430
  const ruleAbove = endsInRule({ card, asks: s.asks || [], background: s.background, pending: s.pending, steering: s.steering, countdown: Boolean(AR.current.timer && AR.current.endsAt) }) && !(SU.current.isOpen() || LI.current.isOpen() || CN.current.isOpen());
4554
5431
  return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
4555
5432
  // Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
4556
5433
  // 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)) }))),
5434
+ h(Static, { key: `static-${epoch}`, items: introDone.current ? staticItems : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols: staticCols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
4558
5435
  introDone.current ? null : h(Box, { key: 'intro-live', flexDirection: 'column' }, h(Intro, { item: introItem, cols, dragon: introDragon, progress: Math.min(1, (Date.now() - introStart.current) / introMs) })),
4559
5436
  liveRegion([
4560
5437
  ...pendingHist.map((it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(FxItem, { item: it, cols, fxFor, gap: historyGap(i ? pendingHist[i - 1] : s.history[committedRef.current - 1], it, termRows(stdout)), active: busy && it.kind === 'trace' && i === pendingHist.length - 1 && !s.blocks.length }))),
@@ -4566,22 +5443,25 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4566
5443
  h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
4567
5444
  busy ? h(Working, { key: 'working', motion: motionOn && !R.current.exiting, handoff: feats().handoff, tokens: turnTokens(s.turn), act: s.turn.act, activity: describeActivity(s), since: s.turn.startedAt, interrupting: s.turn.status === 'interrupting', escLabel: caps.interrupt ? null : 'Esc disconnects' }) : null,
4568
5445
  ...LC.current.pinned().map((lc) => h(Box, { key: `loopcard-${lc.id}`, marginTop: 1 }, h(LoopCardView, { item: { card: lc }, cols, pinned: true }))), // QLOOP-LIVE: running loops are pinned above the composer
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,
5446
+ // PIN-GLITCH: while the PIN pad is open the pad is the whole question (its subject names the action). Keeping the card body above it
5447
+ // pushed the live region to the terminal height, and Ink then wipes and rewrites the whole screen on every keystroke and poll.
5448
+ SU.current.isOpen() ? null : card && burst0 ? h(BatchCard, { model: batchModel(burst0), cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null }) : card ? h(ApprovalCard, { card, org: orgShown() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, signedOut: Boolean(signedOut), draftHint: R.current.denyReason ? 'reason' : K.current.cardFocus ? 'focus' : (B.current.text ? 'draft' : null), confirm: K.current.confirm, position: { i: 1, n: queue.length, others: otherSessionsCount(queue) } }) : null,
4570
5449
  ...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
4571
5450
  // TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
4572
5451
  s.background > 0 ? h(Box, { paddingLeft: 2 }, h(Text, { color: c('dim') }, `Waiting for ${s.background} background agent${s.background === 1 ? '' : 's'} to finish`)) : null,
4573
5452
  h(Pending, { pending: s.pending, steering: s.steering, cols }),
4574
5453
  AR.current.timer && AR.current.endsAt ? h(Box, { key: 'restart-countdown', paddingLeft: 2 }, h(Text, { color: c('accent'), bold: true }, countdownLine(Math.max(1, Math.ceil((AR.current.endsAt - Date.now()) / 1000)), cols, { waiting: Boolean(AR.current.waiting) }))) : null,
5454
+ ...extPanelRows(),
4575
5455
  ruleAbove ? null : h(Box, { marginTop: 1 }),
4576
5456
  SU.current.isOpen() ? h(StepUpCard, { view: SU.current.view(), cols }) : LI.current.isOpen() ? h(LoginCard, { ...LI.current.view(), cols, profile }) : CN.current.isOpen() ? h(ConnectCard, { ...CN.current.view(), cols })
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) } }),
5457
+ : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, slow: s.notify.slow, signedOut: Boolean(blocked), outstanding: s.notify.outstanding, blind: s.notify.blind, others: s.notify.others }, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
5458
+ : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, slow: s.notify.slow, signedOut: Boolean(blocked), outstanding: s.notify.outstanding, blind: s.notify.blind, others: s.notify.others } }),
4579
5459
  !CN.current.isOpen() && PK.current.isOpen() && !card ? h(ListView, { ...PK.current.view(), cols }) : !CN.current.isOpen() && PL.current.isOpen() && !card ? h(ListView, { ...PL.current.view(), cols }) : null,
4580
5460
  h(RowsBar, { rows: RW.current.view(), cols, limit: { grid: QPE_GRID, n: qpeRowsSetting(api.settings?.()) } }),
4581
5461
  h(PresentBar, { stepUp: SU.current, cols }),
4582
5462
  h(StatusLine, { cols, slot: where.current.slot, agent: where.current.agent, groups: [...SC.current.rooms].map(([name, e]) => ({ name, unread: e.unread })), parts: ctxParts, right: hint }),
4583
5463
  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)] }),
5464
+ h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), away: Boolean(AWAY.current.afk), awaySince: AWAY.current.afk?.sinceUnknown ? 0 : AWAY.current.afk?.at || 0, awayUnknown: Boolean(AWAY.current.afk?.sinceUnknown), dndUntil: AWAY.current.afk?.until || 0, dnd: Boolean(AWAY.current.afk?.explicit), awayCount: Math.max(s.notify.approvals || 0, [...s.approvals, ...s.waiting].length), dndCount: waitingCount(AWAY.current.col, Math.max(s.notify.approvals || 0, [...s.approvals, ...s.waiting].length)), inbox: AWAY.current.afk ? '' : (s.notify.inboxBar || ''), spinner: s.notify.spinner || '', alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
4585
5465
  rosterNode())));
4586
5466
  }
4587
5467