quoxcli 0.3.1 → 0.4.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 (188) hide show
  1. package/lib/api/endpoints/actions.js +15 -0
  2. package/lib/api/endpoints/admin.js +20 -0
  3. package/lib/api/endpoints/blog.js +16 -0
  4. package/lib/api/endpoints/chat.js +7 -0
  5. package/lib/api/endpoints/contactsq.js +4 -0
  6. package/lib/api/endpoints/devServerRegistry.js +25 -0
  7. package/lib/api/endpoints/engagement.js +26 -0
  8. package/lib/api/endpoints/evidence.js +20 -0
  9. package/lib/api/endpoints/hat.js +14 -0
  10. package/lib/api/endpoints/index.js +12 -0
  11. package/lib/api/endpoints/loops.js +26 -0
  12. package/lib/api/endpoints/mon.js +173 -0
  13. package/lib/api/endpoints/objectives.js +6 -0
  14. package/lib/api/endpoints/quoxseo.js +6 -0
  15. package/lib/api/endpoints/server.js +21 -0
  16. package/lib/api/endpoints/setup.js +6 -0
  17. package/lib/api/endpoints/v1.js +34 -0
  18. package/lib/commands/actions.js +81 -11
  19. package/lib/commands/agent.js +11 -4
  20. package/lib/commands/agentAttach.js +46 -7
  21. package/lib/commands/approvals.js +5 -0
  22. package/lib/commands/ask.js +3 -1
  23. package/lib/commands/blog.js +110 -0
  24. package/lib/commands/chat.js +127 -28
  25. package/lib/commands/chatCompanion.js +27 -0
  26. package/lib/commands/chatState.js +45 -0
  27. package/lib/commands/claude.js +3 -32
  28. package/lib/commands/contacts.js +85 -4
  29. package/lib/commands/contactsSendAt.js +21 -0
  30. package/lib/commands/engagement.js +90 -0
  31. package/lib/commands/evidence.js +154 -0
  32. package/lib/commands/ext.js +23 -0
  33. package/lib/commands/fleet.js +25 -0
  34. package/lib/commands/hat.js +76 -0
  35. package/lib/commands/image.js +68 -0
  36. package/lib/commands/kuma.js +1050 -0
  37. package/lib/commands/kumaSla.js +174 -0
  38. package/lib/commands/licence.js +39 -1
  39. package/lib/commands/loop.js +291 -0
  40. package/lib/commands/objective.js +48 -1
  41. package/lib/commands/org.js +21 -4
  42. package/lib/commands/provider.js +58 -0
  43. package/lib/commands/quoxseo.js +31 -0
  44. package/lib/commands/research.js +79 -0
  45. package/lib/commands/rows.js +9 -0
  46. package/lib/commands/run.js +18 -1
  47. package/lib/commands/scope.js +2 -2
  48. package/lib/commands/server.js +113 -0
  49. package/lib/commands/shortcut.js +41 -0
  50. package/lib/commands/skills.js +20 -4
  51. package/lib/commands/vm.js +2 -2
  52. package/lib/commands/youtube.js +172 -0
  53. package/lib/core/actionItems.js +115 -0
  54. package/lib/core/agentAlias.js +2 -0
  55. package/lib/core/agentFamilies.js +7 -0
  56. package/lib/core/client.js +10 -2
  57. package/lib/core/commandRegistrars.js +9 -0
  58. package/lib/core/config.js +1 -1
  59. package/lib/core/findingReconcile.js +90 -0
  60. package/lib/core/global-flags.js +1 -1
  61. package/lib/core/governedSession.js +1 -3
  62. package/lib/core/liveSessionsReport.js +1 -1
  63. package/lib/core/loopGlyph.js +97 -0
  64. package/lib/core/offload.js +36 -7
  65. package/lib/core/output.js +7 -7
  66. package/lib/core/sessionHeartbeat.js +12 -8
  67. package/lib/core/shortcut.js +83 -0
  68. package/lib/core/version.js +1 -1
  69. package/lib/core/vm.js +5 -0
  70. package/lib/dragon/companionView.js +34 -0
  71. package/lib/dragon/frames.js +118 -0
  72. package/lib/dragon/jobs.js +123 -0
  73. package/lib/dragon/renderWorker.js +15 -0
  74. package/lib/dragon/select.js +72 -0
  75. package/lib/evidence/bundleExport.js +47 -0
  76. package/lib/evidence/bundleVerify.js +77 -0
  77. package/lib/quoxon/EXTENSIONS.md +23 -0
  78. package/lib/quoxon/api.js +192 -6
  79. package/lib/quoxon/app.js +655 -89
  80. package/lib/quoxon/approvalFacts.js +8 -6
  81. package/lib/quoxon/approvalSentence.js +44 -0
  82. package/lib/quoxon/approvalsView.js +63 -4
  83. package/lib/quoxon/commandShort.js +15 -3
  84. package/lib/quoxon/commands.js +75 -10
  85. package/lib/quoxon/countdownLine.js +10 -1
  86. package/lib/quoxon/deck.js +30 -0
  87. package/lib/quoxon/demo.autoplay.js +1 -1
  88. package/lib/quoxon/demo.js +3 -1
  89. package/lib/quoxon/dividerAocl.js +4 -3
  90. package/lib/quoxon/dividerRule.js +3 -3
  91. package/lib/quoxon/dragonCompanion.js +255 -0
  92. package/lib/quoxon/dragonModes.js +1 -0
  93. package/lib/quoxon/dragonMoments.js +82 -0
  94. package/lib/quoxon/dragonReads.js +68 -0
  95. package/lib/quoxon/dragonView.js +2 -2
  96. package/lib/quoxon/events.js +24 -3
  97. package/lib/quoxon/extensions.js +276 -0
  98. package/lib/quoxon/inboxView.js +79 -0
  99. package/lib/quoxon/installCli.js +79 -0
  100. package/lib/quoxon/keys.js +2 -2
  101. package/lib/quoxon/loopCard.js +456 -0
  102. package/lib/quoxon/loopCards.js +180 -0
  103. package/lib/quoxon/loopStream.js +87 -0
  104. package/lib/quoxon/main.js +12 -3
  105. package/lib/quoxon/mouse.js +12 -1
  106. package/lib/quoxon/objectiveOffer.js +82 -0
  107. package/lib/quoxon/objects/index.js +22 -4
  108. package/lib/quoxon/objects/model.js +33 -0
  109. package/lib/quoxon/objects/proof.js +12 -4
  110. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  111. package/lib/quoxon/onboarding/view.js +1 -1
  112. package/lib/quoxon/orgSwitch.js +27 -1
  113. package/lib/quoxon/planSteps.js +51 -0
  114. package/lib/quoxon/print.js +6 -3
  115. package/lib/quoxon/qdragonFly.js +7 -6
  116. package/lib/quoxon/qmotion.js +17 -0
  117. package/lib/quoxon/render.js +130 -33
  118. package/lib/quoxon/roster.js +26 -12
  119. package/lib/quoxon/rows.js +244 -1
  120. package/lib/quoxon/serverChip.js +42 -0
  121. package/lib/quoxon/session.js +10 -0
  122. package/lib/quoxon/slash/cliCommand.js +42 -1
  123. package/lib/quoxon/slash/cliLeaves.json +76 -1
  124. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  125. package/lib/quoxon/slash/palette.js +1 -1
  126. package/lib/quoxon/slash/qpeCmd.js +35 -0
  127. package/lib/quoxon/slash/restart.js +25 -1
  128. package/lib/quoxon/slash/resume.js +18 -1
  129. package/lib/quoxon/slash/todoCmd.js +122 -0
  130. package/lib/quoxon/suggestCommand.js +33 -2
  131. package/lib/quoxon/toolCards.js +30 -0
  132. package/lib/quoxon/whatsnew.jsonl +45 -0
  133. package/lib/quoxon/windowOrg.js +11 -1
  134. package/lib/server/devServers.js +137 -0
  135. package/package.json +2 -1
  136. package/vendor/dragonlab/SOURCE +4 -0
  137. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  138. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  139. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  140. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  141. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  142. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  143. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  144. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  145. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  146. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  147. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  148. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  149. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  150. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  151. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  152. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  153. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  154. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  155. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  156. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  157. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  158. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  159. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  160. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  161. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  162. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  163. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  164. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  165. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  166. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  167. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  168. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  169. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  170. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  171. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  172. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  173. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  174. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  175. package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
  176. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  177. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  178. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  179. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  180. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  181. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  182. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  183. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  184. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  185. package/vendor/dragonlab/lib/power-effects.js +294 -0
  186. package/vendor/dragonlab/lib/power-registry.js +74 -0
  187. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  188. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
package/lib/quoxon/app.js CHANGED
@@ -11,15 +11,15 @@ import { mkdtempSync, writeFileSync, writeSync, readFileSync, unlinkSync } from
11
11
  import { tmpdir, hostname as osHostname } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  import { bufferReducer, initialBuffer, sanitizeForSend, wordAt, selection, selectedText, atVisualEdge } from './textBuffer.js';
14
- import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, screenHit, classifyClick, composerIndexAt } from './mouse.js';
14
+ import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, lineAt, MOUSE_HINT, screenHit, classifyClick, composerIndexAt } from './mouse.js';
15
15
  import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
16
16
  import { isHeldFromBulk, heldReason } from './bulkRisk.js';
17
- import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, parseAnswer, parsePairs } from './approvalsView.js';
17
+ import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, approvalAsWalkItem, parseAnswer, parsePairs } from './approvalsView.js';
18
18
  import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
19
- import { reduce, initialState, approvalKeyAllowed, groupApprovals, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
19
+ import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
20
20
  import { turnTokens } from './indicator.js';
21
21
  import {
22
- Composer, RingComposer, Pending, ApprovalCard, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
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
23
  , endsInRule } from './render.js';
24
24
  import { c, mono } from './theme.js';
25
25
  import { strayCardAnswer, strayQuestion, alreadyQueued } from './stray.js';
@@ -34,18 +34,22 @@ import { nextTip, normaliseMode, tipSettingsReply, tipsReply, DEFAULT_MODE, TIPS
34
34
  import { findSkill, skillRows, skillDetail, skillPrompt } from './skills.js';
35
35
  import { createScreenContext } from './screenContext.js';
36
36
  import { askText, askQuestion, askAnswer, askChatMessage, settledAskLine, settledAskTone, askOutcomeOf, isAskClarification, askClock } from './ask.js';
37
+ import { objectiveFromEvent, objectiveAskText, objectiveStartedNote, objectiveWatchLine, objectiveFailedNote, watchObjective } from './objectiveOffer.js';
37
38
  import { createHandoffStreak, handoffBiggerAskText, handoffFromEvent, handoffAskText, handoffAnsweredNote, handoffSupersededNote, handoffSkippedNote } from './handoffOffer.js';
38
39
  import * as selfUpdate from '../core/selfUpdate.js';
40
+ import { parseDone, todoDoneLine, parseTodo, todoAddedLine, todoBoardText, numberBoard, resolveRef, todoMovedLine } from './slash/todoCmd.js';
39
41
  import { runRequest, runAnswer, handOverRun, runFooter } from './slotRun.js';
40
42
  import { profileChooserRows, liveApi, switchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
41
43
  import { rosterView, peekLines, selectRows, backgroundAlerts, isExpired, applyProgress, SPIN_MS } from './roster.js';
42
44
  import { runObjectCommand } from './objects/index.js';
45
+ import { runEvidenceCommand } from './slash/evidenceCmd.js';
43
46
  import { peekLines as peekIdLines } from '../core/peek.js';
44
47
  import { turnsFromItems, asideNoteText } from './asideView.js';
45
48
  import { createMentionCache, loadMentions, receiptMentions } from './mentions.js';
46
49
  const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile', '/fleet', '/prove', '/replay', '/health', '/core']);
47
50
  import { intentHint } from './intent.js';
48
51
  import { observerLines } from './observers.js';
52
+ import { inboxBarText, arrivals, arrivalLine, inboxTable, inboxDetail, parseInbox, INBOX_USAGE, newestFirst, isApprovalItem } from './inboxView.js';
49
53
  import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
50
54
  import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
51
55
  import { packageVersion } from './welcome.js';
@@ -53,7 +57,7 @@ import { feedbackPayload, feedbackPreview } from './feedback.js';
53
57
  import { vetSuggestion, namedCommand } from './suggestCommand.js';
54
58
  import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // PLAN MODE (owner 2026-10-16)
55
59
  import { echoLine } from './echo.js';
56
- import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
60
+ import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, looksLikeAddress, sayWhoText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
57
61
  import { pushHistory } from './promptHistory.js';
58
62
  import { termRows } from './term.js';
59
63
  import { cardTallerThanScreen } from './objects/view.js';
@@ -70,9 +74,9 @@ import { humanSessionLines } from '../core/sessionsOverview.js';
70
74
  import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
71
75
  import { capabilityLines, offerFor, findCapability } from './capabilities.js';
72
76
  import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
73
- import { DragonLive, playDragon, dragonActive, resetDragon } from './qmotion.js';
77
+ import { DragonLive, playDragon, dragonActive, resetDragon, clipRoom, flyRoom } from './qmotion.js';
74
78
  import { AUTOPLAY, AUTOPLAY_REQUEST, AUTOPLAY_PROOF, typingPlan } from './demo.autoplay.js';
75
- import { motionLevel, levelFeatures, moodForEvent, ignitionGlow, normaliseLevel } from './motion/level.js';
79
+ import { motionLevel, levelFeatures, ignitionGlow, normaliseLevel } from './motion/level.js';
76
80
  import { CLIPS as DRAGON_CLIPS } from './qdragonScenes.js';
77
81
  import { DRAGON_USAGE, DRAGON_EXPLAINS, DRAGON_ANSWERS, NOTHING_TO_EXPLAIN, ASK_PROMPT, WAKE_MS, explainPrompt, askPrompt, lastOutput, parseDragonArg, sourceLines } from './dragonModes.js';
78
82
  import { SleepScreen, DragonTalk, FlyLive } from './dragonView.js';
@@ -85,6 +89,7 @@ import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
85
89
  import { parseBroadcastText } from './slash/broadcast.js';
86
90
  import { isOwnBroadcast } from './ownBroadcast.js';
87
91
  import { orgLabel, isOrgId } from './orgLabel.js';
92
+ import { enrichPlanItem } from './planSteps.js';
88
93
  import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine } from './slotChat.js';
89
94
  import { homedir as osHome } from 'node:os';
90
95
  import { createRowCmd } from './slash/rowCmd.js';
@@ -92,6 +97,13 @@ import { createKillerCmds } from './slash/killer.js';
92
97
  import { createUndoWish } from './slash/undoWish.js';
93
98
  import { createHatWalk } from './slash/hatWalk.js';
94
99
  import { createRowEngine, runShell } from './rows.js';
100
+ import { qpeCommand, qpeRowsSetting, QPE_GRID } from './slash/qpeCmd.js';
101
+ import { createExtensionHost, pickDraft as deckPickDraft, readExtList, extSetEnabled, extAdd } from './extensions.js';
102
+ import { createLoopCards, rowsWithoutLoopCards } from './loopCards.js';
103
+ import { initLoop, applySnapshot as applyLoopSnapshot, cardLines } from './loopCard.js';
104
+ import { createCompanionController, companionCommand, DragonCompanionLive, takeGreeting, detachGreeting } from './dragonCompanion.js';
105
+ import { dragonForAgent } from '../dragon/select.js';
106
+ import { createDragonJobs } from '../dragon/jobs.js';
95
107
  import { createModelCmd } from './slash/modelCmd.js';
96
108
  import { createChallengeCmd } from './slash/challenge.js';
97
109
  import { createSpawnCmd } from './slash/spawn.js';
@@ -101,10 +113,10 @@ import { createLedger, costModel } from './slash/ledger.js';
101
113
  import { statusModel, contextModel, doctorModel, chatChecks, modelText } from './slash/panels.js';
102
114
  import { parseAgentsArgs, filterAgents, agentPickerRow, toolLines, whichReply, looseCommand } from './slash/agents.js';
103
115
  import { createPicker } from './slash/picker.js';
104
- import { resumeRows, pickerRow, matchConversation, turnsToHistory } from './slash/resume.js';
116
+ import { resumeRows, pickerRow, matchConversation, turnsToHistory, cleanTitle, titleFromTurns } from './slash/resume.js';
105
117
  import { settingsList, applySettings, configRows, cycle } from './slash/settings.js';
106
118
  import { countdownLine } from './countdownLine.js';
107
- import { startBuildWatch, DIFFERENT_NOTICE } from './slash/restart.js';
119
+ import { startBuildWatch, DIFFERENT_NOTICE, waitForHealthy, SERVER_WAIT_EVERY_MS, SERVER_WAIT_MAX_MS } from './slash/restart.js';
108
120
  import { dashboardLines } from './slash/dashboard.js';
109
121
  import { supportsHyperlinks } from './markdown.js';
110
122
  import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
@@ -119,6 +131,7 @@ import { FrameContext, useFrame } from './motion/frame.js';
119
131
  import { ringMode, ringAnimating } from './motion/ring.js';
120
132
  import { TIMING, revealSpan } from './motion/reveal.js';
121
133
  import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
134
+ import { INSTALL_TOOLS, SUGGEST_LINE, planLine, offerQuestion, runInstall, missingTools } from './installCli.js';
122
135
  import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
123
136
  import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
124
137
 
@@ -157,7 +170,7 @@ const byId = (id) => CHOICES.find(x => x.id === id) || null;
157
170
  // the status line shows the new ask until the server answers
158
171
  const NO_RAN = { provider: null, model: null, endpoint: null };
159
172
 
160
- export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, autoTickMs = 1000, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined }) {
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 }) {
161
174
  // Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
162
175
  // one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
163
176
  // PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
@@ -183,6 +196,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
183
196
  const exit = (afterMs = 0) => {
184
197
  const R0 = R.current;
185
198
  if (R0.exiting) return undefined;
199
+ try { DK.current?.unloadAll(); } catch { /* closing the deck never blocks exit */ }
186
200
  R0.exiting = true; R0.sleep = null; R0.fly = null;
187
201
  R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
188
202
  resetDragon();
@@ -232,6 +246,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
232
246
  // Drop-in reveals: history items enter <Static> only once their reveal has ended (at most ~1 s); until then
233
247
  // they render in the live region. committedRef = how many history items <Static> has been given.
234
248
  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)
235
250
  const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
236
251
  const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
237
252
  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 });
@@ -252,7 +267,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
252
267
 
253
268
  const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
254
269
  /** QUOXON-FW15: the one card shown: the oldest waiting approval (this conversation first, then other sessions/plugins) not dismissed with Tab. */
255
- const queueOf = (st) => { const all = [...st.approvals, ...st.waiting]; const live = new Set(all.map(x => x.callId)); for (const id of K.current.dismissed) if (!live.has(id)) K.current.dismissed.delete(id); return all.filter(x => !K.current.dismissed.has(x.callId)); };
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)); };
256
271
  const headCard = (st) => queueOf(st)[0] || null;
257
272
  const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
258
273
  const dispatch = (a) => {
@@ -264,7 +279,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
264
279
  }
265
280
  transcript?.action(a.redact !== undefined ? { ...a, text: a.redact } : a); // dev transcript capture (off by default; see transcript.js); `redact` is what the capture sees instead of a secret-bearing text
266
281
  const prevS = S.current; S.current = reduce(prevS, { now: Date.now(), ...a }); SND.current.observe(prevS, S.current, a);
267
- if (motionOn && !mono() && feats().moods) { const m = moodForEvent(prevS, a, S.current); if (m) playDragon(m); } // cinematic: the dragon reacts
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)
268
283
  // Per shown card: remember the key that was already down when it appeared, and restart the typed word.
269
284
  const c0 = headCard(S.current);
270
285
  if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
@@ -292,6 +307,33 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
292
307
  };
293
308
  // QUOXON-ASK: every question for the human is ONE shape (▶ reason. question? keys), highlighted. Never say a raw y/N.
294
309
  const ask = (question, opts) => say(askText(question, opts), 'ask');
310
+ // LAZY-AI-CLI (owner 2026-10-19): when something needs Claude Code or Codex and this server lacks it, offer to
311
+ // install it THEN, as one y/N card (never "type this command"). The server installs the vendor's own npm package.
312
+ const offerInstall = async (tool) => {
313
+ const t = INSTALL_TOOLS[tool];
314
+ const plan = api.providerCliPlan ? await api.providerCliPlan(tool) : null;
315
+ if (!plan?.ok) {
316
+ if (plan?.status === 403) say(`${t.name} isn't installed on this server. An instance admin can install it from the Quox terminal.`, 'warn');
317
+ else if (plan?.status === 404 || plan?.status === 405 || !api.providerCliPlan) say(`${t.name} isn't installed on this server, and this server is too old to install it for you. Update the server first.`, 'warn');
318
+ else say(`${t.name} isn't installed on this server, and Quox could not check npm right now (${plan?.error || 'no answer'}).`, 'warn');
319
+ return nextInstallOffer();
320
+ }
321
+ say(planLine(plan), 'dim');
322
+ R.current.installAsk = { tool, plan, at: Date.now() };
323
+ return ask(offerQuestion(tool, plan), { action: `install ${t.name}`, reason: `${t.vendor}'s own package, downloaded from npm onto this server.` });
324
+ };
325
+ function nextInstallOffer() {
326
+ const q = R.current.installQueue;
327
+ if (!q || !q.length) return undefined;
328
+ return offerInstall(q.shift());
329
+ }
330
+ async function installNow(a) {
331
+ say(`◐ installing ${INSTALL_TOOLS[a.tool].name} ${a.plan?.version || ''} on the server (a minute or two)`.trim(), 'dim');
332
+ const r = await runInstall(api, a.tool, { onProgress: (l) => say(l, 'dim') });
333
+ for (const l of r.lines) say(l, r.ok ? 'ok' : 'warn');
334
+ if (r.ok) bump(n => n + 1);
335
+ return nextInstallOffer();
336
+ }
295
337
  // PLAN MODE: this window's mode ('ask' | 'plan'). Window-scoped: rides /restart, never the shared profile.
296
338
  const MD = React.useRef(null); MD.current ??= normMode(mode);
297
339
  const askerName = () => String(SN.current?.agent || where.current?.agent || 'Quox').toUpperCase(); // a model-suggested command: who asks
@@ -365,6 +407,56 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
365
407
  const rowsLoaded = React.useRef(false);
366
408
  React.useEffect(() => { if (!blocked && !demoLabel && !rowsLoaded.current) { rowsLoaded.current = true; RW.current.load(); } }, [blocked]);
367
409
  React.useEffect(() => () => RW.current.stopEngine(), []);
410
+ // EXTENSIONS (extensions.js): optional npm packages listed in ~/.quox/extensions.json. They can open an approval or
411
+ // preselect an answer; the user confirms here. Core ships no extension-specific code.
412
+ const DK = React.useRef(null);
413
+ DK.current ??= createExtensionHost({
414
+ env,
415
+ getSettings: () => api.settings?.() || {},
416
+ handlers: {
417
+ openApproval: (n) => { command(`/approvals ${n}`); },
418
+ preselect: (n, option) => {
419
+ const it = (R.current.deckItems || [])[n - 1];
420
+ const draft = deckPickDraft(it, n, option);
421
+ if (!draft) return say(`Extension: no such option "${option}" for [${n}].`, 'warn');
422
+ edit({ type: 'set', text: draft }); // preselected only: the answer goes when the user presses Enter
423
+ say(`Preselected: ${draft.trim()}. Press Enter to confirm.`, 'accent');
424
+ },
425
+ changed: () => bump(n => n + 1)
426
+ },
427
+ log: () => {}
428
+ });
429
+ const extDispatch = async (cmd, restArg) => { // a loaded extension's own slash command; true when one handled it
430
+ const xr = await DK.current.runCommand(cmd, restArg, { say: (t, tone) => say(t, tone), org: orgShown(), host: DK.current });
431
+ if (!xr.handled) return false;
432
+ if (xr.error) say(`${cmd}: ${xr.error}`, 'warn'); else if (typeof xr.result === 'string') say(xr.result); else if (xr.result?.text) say(xr.result.text, xr.result.tone);
433
+ return true;
434
+ };
435
+ const extLoad = async (name, quiet) => { const r = await DK.current.load(name); if (!quiet || r.ok) say(r.text, r.ok ? 'ok' : 'warn'); return r; };
436
+ React.useEffect(() => {
437
+ if (!blocked && !demoLabel) for (const x of readExtList()) if (x.enabled) extLoad(x.name, true).catch(() => {});
438
+ return () => { DK.current.unloadAll(); };
439
+ }, []);
440
+ React.useEffect(() => { // fleet for extensions: a light 30 s poll of the existing host list, only while an extension is loaded
441
+ if (!DK.current.armed() || !api.mentionHosts) return undefined;
442
+ let dead = false;
443
+ const poll = () => api.mentionHosts().then((h) => { if (!dead) { R.current.fleetHosts = h; bump(n => n + 1); } }, () => { if (!dead) { R.current.fleetHosts = null; } });
444
+ poll(); const id = setInterval(poll, 30000);
445
+ return () => { dead = true; clearInterval(id); };
446
+ }, [DK.current.armed()]);
447
+ React.useEffect(() => { // push what this window already holds; the host dedupes and throttles to 1/s
448
+ if (!DK.current.armed()) return;
449
+ const rows = R.current.roster?.allRows || [];
450
+ DK.current.push({
451
+ org: { id: R.current.orgId ?? api.orgId?.() ?? null, name: orgShown() },
452
+ items: R.current.deckItems || [],
453
+ health: { collector: R.current.notify?.fails > 0 || S.current.notify?.error ? 'down' : 'ok', rows: rows.some(r => r.state === 'failed') ? 'warn' : 'ok' },
454
+ 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
+ fleet: R.current.fleetHosts || null,
456
+ evidence: api.evidenceChain?.() || null, // quoxon holds no chain status today: 'unknown' unless the api provides one
457
+ sessions: { active: rows.filter(r => r.state === 'running').length }
458
+ });
459
+ });
368
460
  PL.current ??= createPalette({
369
461
  getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
370
462
  run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
@@ -378,13 +470,47 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
378
470
  flagged: modelChoice != null, off: Boolean(blocked || demoLabel),
379
471
  dispatchCtx: () => dispatch({ type: 'ctx', ctx: NO_RAN })
380
472
  }), []);
473
+ // TUI-STATUS: the registered server this runs on, read once at start (a restart re-reads); null = show nothing
474
+ const SV = React.useRef(null); SV.current ??= { chip: null };
475
+ React.useEffect(() => { // retry every 3 min (max 20) until the lookup succeeds: a collector restart at startup must not hide the chip for the whole session; failing shows nothing
476
+ if (blocked || demoLabel || !api.serverChip) return undefined;
477
+ let stopped = false, timer = null, tries = 0;
478
+ const attempt = () => { tries++; Promise.resolve().then(() => api.serverChip()).then((c) => { if (stopped) return; if (c?.name) { SV.current.chip = c; dispatch({ type: 'notify', patch: {} }); } else if (tries < 20) timer = setTimeout(attempt, 180000); }).catch(() => { if (!stopped && tries < 20) timer = setTimeout(attempt, 180000); }); };
479
+ attempt();
480
+ return () => { stopped = true; if (timer) clearTimeout(timer); };
481
+ }, []);
381
482
  const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
382
483
  // /restart auto: saved per profile (profile.quoxon.restartAuto). `newId` = the newest installed build seen; `cancelledId` = the build whose countdown a key cancelled.
383
484
  const AR = React.useRef(null); AR.current ??= { on: (api.settings?.() || {}).restartAuto === true, newId: null, cancelledId: null, timer: null, endsAt: 0 };
384
485
  // Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
385
486
  const countdownMs = () => { if (autoRestartMs != null) return autoRestartMs; const n = Number(env.QUOX_RESTART_COUNTDOWN); return (env.QUOX_RESTART_COUNTDOWN != null && env.QUOX_RESTART_COUNTDOWN !== '' && Number.isFinite(n) && n >= 0 ? n : 10) * 1000; };
386
487
  const cdLog = (phase, seconds = null) => transcript?.action({ type: 'restart.countdown', phase, seconds, build: AR.current.newId }); // lifecycle in the dev transcript
387
- const stopCountdown = () => { clearTimeout(AR.current.timer); AR.current.timer = null; AR.current.endsAt = 0; };
488
+ const stopCountdown = () => { clearTimeout(AR.current.timer); AR.current.timer = null; AR.current.endsAt = 0; AR.current.waiting = null; };
489
+ // A land refreshes the CLI and redeploys the collector together: an AUTO restart waits for the server's /health (every
490
+ // serverPollMs, up to serverWaitMs), then restarts anyway. Manual /restart and Enter never wait.
491
+ const serverUp = serverProbe || (api.health ? () => api.health(3000) : null);
492
+ const autoFire = () => {
493
+ const a = AR.current;
494
+ if (!serverUp) { cdLog('timed-out'); doRestart(); return; }
495
+ const token = {}; a.waiting = token; // set first: cancels and the idle check see a restart in flight
496
+ Promise.resolve().then(() => serverUp()).catch(() => null).then((first) => {
497
+ if (AR.current.waiting !== token || R.current.closed) return;
498
+ if (first?.ok === true) { stopCountdown(); cdLog('timed-out'); doRestart(); return; } // healthy: no waiting line, as before
499
+ beginWait(token);
500
+ });
501
+ };
502
+ const beginWait = (token) => {
503
+ const a = AR.current;
504
+ a.endsAt = Date.now() || 1;
505
+ a.timer = setTimeout(() => {}, serverWaitMs + 60000); // holds the countdown slot (keys, held-by-draft check); cleared by stopCountdown
506
+ cdLog('waiting-for-server');
507
+ bump(n => n + 1);
508
+ waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => AR.current.waiting !== token || R.current.closed }).then((up) => {
509
+ if (AR.current.waiting !== token || R.current.closed) return; // cancelled (Esc, Enter, a draft) or closed
510
+ stopCountdown();
511
+ if (AR.current.on && autoIdle()) { cdLog(up ? 'server-back' : 'wait-timed-out'); doRestart(); } else { cdLog('held'); bump(n => n + 1); }
512
+ });
513
+ };
388
514
  React.useEffect(() => (startSha ? buildWatch({ startSha, repeat: true, onNotice: (t, id) => {
389
515
  AR.current.newId = id || t;
390
516
  if (!AR.current.on) say(t === DIFFERENT_NOTICE ? t : 'a newer quox is installed: /restart (or /restart auto on)', 'dim');
@@ -397,14 +523,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
397
523
  React.useEffect(() => {
398
524
  const id = setInterval(() => {
399
525
  const a = AR.current;
400
- if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting) return;
526
+ if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting || (a.waiting && !a.timer)) return;
401
527
  if (a.timer) { if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); if (R.current.pendingWalk) say('Restart held: it waits until the walkthrough ends.', 'dim'); } else bump(n => n + 1); return; } // ticks the visible countdown
402
528
  if (!autoIdle()) return;
403
529
  const ms = countdownMs();
404
- if (ms <= 0) { cdLog('timed-out', 0); doRestart(); return; }
530
+ if (ms <= 0) { cdLog('timed-out', 0); autoFire(); return; }
405
531
  a.endsAt = Date.now() + ms;
406
532
  cdLog('started', Math.round(ms / 1000));
407
- a.timer = setTimeout(() => { stopCountdown(); if (AR.current.on && autoIdle()) { cdLog('timed-out'); doRestart(); } else { cdLog('held'); bump(n => n + 1); } }, ms);
533
+ a.timer = setTimeout(() => { if (AR.current.on && autoIdle()) { clearTimeout(a.timer); a.timer = null; a.endsAt = 0; autoFire(); } else { stopCountdown(); cdLog('held'); bump(n => n + 1); } }, ms);
408
534
  bump(n => n + 1);
409
535
  }, autoTickMs);
410
536
  return () => { clearInterval(id); stopCountdown(); };
@@ -412,18 +538,51 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
412
538
  // /restart carried a slot: re-check it out under this window's label before the old one is gone. /restart auto tends to
413
539
  // fire just as a new build lands, which is often while the collector redeploys, so a timeout or 5xx is retried; if it
414
540
  // never answers, the "Reattach to slot X? y/N" ask is offered instead of leaving the window silently slotless.
415
- React.useEffect(() => { if (restartSlot && !blocked && api.checkoutSlot) carrySlot(restartSlot); }, []);
541
+ React.useEffect(() => { if (restartSlot && !blocked && api.checkoutSlot) waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => R.current.closed }).then(() => { if (!R.current.closed) carrySlot(restartSlot); }); }, []);
416
542
  // /restart carried an /attach (agent or slot): re-attach once the window can act (owner 2026-10-15 "why dont u reattach").
417
543
  const attachCarried = React.useRef(false);
418
544
  React.useEffect(() => {
419
545
  if (!restartAttach || blocked || attachCarried.current) return;
420
546
  attachCarried.current = true;
421
547
  if (S.current.turn) { R.current.pendingAttach = restartAttach; say(`Re-attaching to ${restartAttach.replace(/^(agent|slot):/, '')} when this turn ends.`, 'dim'); return; } // a resumed turn would refuse it
422
- command(`/attach ${restartAttach}`);
548
+ waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => R.current.closed }).then(() => { // the server may still be redeploying: attach only once it answers
549
+ if (R.current.closed) return;
550
+ if (S.current.turn) { R.current.pendingAttach = restartAttach; return; }
551
+ R.current.noGreeting = true; command(`/attach ${restartAttach}`);
552
+ });
423
553
  }, [blocked]);
424
554
  React.useEffect(() => { // any other way the resumed turn ends (reattach path, Esc): carry the attach then
425
555
  if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
426
556
  });
557
+ /**
558
+ * Move THIS window to org `t` ({ id, name }) now: new conversation, status bar, agent re-attach as on /restart.
559
+ * `quick` (picker / `/org <name>`): the pick is the authorization, so one short line, and `makeDefault` also
560
+ * changes the profile-wide default (the same change as `quox org switch` in a shell).
561
+ */
562
+ async function switchOrgNow(t, { quick = false, makeDefault = false } = {}) {
563
+ const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
564
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
565
+ if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
566
+ if (t.id === curId && !makeDefault) return say(`Already in ${t.name}.`);
567
+ let done;
568
+ try { done = await api.switchOrg(t.id); } catch (e) { return say(`Not switched. ${e?.message || 'The server refused.'} You are still in ${R0.orgName ?? org ?? 'the same org'}.`, 'warn'); }
569
+ const att = SN.current.agent ? `agent:${SN.current.agent}` : null;
570
+ if (t.id !== curId) R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
571
+ R0.orgId = done.id; R0.orgName = done.name;
572
+ dispatch({ type: 'ctx', ctx: { conversationId: null } });
573
+ SN.current.name = null; SN.current.saved = null;
574
+ dispatch({ type: 'history.reset' });
575
+ if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
576
+ setEpoch(e => e + 1); bump(n => n + 1);
577
+ let scope = 'this window only; the profile default is unchanged';
578
+ let tone = 'ok';
579
+ if (makeDefault) {
580
+ try { await api.makeOrgDefault(done.id); scope = 'this window and the profile default'; }
581
+ catch (e) { scope = `this window only; could not change the profile default (${e?.message || 'refused'})`; tone = 'warn'; }
582
+ }
583
+ if (att) { R.current.noGreeting = true; command(`/attach ${att}`); }
584
+ return say(quick ? `now in ${done.name} (${scope})` : `Switched to ${done.name}. New conversation in that org.`, tone);
585
+ }
427
586
  async function carrySlot(nm) {
428
587
  for (let i = 0; ; i++) {
429
588
  if (R.current.closed || SC.current.slot) return; // closed, or this window checked a slot out meanwhile (/slot); a slot merely inherited from a parent Claude session does not count
@@ -447,6 +606,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
447
606
  return () => clearInterval(id);
448
607
  }, []);
449
608
  // ---- 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.
610
+ const DC = React.useRef(null); DC.current ??= createCompanionController({ settings: () => api.settings?.() || {}, isTTY: Boolean(stdout?.isTTY), typing: () => B.current.text !== '', jobs: createDragonJobs({ reads: api.dragonReads || {}, context: () => ({ envelopeId: receiptMentions([...S.current.history, ...S.current.blocks])[0]?.value || null }) }) }); // each dragon's one job: a real read (dragon/jobs.js)
611
+ const dispatchEv = (a) => { DC.current.tap(a); dispatch(a); };
612
+ // QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
613
+ const LC = React.useRef(null); LC.current ??= createLoopCards({ api, say: (t, tone) => say(t, tone), addItem: (it) => dispatch({ type: 'loopcard', card: it.card }), bump: () => { if (!R.current.closed) bump(n => n + 1); }, dragon: { summon: (d, cap) => DC.current.summon(d, cap) } });
450
614
  const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
451
615
  const realCwd = () => (cwd ? String(cwd).replace(/^~(?=\/|$)/, osHome()) : process.cwd());
452
616
  const myDisplay = () => { try { return api.displayName?.() || null; } catch { return null; } };
@@ -502,6 +666,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
502
666
  const runAskOpen = () => Boolean(SC.current.runAsk && runNow() < SC.current.runAsk.expiresAt); // one clock: the run-ask clock decides open and expired alike
503
667
  /** ASK-SETTLE: history may go to <Static> (scrollback, immutable) only up to the first armed run card. */
504
668
  function commitLimit(s0) {
669
+ // QLOOP-LIVE: a running loop's card is drawn pinned above the composer, so it never holds the history after it live (that re-laid-out every later row on every card tick); syncStatic defers the card itself until it settles
505
670
  const held = SC.current.runCards;
506
671
  if (held?.size) for (let i = committedRef.current; i < s0.history.length; i++) { const r = s0.history[i]?.line?.run; if (r?.askId && !r.settled && held.has(r.askId)) return i; }
507
672
  return s0.history.length;
@@ -627,6 +792,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
627
792
  const r = await api.slotSay(slot, text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
628
793
  if (!r.ok && r.status === 404 && api.slots) { let rows = []; try { rows = await api.slots(); } catch { /* the list is a hint only */ } return say(sayMissText(slot, rows), 'warn'); }
629
794
  if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
795
+ 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');
630
797
  const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
631
798
  dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
632
799
  announceDelivery(slot, r);
@@ -765,12 +932,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
765
932
  }
766
933
  }).catch(() => { /* no ask when the server cannot say */ });
767
934
  }, []);
768
- React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume)); }, []);
935
+ React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume), { fromRestart: Boolean(restartAttach) }); }, []);
769
936
  React.useEffect(() => { if (!blocked && !demoLabel) aiConnected().then((st) => { R.current.noAi = st === false ? true : st === true ? false : undefined; bump(n => n + 1); }); }, []);
770
937
  // Tips (tips.js): mode, rotation and "already used" persist on the profile (quoxon.tips / tipsSeen / tipsUsed).
771
- // QUOXON-MOUSE: optional mouse (default off, saved per profile). `tty` = tracking is on at the terminal right now.
938
+ // QUOXON-MOUSE: optional mouse (default off, saved per profile; selection-safe.test.jsx forbids tracking by default). `tty` = tracking is on at the terminal right now.
772
939
  const MO = React.useRef(null);
773
940
  MO.current ??= { on: (api.settings?.() || {}).mouse === 'on', tty: false, chain: Promise.resolve(), drag: null, last: null, onCursor: null, exitFn: null };
941
+ const TODO_MAP = React.useRef(null); // /todos short ids (td1, do2, dn3) -> real item ids, as last shown
774
942
  const TP = React.useRef(null);
775
943
  TP.current ??= (() => { const q = api.settings?.() || {}; const arr = (v) => (Array.isArray(v) ? v.filter(x => typeof x === 'string') : []); return { mode: normaliseMode(q.tips) || DEFAULT_MODE, seen: arr(q.tipsSeen), used: arr(q.tipsUsed) }; })();
776
944
  const showTip = async ({ startup: atLaunch = false } = {}) => {
@@ -807,6 +975,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
807
975
  const shown = showWhatsNew();
808
976
  if (tipStartup && !welcome && !shown) showTip({ startup: true });
809
977
  };
978
+ 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
+ if (blocked || !welcome || !api.providerCliStatus || !api.providerCliPlan) return;
980
+ api.providerCliStatus().then(async (st) => {
981
+ const missing = missingTools(st);
982
+ if (!missing.length) return;
983
+ const first = await api.providerCliPlan(missing[0]);
984
+ if (!first?.ok) return; // not an instance admin (or npm unreachable): no suggestion they cannot act on
985
+ say(SUGGEST_LINE);
986
+ R.current.installQueue = missing.slice(1);
987
+ say(planLine(first), 'dim');
988
+ R.current.installAsk = { tool: missing[0], plan: first, at: Date.now() };
989
+ ask(offerQuestion(missing[0], first), { action: `install ${INSTALL_TOOLS[missing[0]].name}`, reason: `${INSTALL_TOOLS[missing[0]].vendor}'s own package, downloaded from npm onto this server.` });
990
+ }, () => {});
991
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
810
992
  React.useEffect(() => {
811
993
  if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); startupTip(); }, () => startupTip());
812
994
  else startupTip();
@@ -846,6 +1028,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
846
1028
  if (nm && !isOrgId(nm)) { R.current.orgName = nm; dispatch({ type: 'notify', patch: {} }); }
847
1029
  } catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
848
1030
  };
1031
+ // 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 () => {
1033
+ if (!api.inboxItems) return;
1034
+ try {
1035
+ const items = await api.inboxItems({ limit: 30 });
1036
+ const a = arrivals(items, R.current.inboxSeen || null);
1037
+ 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 */ }
1043
+ };
849
1044
  const refreshNotify = async () => {
850
1045
  if (BL.current) return;
851
1046
  const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
@@ -854,12 +1049,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
854
1049
  if (N.authLost) say('Signed in again: approvals are back.', 'ok');
855
1050
  else if (N.slow) say('Quox server is back.', 'ok');
856
1051
  N.fails = 0; N.authLost = false; N.slow = false;
1052
+ 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
857
1053
  dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
1054
+ refreshInboxBar(); // HITL-INBOX-BAR: rides the same poll, never blocks it
858
1055
  retryOrgName();
859
1056
  dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
860
1057
  R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
861
1058
  // Provider paths that emit no approval.requested still surface here, as the same card.
862
- for (const item of items) dispatch({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
1059
+ for (const raw of items) {
1060
+ const item = await enrichPlanItem(raw, { objectiveGet: api.objectiveGet, agents: api.agents, orgs: api.orgs, cache: (R.current.planCache ||= new Map()) });
1061
+ dispatchEv({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
1062
+ }
863
1063
  } catch (e) {
864
1064
  N.fails++; // counted apart from an empty inbox
865
1065
  const msg = e?.message || 'failed';
@@ -926,6 +1126,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
926
1126
  R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
927
1127
  r = { ...r, rows: selectRows(r.rows, { all: R0.all }) };
928
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)
929
1130
  const at = r.rows.findIndex(x => x.key === prevKey);
930
1131
  R0.sel = at >= 0 ? at : Math.min(R0.sel, Math.max(0, r.rows.length - 1));
931
1132
  if (!r.rows.length) { R0.focus = false; R0.peek = false; R0.confirm = null; }
@@ -1008,6 +1209,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1008
1209
  d.push({ every: FRAME_MS });
1009
1210
  if (s0.divider.endedAt != null) d.push({ at: s0.divider.endedAt + (s0.divider.phase === 'done' ? GREEN_MS : RED_MS) + 1 });
1010
1211
  }
1212
+ if (DC.current.animating()) d.push({ every: motionOn && !mono() ? FRAME_MS : 500 }); // companion dragon: paced like the classic one, frames come from the cache (dragon/frames.js)
1011
1213
  if (dragonActive(now, !motionOn || mono())) d.push({ every: motionOn && !mono() ? 40 : 500 }); // /dragon and the cinematic moods (qmotion DragonLive)
1012
1214
  if (motionOn && fxBusy(now)) d.push({ every: FRAME_MS }); // reveals in flight
1013
1215
  const sl = R.current.sleep;
@@ -1017,6 +1219,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1017
1219
  return d;
1018
1220
  };
1019
1221
  React.useEffect(() => { if (!R.current.closed) clockRef.current.schedule(demands()); });
1222
+ // The companion dragon appears and leaves outside any App render (its own leaf and timers): re-arm the clock when it does, or it would get no frames until something else rendered.
1223
+ const rearm = React.useRef(null); rearm.current = () => { if (!R.current.closed) clockRef.current.schedule(demands()); };
1224
+ React.useEffect(() => DC.current.subscribe(() => rearm.current()), []);
1020
1225
 
1021
1226
  const fxEnd = (e) => Math.max(e.at + revealSpan(e.timing) * 1000, e.settleAt != null ? e.settleAt + revealSpan(TIMING.settle) * 1000 : 0);
1022
1227
  const fxActive = (item, now) => { const e = fxRef.current.get(item.id); return Boolean(e) && now < fxEnd(e); };
@@ -1153,6 +1358,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1153
1358
  queueMicrotask(() => reconcileAsks());
1154
1359
  return true;
1155
1360
  };
1361
+ // TUI-WALK (owner 2026-10-19: he always runs /approvals again to confirm it is empty): the walk ends with a FRESH server
1362
+ // read, never the local count. Empty is only ever claimed after a successful fetch.
1363
+ const walkRecheck = async (W) => {
1364
+ let items;
1365
+ try { items = await api.approvals(); }
1366
+ 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
+ const gone = walkAnsweredIds();
1368
+ 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; }
1370
+ const fresh = open.filter(x => !W.ids.includes(x.id)).length;
1371
+ if (fresh) say(`⏸ ${fresh} more arrived while you were answering · /approvals`, 'warn');
1372
+ else say(`⏸ ${open.length} still waiting (skipped) · /approvals`, 'warn');
1373
+ };
1156
1374
  const walkShow = () => {
1157
1375
  endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
1158
1376
  const W = R.current.pendingWalk; if (!W) return;
@@ -1166,7 +1384,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1166
1384
  if (walkGoneElsewhere(W, id)) { W.done.add(id); say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
1167
1385
  nextId = id; break;
1168
1386
  }
1169
- if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); queueMicrotask(() => reconcileAsks()); return; }
1387
+ if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); walkRecheck(W); queueMicrotask(() => reconcileAsks()); return; }
1170
1388
  const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
1171
1389
  const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
1172
1390
  W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
@@ -1237,7 +1455,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1237
1455
  }
1238
1456
  R.current.lastAnswered = null;
1239
1457
  R.current.walkSend = true;
1240
- try { await command(`/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
1458
+ try { await command(it.__approval ? (rep.key === 'y' ? `/approve ${n}` : `/deny ${n}${rep.note ? ` ${rep.note}` : ''}`) : `/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
1241
1459
  if (R.current.lastAnswered === it.id) {
1242
1460
  walkAnsweredIds().add(it.id);
1243
1461
  W.done.add(it.id); W.answered.push({ title: it.title, word: `${it.payload.options.find(o => o.key === rep.key).label}${rep.note ? ` (note: ${rep.note.length} characters)` : ''}` });
@@ -1257,7 +1475,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1257
1475
  }
1258
1476
  await respondWithStepUp(card, action, { proactive, reason, bulk });
1259
1477
  // The demo's stream carries on to its own tool result, so no "running, result later" note, no poll, no stale count.
1260
- dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
1478
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
1261
1479
  if (demoLabel) { B.current = initialBuffer(); dispatch({ type: 'notify', patch: { approvals: 0, error: null } }); } // (a stray early keystroke is not left in the composer)
1262
1480
  if (action === 'approve' && card.tool !== 'hat.switch' && !card.foreign && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1263
1481
  return { ok: true };
@@ -1432,10 +1650,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1432
1650
  say(handoffAnsweredNote(a.h), 'dim');
1433
1651
  bump(n => n + 1);
1434
1652
  }
1653
+ // QUOXON objective offer: the same path as `quox objective create <goal> --checkpoint` + start. Offered, never auto-run.
1654
+ async function runObjective(a) {
1655
+ say('Making the objective…', 'dim');
1656
+ try {
1657
+ if (typeof api.startObjective !== 'function') throw new Error('this build cannot create objectives from here');
1658
+ const o = await api.startObjective(a.o.goal, a.o.loop || null);
1659
+ say(objectiveStartedNote(o.id), 'dim');
1660
+ say(objectiveWatchLine(o.id), 'dim');
1661
+ if (a.o.loop && api.loopEvents) LC.current.watch(o.id, { title: a.o.goal }); // QLOOP-LIVE: the loop's live card appears in the chat stream
1662
+ // Report back into this conversation when a plan objective ends (the owner inbox item covers a closed window).
1663
+ if (!a.o.loop && typeof api.objectiveGet === 'function') {
1664
+ watchObjective({ get: () => api.objectiveGet(o.id), say: (t) => { say(t, 'dim'); bump(n => n + 1); }, stopped: () => R.current.closed === true }).catch(() => {});
1665
+ }
1666
+ } catch (e) { say(objectiveFailedNote(a.o.goal, e), 'dim'); }
1667
+ bump(n => n + 1);
1668
+ }
1435
1669
  async function consumeTurn(turnId, open, { keepOnDown = false } = {}) {
1436
1670
  R.current.caps.steer = true; R.current.steerNoted = false; // steer support is learned per turn (provider paths differ)
1437
1671
  R.current.durable = false; // learned from the server: v1.1 events carry a seq
1438
- dispatch({ type: 'turn.begin', turnId });
1672
+ dispatchEv({ type: 'turn.begin', turnId });
1439
1673
  const ctrl = new AbortController();
1440
1674
  R.current.abort = ctrl;
1441
1675
  let offered = false, sawEvent = false, down = false;
@@ -1446,8 +1680,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1446
1680
  sawEvent = true;
1447
1681
  if (typeof ev?.seq === 'number') R.current.durable = true;
1448
1682
  LG.current.observe(ev);
1449
- dispatch({ type: 'event', ev });
1683
+ dispatchEv({ type: 'event', ev });
1450
1684
  if (ev.type === 'tool.finished' && ev.suggest_command) { suggested = true; offerSuggestion(ev); }
1685
+ // QLOOP-LIVE: the chat's objective tool made a LOOP: open its live card in this conversation (the tool answer names the title, not the id)
1686
+ if (ev.type === 'tool.finished' && /objective/i.test(String(ev.tool || '')) && ev.status === 'ok' && /^\s*Loop "/.test(String(ev.result_summary || '')) && api.loopEvents && !(ev.call_id && SC.current.loopSeen?.has(ev.call_id))) {
1687
+ (SC.current.loopSeen ??= new Set()).add(ev.call_id);
1688
+ LC.current.discover({ title: (/^\s*Loop "(.*?)(?:"|$)/.exec(String(ev.result_summary)) || [])[1] || '' }).catch(() => {});
1689
+ }
1690
+ if (ev.type === 'turn.objective_suggested') { const ob = objectiveFromEvent(ev, SN.current.agent || (ev.objective_suggested?.mode === 'loop' && ev.objective_suggested?.from === 'quox' ? 'quox' : null)); if (ob) R.current.objectivePending = { o: ob, text: R.current.lastSent || '' }; } // QUOXON objective offer; unattached chat is Quox itself, so its keep-going offer still shows
1451
1691
  if (ev.type === 'turn.handoff_suggested') { const ho = handoffFromEvent(ev, SN.current.agent); if (ho) R.current.handoffPending = { h: ho, text: R.current.lastSent || '' }; } // QUOXON handoff offer
1452
1692
  if (ev.type === 'chunk' || ev.type === 'text.delta') { const g = textGate(gate, ev); gate = g.st; if (g.append && answerText.length < 20000) answerText += String(ev.content); }
1453
1693
  // Z2H-3B: a server default that is not connected must not linger in the status bar.
@@ -1468,7 +1708,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1468
1708
  if (S.current.turn?.id === turnId) {
1469
1709
  // stream closed without a terminal event
1470
1710
  // never claim completion the server did not confirm
1471
- dispatch({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
1711
+ dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
1472
1712
  }
1473
1713
  } catch (e) {
1474
1714
  if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
@@ -1479,15 +1719,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1479
1719
  say(`Blocked by policy (${e.body.block_id}). A person can override this one action: /override last <reason>`, 'warn');
1480
1720
  }
1481
1721
  // TUI-DIVIDER: a refusal before the stream opened names the layers it really evaluated; the rule lights them.
1482
- if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers) dispatch({ type: 'event', ev: { type: 'aocl.layer', ...l } });
1722
+ if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers) dispatchEv({ type: 'event', ev: { type: 'aocl.layer', ...l } });
1483
1723
  const why = sawEvent ? explainTermination(e) : null;
1484
- if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running') dispatch({ type: 'event', ev: { type: 'tool.finished', call_id: b.callId, tool: b.tool, status: 'interrupted', result_summary: 'connection lost before a result; verify before retrying' } });
1724
+ if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running') dispatchEv({ type: 'event', ev: { type: 'tool.finished', call_id: b.callId, tool: b.tool, status: 'interrupted', result_summary: 'connection lost before a result; verify before retrying' } });
1485
1725
  const dead = !why && (e?.status === 401 || isSessionDead(e));
1486
- dispatch({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
1726
+ dispatchEv({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
1487
1727
  if (dead) markSignedOut(e?.reason || null);
1488
1728
  if (why) say(RETRY_HINT, 'warn');
1489
1729
  // A lost reattach is not a failed turn: the server may still be finishing it (C5).
1490
- if (S.current.turn?.id === turnId) dispatch({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
1730
+ if (S.current.turn?.id === turnId) dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
1491
1731
  }
1492
1732
  } finally {
1493
1733
  clearTimeout(R.current.fallbackTimer);
@@ -1499,6 +1739,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1499
1739
  // QUOXON handoff offer: the attached agent lacks the tools for this ask. The handoff card answers the actual question,
1500
1740
  // so it wins over the agent's own suggested command (owner live 22:42: "Run /vm list?" took the slot and the handoff
1501
1741
  // card was dropped silently). Nothing is dropped silently: a superseded or skipped card says so in one line.
1742
+ const op = R.current.objectivePending; R.current.objectivePending = null;
1743
+ const opOk = Boolean(op && !down && !ctrl.signal.aborted && !planDone);
1744
+ if (opOk) { R.current.handoffPending = null; R.current.suggestLine = null; R.current.suggestAsk = null; } // one ask, not two: the objective card replaces the handoff and the agent's own command
1502
1745
  const hp = R.current.handoffPending; R.current.handoffPending = null;
1503
1746
  const hpOk = Boolean(hp && !down && !ctrl.signal.aborted && hp.text);
1504
1747
  if (hpOk && !planDone && R.current.suggestLine) {
@@ -1506,8 +1749,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1506
1749
  R.current.suggestLine = null; R.current.suggestAsk = null;
1507
1750
  say(handoffSupersededNote(hp.h, line), 'dim');
1508
1751
  }
1509
- const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone);
1752
+ const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone) || opOk;
1510
1753
  flushSuggestion();
1754
+ if (opOk) { R.current.objectiveAsk = { ...op, at: Date.now() }; say(objectiveAskText(op.o, cols), 'ask'); }
1511
1755
  if (hpOk && planDone) say(handoffSkippedNote(hp.h), 'dim');
1512
1756
  else if (hpOk) {
1513
1757
  const hs = (R.current.handoffStreak ??= createHandoffStreak()).offered(hp.h.from);
@@ -1616,6 +1860,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1616
1860
  R.current.fallbackTimer = setTimeout(local, INTERRUPT_FALLBACK_MS);
1617
1861
  }
1618
1862
 
1863
+ // Stop the turn now, locally and for good: tell the server (best effort), abort the stream, and always leave busy cleared.
1864
+ function stopTurnNow() {
1865
+ const s = S.current, turn = s.turn;
1866
+ try {
1867
+ if (R.current.waitingServer) { R.current.abort?.abort(); return; }
1868
+ if (!turn) return;
1869
+ clearTimeout(R.current.fallbackTimer);
1870
+ try { Promise.resolve(api.interrupt?.(s.ctx.conversationId, turn.id)).catch(() => {}); } catch { /* best effort */ }
1871
+ R.current.abort?.abort();
1872
+ } finally {
1873
+ if (turn && S.current.turn?.id === turn.id) dispatch({ type: 'stopped.local' });
1874
+ }
1875
+ }
1876
+
1619
1877
  // ---- L1 slash commands (models in slash/*.js) ------------------------------------------------
1620
1878
  const help = (model) => dispatch({ type: 'help', model });
1621
1879
  const leaveArmed = (kind) => { // a running turn would be disconnected: ask once, a second ask within 5 s goes ahead
@@ -1700,12 +1958,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1700
1958
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
1701
1959
  let turns;
1702
1960
  try { turns = await reconnecting(() => api.conversationTurns(row.id), st); } catch (e) { if (R.current.closed) return undefined; if (e?.reconnectGaveUp) return resumeGaveUp(row.id); if (e?.status === 404) return say('That conversation is not on the server any more (it keeps them 7 days).', 'warn'); return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
1961
+ // RESUME: the attachment and the conversation must name the same agent. After a /restart the replayed attach is what the
1962
+ // window held when it was saved; if the conversation was with someone else, warn once and start fresh rather than mix.
1963
+ const wasAgent = st.fromRestart && /^agent:/.test(restartAttach || '') ? restartAttach.slice(6) : null;
1964
+ if (st.fromRestart && wasAgent && row.agent && row.agent !== wasAgent) return say(`That conversation was with ${row.agent}, but this window was attached to ${wasAgent}. Starting a fresh conversation with ${wasAgent}; /resume brings the ${row.agent} one back.`, 'warn');
1703
1965
  const t = turnsToHistory(turns);
1966
+ const shownTitle = cleanTitle(row.title) && row.title !== '(untitled)' ? cleanTitle(row.title) : (titleFromTurns(turns) || '(untitled)'); // never rendered UI
1704
1967
  dispatch({ type: 'ctx', ctx: { conversationId: row.id } });
1705
1968
  SN.current.name = loadNames(namesPath)[row.id]?.name || null; SN.current.saved = null;
1706
1969
  if (!t.total && SN.current.name) SN.current.saved = `${row.id}|${SN.current.name}`; // nothing found: do not re-stamp the saved name
1707
- say(`Resumed "${clip(row.title, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
1970
+ say(`Resumed "${clip(shownTitle, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
1708
1971
  dispatch({ type: 'history.add', items: t.items, answers: t.answers });
1972
+ if (!st.fromRestart && row.agent && row.agent !== SN.current.agent) { R.current.noGreeting = true; await command(`/attach agent:${row.agent}`); } // next turns go to the agent this conversation was with
1709
1973
  if (!t.total && agentLogin && row.byName) {
1710
1974
  const h = humanProfiles.find(x => x !== profile);
1711
1975
  return say(`${row.byName} was saved by another profile. Start with quox -p ${h || '<human profile>'} to resume it.`, 'warn');
@@ -1713,9 +1977,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1713
1977
  say(t.total ? `Showing the last ${t.shown} of ${t.total} stored messages. Your next message continues this conversation.` : 'The server has no stored messages for it (it keeps them 7 days). Your next message continues it anyway.', 'dim');
1714
1978
  return reattachIfRunning(row.id);
1715
1979
  }
1716
- async function resumeCmd(q) {
1980
+ async function resumeCmd(q, { fromRestart = false } = {}) {
1717
1981
  if (!api.conversations || !api.conversationTurns) return say('Resume is not available in this mode.', 'warn');
1718
- let rows; const st = {};
1982
+ let rows; const st = { fromRestart };
1719
1983
  try {
1720
1984
  const [list, index] = await reconnecting(() => Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]), st);
1721
1985
  rows = resumeRows(list, index, loadNames(namesPath));
@@ -1770,6 +2034,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1770
2034
  bump(n => n + 1); return true;
1771
2035
  }
1772
2036
  function dragonFly() { // one pass of the dragon in flight in the live region; Esc stops it, a new call restarts it
2037
+ const room = flyRoom(termRows(stdout), chromeH.current ?? 10); if (!room.ok) return say(room.note, 'warn'); // too small: say so, never silently nothing
1773
2038
  R.current.fly = { startedAt: Date.now(), still: !motionOn || mono() };
1774
2039
  bump(n => n + 1); return true;
1775
2040
  }
@@ -1882,6 +2147,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1882
2147
  }
1883
2148
 
1884
2149
  function cliSlashRun(hit) {
2150
+ if (hit.error) return say(`${hit.error}`, 'warn');
1885
2151
  if (hit.cls === 'never') return say(`${hit.display} will not run from here (credentials, install or other never-from-chat commands). Run it in your own terminal if you mean it.`, 'warn');
1886
2152
  const bang = { kind: 'quox', args: hit.args, cmd: hit.display };
1887
2153
  if (hit.cls === 'write') { R.current.cliAsk = { bang, at: Date.now() }; say(hit.display, 'accent'); return ask('Run it?', { action: hit.display, reason: 'This quox command writes.' }); }
@@ -1980,12 +2246,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1980
2246
  }
1981
2247
 
1982
2248
  async function command(line) {
1983
- if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/note');
2249
+ if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/devnote');
1984
2250
  const [cmd0, arg] = line.trim().split(/\s+/);
1985
2251
  const cmd = cmd0 === '/agent' ? '/agents' : cmd0; // /agent is the same command
1986
2252
  if (!['/afk', '/sleep', '/back', '/away'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
1987
- if (cmd !== '/note' && cmd !== '/context') SCX.current.command(line.trim());
1988
- if (cmd !== '/note') transcript?.command(/^\/(authenticate|authorise|authorize)$/.test(cmd) && !/^[\d,\s-]*$/.test(line.trim().replace(/^\S+\s*/, '')) ? cmd : line); // QUOXON-STEPUP: never record a stray argument that might be a PIN
2253
+ 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
1989
2255
  const restArg = line.trim().replace(/^\S+\s*/, '');
1990
2256
  touchSeen();
1991
2257
  if (TIPS.some(x => tryCommand(x) === cmd) && !TP.current.used.includes(cmd)) { TP.current.used = [...TP.current.used, cmd]; api.saveSetting?.('tipsUsed', TP.current.used); } // using a thing retires its tip
@@ -1998,8 +2264,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1998
2264
  objArg = p.name;
1999
2265
  }
2000
2266
  if (OBJECT_CMDS.has(cmd) && !(cmd === '/rules' && arg)) { // inline object views (objects/): a card into history, or an honest note
2001
- const r = await runObjectCommand(cmd, objArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks], conversationId: S.current.ctx?.conversationId });
2002
- if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : say(clip(r.note, cols - 4), r.tone);
2267
+ const r = await runObjectCommand(cmd, objArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks], conversationId: S.current.ctx?.conversationId, cols });
2268
+ if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : r.lines ? r.lines.forEach(l => say(l, r.tone)) : say(r.note, r.tone); // wrap, never clip: notes carry ids and hints
2003
2269
  }
2004
2270
  switch (cmd) {
2005
2271
  case '/copy': {
@@ -2009,13 +2275,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2009
2275
  return say(`copied ${r.text.length.toLocaleString('en-US')} chars to the clipboard (needs a terminal that allows OSC 52)`, 'dim');
2010
2276
  }
2011
2277
  case '/share': return shareCommand(arg);
2012
- case '/note': {
2278
+ case '/devnote': {
2013
2279
  if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
2014
- if (!restArg) return say('Usage: /note <text> (or // text). Nothing to note.', 'warn');
2280
+ if (!restArg) return say('Usage: /devnote <text> (or // text). Nothing to note.', 'warn');
2015
2281
  if (!transcript?.note) return say('notes need dev transcripts on (touch ~/.config/quox/dev-transcripts)', 'warn');
2016
2282
  transcript.note(restArg.slice(0, 2000));
2017
2283
  return say('noted for the observer', 'dim');
2018
2284
  }
2285
+ case '/note': { // TUI-NOTE: a NotesQ note, same backend as `quox notes new`
2286
+ if (!restArg) return say('Usage: /note <text> (saves a NotesQ note; /notes lists them).', 'warn');
2287
+ if (!api.createNote) return say('NotesQ is not available in this window. Run quox notes new from a signed-in terminal, or enable NotesQ for your org in the dashboard.', 'warn');
2288
+ const r = await api.createNote(restArg);
2289
+ if (!r.ok) return say(r.message, 'warn');
2290
+ return say(`Saved note "${r.title}" (${r.id}). /notes lists your notes (or quox notes ls).`, 'ok');
2291
+ }
2292
+ case '/notes': return cliSlashRun(resolveCliSlash('/notes', 'ls'));
2293
+ case '/contact': return cliSlashRun(resolveCliSlash('/contact', restArg)); // registered command; runs as the quox contacts leaf
2019
2294
  case '/rename': {
2020
2295
  if (!restArg) return say(SN.current.name ? `Session name: ${SN.current.name}` : 'This session has no name. Use /rename <name>.');
2021
2296
  const n = cleanName(restArg);
@@ -2033,7 +2308,21 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2033
2308
  const m = /^(\S+)\s+([\s\S]*\S[\s\S]*)$/.exec(restArg || '');
2034
2309
  if (!m) return say('Usage: /say <slot>[,<slot>...] <text>', 'warn');
2035
2310
  const to = [...new Set(m[1].split(',').map(x => x.trim().toLowerCase()).filter(Boolean))];
2036
- if (to.length < 2) return sendToSlot(to[0] || m[1].toLowerCase(), m[2].trim());
2311
+ if (to.length < 2) { // SAY-LAST-TARGET: a first word that is not a slot is part of the message when a recent target exists
2312
+ const first = to[0] || m[1].toLowerCase();
2313
+ let rows = [];
2314
+ 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);
2316
+ const last = R.current.lastSay;
2317
+ if (!known && (looksLikeAddress(m[1]) || last)) {
2318
+ if (last && Date.now() - last.at < 2 * 3600000) {
2319
+ R.current.sayLastAsk = { slot: last.slot, text: restArg.trim(), at: Date.now() };
2320
+ return say(askText(`Send to ${last.slot}?`, { keys: 'y send · n cancel', action: `send the whole message to ${last.slot}` }), 'ask');
2321
+ }
2322
+ return say(sayWhoText(rows), 'warn');
2323
+ }
2324
+ return sendToSlot(first, m[2].trim());
2325
+ }
2037
2326
  // QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
2038
2327
  const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
2039
2328
  const head = `(to ${slots.join(', ')}) `;
@@ -2078,6 +2367,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2078
2367
  return takeSlot(nm, take);
2079
2368
  }
2080
2369
  case '/attach': {
2370
+ const greetThis = takeGreeting(R.current); // read and clear first: an early return below must not leave a quiet flag armed for the next typed /attach
2081
2371
  if (!restArg) return say(SN.current.agent ? `Attached to ${SN.current.agent}. /detach returns to CommanderQ.` : 'Talking to CommanderQ. Use /attach <agent> to pick one.');
2082
2372
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2083
2373
  if (!api.agents) return say('Agents are not available in this mode.', 'warn');
@@ -2094,7 +2384,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2094
2384
  const m = pt.kind === 'slot' ? { matches: [] } : matchAgent(pt.name, list);
2095
2385
  const slotLive = !!(slotRow && slotRow.holder_session);
2096
2386
  let choice = null; // 'slot' | 'agent'
2097
- if (slotRow && m.agent) choice = pt.kind === 'slot' || slotLive ? 'slot' : 'agent';
2387
+ if (slotRow && m.agent) choice = pt.kind === 'slot' ? 'slot' : 'agent'; // a bare name that is both defaults to the AGENT; slot: is the explicit override
2098
2388
  else if (slotRow) choice = 'slot';
2099
2389
  else if (m.agent) choice = 'agent';
2100
2390
  if (!choice) {
@@ -2107,7 +2397,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2107
2397
  const both = !!(slotRow && m.agent);
2108
2398
  if (choice === 'slot') {
2109
2399
  if (!api.slotSay) return say('Talking to a slot is not available in this mode.', 'warn');
2110
- SN.current.agent = null; SN.current.slotTarget = slotRow.name;
2400
+ SN.current.agent = null; SN.current.slotTarget = slotRow.name; DC.current.detach();
2111
2401
  where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
2112
2402
  const host = slotRow.holder_identity ? String(slotRow.holder_identity).replace(/^resident:/, '') : (slotRow.host_name || slotRow.hostname || null); // RESTORE-TUI: a named host from an older server row still shows
2113
2403
  const lbl = `slot ${slotRow.name}${host ? ` (${host})` : ''}`;
@@ -2119,11 +2409,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2119
2409
  }
2120
2410
  SN.current.slotTarget = null;
2121
2411
  SN.current.agent = m.agent.id; where.current = { ...where.current, agent: m.agent.id }; bump(x => x + 1);
2122
- const alt = both ? ` To pick the slot instead: /attach slot:${pt.name}` : '';
2123
- say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.${alt}`, 'ok');
2412
+ DC.current.attach(m.agent.id, { greet: greetThis }).then(() => { const n = DC.current.takeArtNotice(); if (n) say(n, 'warn'); });
2413
+ say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.`, 'ok');
2414
+ if (both && pt.kind !== 'agent') say(`${pt.name} is also a slot: /attach slot:${pt.name} for that`, 'warn');
2124
2415
  if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
2125
2416
  return undefined;
2126
2417
  }
2418
+ case '/evidence': case '/ward': case '/volt': {
2419
+ for (const l of await runEvidenceCommand({ cmd, api, conversationId: S.current.ctx?.conversationId, cols })) say(l.text, l.tone);
2420
+ return undefined;
2421
+ }
2422
+ case '/envelope': case '/envelopes': case '/aee': { // pointers to /proof (the envelope ids) and /evidence (the bundle)
2423
+ say('Envelope ids are what /proof lists; /evidence exports them all as a signed bundle.', 'dim');
2424
+ return command(`/proof${restArg ? ` ${restArg}` : ''}`);
2425
+ }
2127
2426
  case '/peek': { // PEEK: the same lookup as `quox peek <id>`, in this window's org
2128
2427
  const pid = restArg.trim().split(/\s+/)[0] || '';
2129
2428
  if (!api.peek) return say('Peek is not available in this mode. In a terminal: quox peek <id>', 'warn');
@@ -2180,6 +2479,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2180
2479
  if (!api.connections) return say('Connections are not available in this mode.', 'warn');
2181
2480
  const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
2182
2481
  for (const [t, tone] of connectionLines(vault, caps)) say(clip(t, cols - 4), tone);
2482
+ // LAZY-AI-CLI: no Claude Code on this server = the Claude sign-in cannot work yet; offer it as a y/N card here
2483
+ if (api.providerCliStatus && missingTools(await api.providerCliStatus()).includes('claude-code')) { R.current.installQueue = []; return offerInstall('claude-code'); }
2183
2484
  return undefined;
2184
2485
  }
2185
2486
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
@@ -2229,6 +2530,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2229
2530
  return undefined;
2230
2531
  }
2231
2532
  let r = selectModel(res, restArg, caps);
2533
+ // LAZY-AI-CLI: the Claude sign-in runs through the claude binary; no binary on this server = offer to install it now
2534
+ if ((r.choice?.via === 'subscription' || /^(claude-cli|claude-subscription|subscription|claude-code)\b/i.test(String(restArg).trim())) && api.providerCliStatus) {
2535
+ const st = await api.providerCliStatus();
2536
+ if (missingTools(st).includes('claude-code')) { R.current.installQueue = []; return offerInstall('claude-code'); }
2537
+ }
2232
2538
  let installed = null;
2233
2539
  if (r.ok && r.choice?.provider === 'ollama' && api.ollamaModels) { // B3: check against what that Ollama has installed
2234
2540
  r = selectOllamaModel(await api.ollamaModels(), r.choice.model || null);
@@ -2256,6 +2562,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2256
2562
  return undefined;
2257
2563
  }
2258
2564
  case '/row': return RW.current.handle(restArg);
2565
+ case '/qpe': { // TUI-QPE: how many proof engine rows show (a CLI setting)
2566
+ const r = qpeCommand(restArg, api.settings?.() || {});
2567
+ if (r.save != null) { api.saveSetting?.('qpeRows', r.save); bump(n => n + 1); }
2568
+ return say(r.text, r.tone);
2569
+ }
2570
+ case '/deck': { // provided by the quoxdeck extension when loaded; otherwise says where it comes from (the registry entry carries ext)
2571
+ 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');
2573
+ }
2574
+ case '/ext': { // EXTENSIONS: /ext list|on|off <name>
2575
+ const [w, nm] = restArg.trim().split(/\s+/);
2576
+ const list = readExtList();
2577
+ if (!w || w === 'list') {
2578
+ 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'));
2580
+ }
2581
+ if ((w === 'on' || w === 'off') && nm) {
2582
+ if (w === 'on') { if (!list.some(x => x.name === nm)) extAdd(nm); else extSetEnabled(nm, true); await extLoad(nm, false); return undefined; }
2583
+ extSetEnabled(nm, false); const r = DK.current.unload(nm); return say(r.text, 'ok');
2584
+ }
2585
+ return say('Usage: /ext list|on|off <name>', 'warn');
2586
+ }
2259
2587
  case '/watch': return KC.current.watch(restArg);
2260
2588
  case '/automate': return KC.current.automate(restArg);
2261
2589
  case '/crystallize': return KC.current.crystallize(restArg);
@@ -2277,9 +2605,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2277
2605
  case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': case '/fleet': case '/prove': case '/replay': return undefined; // answered above (runObjectCommand)
2278
2606
  case '/agent': case '/agents': return undefined; // answered above (picker, tools, card)
2279
2607
  case '/which': return showWhich(arg);
2280
- case '/detach':
2608
+ case '/detach': {
2609
+ const hadAgent = Boolean(SN.current.agent);
2281
2610
  SN.current.agent = null; SN.current.slotTarget = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
2282
- return say('Back to CommanderQ.');
2611
+ DC.current.detach();
2612
+ say('Back to CommanderQ.');
2613
+ const g = detachGreeting({ settings: api.settings?.() || {}, env, rows: termRows(stdout), chromeH: chromeH.current ?? 10, hadAgent }); // dragons on: the Q dragon greets the return
2614
+ if (g?.clip) { playDragon(g.clip, undefined); bump(n => n + 1); } else if (g?.note) say(g.note, 'dim');
2615
+ return true;
2616
+ }
2283
2617
  case '/sessions': {
2284
2618
  // SESSIONS (owner 2026-10-15): name and where first; ids only with `refs`.
2285
2619
  const flags = new Set(String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean));
@@ -2444,8 +2778,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2444
2778
  if (!m) return say(DRAGON_USAGE);
2445
2779
  if (m.mode === 'explain') return dragonExplain();
2446
2780
  if (m.mode === 'ask') return dragonAsk(m.question);
2781
+ if (m.mode === 'companion') {
2782
+ const r = companionCommand(m.arg, { settings: api.settings?.() || {}, plan: DC.current.plan(), artProblem: DC.current.artProblem() });
2783
+ if (r.save != null) { api.saveSetting?.('dragonCompanion', r.save); DC.current.refresh(); }
2784
+ say(r.text, r.tone);
2785
+ if (r.invoke && await DC.current.invoke()) bump(n => n + 1); // asking about the companion is also calling it
2786
+ return true;
2787
+ }
2447
2788
  if (m.mode === 'sleep') return dragonSleep();
2448
2789
  if (m.mode === 'fly') return dragonFly();
2790
+ if (m.mode === 'pick') { // plain /dragon: the ATTACHED agent's own dragon when it has one, else the classic Quox dragon
2791
+ const shown = await DC.current.invoke();
2792
+ if (shown) { bump(n => n + 1); return true; }
2793
+ const who = SN.current.agent;
2794
+ if (who && !dragonForAgent(who)) say(`${who[0].toUpperCase()}${who.slice(1)} has no dragon of its own; here's Quox.`, 'dim');
2795
+ }
2796
+ const room = clipRoom(m.mode === 'idle' ? 'classic' : m.mode, termRows(stdout), chromeH.current ?? 10); if (!room.ok) return say(room.note, 'warn');
2449
2797
  playDragon(m.mode, undefined); bump(n => n + 1); return true;
2450
2798
  }
2451
2799
  case '/dragon-fly': return arg ? say('Usage: /dragon-fly', 'warn') : dragonFly();
@@ -2484,7 +2832,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2484
2832
  case '/about': return dispatch({ type: 'welcome', version: packageVersion() });
2485
2833
  case '/help': return dispatch({ type: 'help', model: helpModel(arg, R.current.caps) });
2486
2834
  case '/clear': case '/new': case '/reset': {
2487
- if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2835
+ if (S.current.turn || R.current.waitingServer) stopTurnNow(); // cancel it exactly as Esc does, so busy and the divider reset
2488
2836
  const prev = S.current.ctx.conversationId;
2489
2837
  dispatch({ type: 'ctx', ctx: { conversationId: null } });
2490
2838
  SN.current.name = null; SN.current.saved = null;
@@ -2493,6 +2841,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2493
2841
  setEpoch(e => e + 1);
2494
2842
  return say(prev ? 'Previous conversation saved. /resume to go back.' : 'New conversation.');
2495
2843
  }
2844
+ case '/stop': case '/cancel': { // same as Esc
2845
+ if (!S.current.turn && !R.current.waitingServer) return say('Nothing is running.', 'dim');
2846
+ stopTurnNow();
2847
+ return say('Stopped.', 'ok');
2848
+ }
2496
2849
  case '/aocl': return dispatch({ type: 'aocl.table' });
2497
2850
  case '/verbose':
2498
2851
  if (!['off', 'on', 'full'].includes(arg)) return say(`verbose is ${S.current.verbose}. Use /verbose off|on|full`);
@@ -2556,11 +2909,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2556
2909
  const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
2557
2910
  let list;
2558
2911
  try { list = await api.orgs(); } catch (e) { return say(`Couldn't list your orgs: ${listFailure(e, profile)}`, 'warn'); }
2912
+ const openOrgPicker = (rowsIn, title, { confirm = false } = {}) => { // confirm: the explicit `/org switch` chooser keeps its y/N
2913
+ const orows = orgRows(rowsIn);
2914
+ const ow = Math.min(30, Math.max(4, ...orows.map(x => [...x.name].length)));
2915
+ PK.current.open({ title, rows: orows.map(x => ({ key: `${x.id === curId ? '* ' : ' '}${x.name.padEnd(ow)}`, text: `${x.slug || x.id}${x.role ? ` (${x.role})` : ''}` })), sel: Math.max(0, orows.findIndex(x => x.id === curId)),
2916
+ hint: confirm ? '↑↓ select · Enter switch (you confirm first) · Esc cancel' : 'Enter: switch this window only · d: also make it the default · ↑↓ select · Esc cancel',
2917
+ onChar: confirm ? undefined : (ch, i) => { if (ch !== 'd' && ch !== 'D') return false; switchOrgNow(orows[i], { quick: true, makeDefault: true }); PK.current.close(); return true; },
2918
+ onPick: (_, i) => (confirm ? (orows[i].id === curId ? say(`Already in ${orows[i].name}.`) : command(`/org switch ${orows[i].slug || orows[i].id}`)) : switchOrgNow(orows[i], { quick: true })) });
2919
+ };
2559
2920
  if (!sub || sub === 'list' || (sub === 'switch' && !query)) { // a bare /org switch offers the chooser, never a Usage line
2560
2921
  if (!list.length) return say('No orgs returned for this login.', 'warn');
2561
- const orows = orgRows(list);
2562
- const ow = Math.min(30, Math.max(4, ...orows.map(x => [...x.name].length)));
2563
- PK.current.open({ title: 'Move this session to an org', rows: orows.map(x => ({ key: `${x.id === curId ? '* ' : ' '}${x.name.padEnd(ow)}`, text: `${x.slug || x.id}${x.role ? ` (${x.role})` : ''}` })), sel: Math.max(0, orows.findIndex(x => x.id === curId)), hint: '↑↓ select · Enter switch (you confirm first) · Esc cancel', onPick: (_, i) => (orows[i].id === curId ? say(`Already in ${orows[i].name}.`) : command(`/org switch ${orows[i].slug || orows[i].id}`)) });
2922
+ openOrgPicker(list, 'Move this session to an org', { confirm: sub === 'switch' });
2923
+ return undefined;
2924
+ }
2925
+ if (!['yes', 'back', '-', 'switch'].includes(sub)) { // /org <name or slug fragment>: a unique match switches this window, else the picker opens filtered
2926
+ const q = restArg.trim();
2927
+ const m = matchOrg(q, list);
2928
+ 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)));
2931
+ if (!part.length) return say(`No org of yours matches "${q}". /org lists them.`, 'warn');
2932
+ openOrgPicker(part, `Orgs matching "${q}"`);
2564
2933
  return undefined;
2565
2934
  }
2566
2935
  if (sub === 'yes') {
@@ -2568,16 +2937,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2568
2937
  if (!t) return say('Nothing to confirm. /org switch <name> first.', 'warn');
2569
2938
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2570
2939
  if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
2571
- let done;
2572
- try { done = await api.switchOrg(t.id); } catch (e) { return say(`Not switched. ${e?.message || 'The server refused.'} You are still in ${R0.orgName ?? org ?? 'the same org'}.`, 'warn'); }
2573
- R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
2574
- R0.orgId = done.id; R0.orgName = done.name;
2575
- dispatch({ type: 'ctx', ctx: { conversationId: null } });
2576
- SN.current.name = null; SN.current.saved = null;
2577
- dispatch({ type: 'history.reset' });
2578
- if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
2579
- setEpoch(e => e + 1); bump(n => n + 1);
2580
- return say(`Switched to ${done.name}. New conversation in that org.`, 'ok');
2940
+ return switchOrgNow(t);
2581
2941
  }
2582
2942
  if (sub === 'back' || sub === '-') { // owner 2026-10-15 "switch back": no "type /org switch <the org you were in>"
2583
2943
  const p = R0.prevOrg;
@@ -2599,6 +2959,63 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2599
2959
  if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
2600
2960
  return ask(`Switch to ${m.org.name}?`, { action: `/org switch ${m.org.id} (${m.org.name})`, reason: 'This starts a new conversation in that org.' });
2601
2961
  }
2962
+ case '/todo': case '/todos': {
2963
+ if (!api.todoAdd || !api.todoList) return say('This server has no Action Tracker.', 'warn');
2964
+ const t = parseTodo(cmd, restArg);
2965
+ try {
2966
+ if (t.kind === 'usage') return say(t.text, 'warn');
2967
+ if (t.kind === 'add') return say(todoAddedLine(await api.todoAdd(t.text)), 'ok');
2968
+ if (t.kind === 'move') {
2969
+ if (!api.todoMove) return say('This server cannot move Action Tracker items.', 'warn');
2970
+ const board = await api.todoList();
2971
+ const many = (t.refs || [t.ref]).length > 1;
2972
+ for (const ref of (t.refs || [t.ref])) {
2973
+ const r = resolveRef(ref, TODO_MAP.current, board);
2974
+ if (r.error) { say(r.error, 'warn'); continue; }
2975
+ if (r.note) say(r.note);
2976
+ const moved = await api.todoMove(r.id, t.to);
2977
+ say(`${todoMovedLine({ ...moved, title: moved.title || r.title })}${many ? '' : ' /todos shows the new numbers.'}`, 'ok');
2978
+ }
2979
+ return undefined;
2980
+ }
2981
+ const board = await api.todoList();
2982
+ const numbered = numberBoard(board);
2983
+ TODO_MAP.current = numbered.map;
2984
+ return say(todoBoardText(board, numbered));
2985
+ } catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
2986
+ }
2987
+ case '/done': {
2988
+ if (!api.todoList || !api.todoMove) return say('This server cannot move Action Tracker items.', 'warn');
2989
+ const d = parseDone(restArg);
2990
+ if (d.kind === 'usage') return say(d.text, 'warn');
2991
+ try {
2992
+ const board = await api.todoList();
2993
+ for (const ref of d.refs) {
2994
+ const r = resolveRef(ref, TODO_MAP.current, board);
2995
+ if (r.error) { say(r.error, 'warn'); continue; }
2996
+ if (r.note) say(r.note);
2997
+ const moved = await api.todoMove(r.id, 'done');
2998
+ say(todoDoneLine(moved.title || r.title), 'ok');
2999
+ }
3000
+ return undefined;
3001
+ } catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
3002
+ }
3003
+ case '/plugin': case '/extensions': case '/extension': case '/addons': case '/apps': case '/plugins': {
3004
+ if (!api.pluginList) return say('This server has no plugin list.', 'warn');
3005
+ const want = /^(list|ls|all)?$/i.test(restArg.trim()) ? '' : restArg.trim().split(/\s+/)[0]; // "/extension list" is just the list
3006
+ try {
3007
+ if (want) {
3008
+ const one = (api.pluginGet ? await api.pluginGet(want) : null) || (await api.pluginList()).find(r => r.plugin_id === want || r.id === want);
3009
+ if (!one) return say(`No installed plugin named "${want}". /plugins lists them.`, 'warn');
3010
+ const on = one.is_enabled !== false && one.is_enabled !== 0;
3011
+ return say([`${one.plugin_id || want} ${on ? 'enabled' : 'disabled'} v${one.version || '?'}`, ...(one.installed_by ? [`installed by ${one.installed_by}${one.installed_at ? ` on ${String(one.installed_at).slice(0, 10)}` : ''}`] : []), ...(one.upgraded_at ? [`upgraded ${String(one.upgraded_at).slice(0, 10)}`] : [])].join('\n'));
3012
+ }
3013
+ const list = await api.pluginList();
3014
+ if (!list.length) return say('No plugins installed for this org.');
3015
+ const w = Math.max(...list.map(r => String(r.plugin_id || '').length));
3016
+ return say([`${list.length} plugin${list.length === 1 ? '' : 's'} (extensions). /plugins <name> for detail:`, ...list.map(r => ` ${String(r.plugin_id || '?').padEnd(w)} ${r.is_enabled === false || r.is_enabled === 0 ? 'disabled' : 'enabled '} v${r.version || '?'}`)].join('\n'));
3017
+ } catch (e) { return say(`Couldn't list plugins: ${e?.message || 'failed'}`, 'warn'); }
3018
+ }
2602
3019
  case '/skills': {
2603
3020
  if (!api.skills) return say('This server has no skills list.', 'warn');
2604
3021
  try {
@@ -2631,6 +3048,36 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2631
3048
  }
2632
3049
  return submit(skillPrompt(arg, argRest));
2633
3050
  }
3051
+ case '/loop': { // QLOOP-LIVE: list | watch <id> | peek <id> | focus
3052
+ const [sub0, ...restW] = String(restArg || '').trim().split(/\s+/).filter(Boolean);
3053
+ const lid = restW[0];
3054
+ if (!sub0 || sub0 === 'list') {
3055
+ const rows = (R.current.roster.allRows || []).filter(r => r.kind === 'loop');
3056
+ if (!rows.length) return say('No loops running or recent. Ask Quox to run something until it is done, and say yes to the loop offer.');
3057
+ for (const r of rows) say(`∞ ${r.id} │ ${r.name} │ ${r.state}${r.activity ? `, ${r.activity}` : ''}${LC.current.get(r.id) ? ' (card on screen)' : ''}`, r.state === 'failed' ? 'error' : undefined);
3058
+ return say('/loop watch <id> puts a live card in this chat. /loop peek <id> prints one snapshot.');
3059
+ }
3060
+ if (sub0 === 'focus') { const c = LC.current.focus(lid || null); return c ? say(`Keys on the loop card: Enter expands the output, s halts (y confirms), Esc leaves.`) : say('No loop card on screen. /loop watch <id> starts one.', 'warn'); }
3061
+ if (sub0 === 'watch') {
3062
+ if (!lid) return say('Usage: /loop watch <id>', 'warn');
3063
+ if (!api.loopEvents) return say('/loop watch is not available in this mode.', 'warn');
3064
+ const known = (R.current.roster.allRows || []).find(r => r.id === lid);
3065
+ const had = LC.current.get(lid);
3066
+ LC.current.watch(lid, { title: known?.name || '' });
3067
+ LC.current.focus(lid);
3068
+ return had ? say('That loop already has a card above; it has focus. Enter expands, s halts, Esc leaves.') : undefined; // the card's own last line lists the keys
3069
+ }
3070
+ if (sub0 === 'peek') {
3071
+ if (!lid) return say('Usage: /loop peek <id>', 'warn');
3072
+ try {
3073
+ const snap = await api.loopSnapshot(lid);
3074
+ const st = applyLoopSnapshot(initLoop({ id: lid, title: snap?.title || '' }), snap);
3075
+ for (const rs of cardLines(st, { cols: Math.max(40, cols - 4) })) say(rs.map(x => x.t).join(''));
3076
+ } catch (e) { say(`loop ${lid}: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}`, 'warn'); }
3077
+ return undefined;
3078
+ }
3079
+ return say('Usage: /loop list | watch <id> | peek <id> | focus', 'warn');
3080
+ }
2634
3081
  case '/roster': {
2635
3082
  const ro = R.current.roster;
2636
3083
  if (arg === 'all') {
@@ -2720,6 +3167,39 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2720
3167
  catch (e) { dispatch({ type: 'error', text: `observers: ${e.message}` }); }
2721
3168
  return undefined;
2722
3169
  }
3170
+ case '/inbox': { // HITL-INBOX-BAR: the owner's Quox inbox, in the chat
3171
+ if (!api.inboxItems) return say('The inbox is not available in this mode.', 'warn');
3172
+ const q = parseInbox(restArg);
3173
+ if (q.kind === 'usage') return say(INBOX_USAGE, 'warn');
3174
+ let items;
3175
+ try { items = await api.inboxItems({ limit: 30 }); }
3176
+ 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
+ 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) } });
3180
+ 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();
3189
+ return undefined;
3190
+ }
3191
+ const it = pick(q.n); if (!it) return undefined;
3192
+ if (q.kind === 'open' && isApprovalItem(it)) {
3193
+ let flat = []; try { flat = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [] }).flatMap((g) => g.items.map((x) => x.item.id)); } catch { /* falls to /approvals below */ }
3194
+ const at = flat.indexOf(it.id);
3195
+ return command(at >= 0 ? `/approvals ${at + 1}` : '/approvals');
3196
+ }
3197
+ 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();
3201
+ return undefined;
3202
+ }
2723
3203
  case '/approvals': {
2724
3204
  if (/^help$/i.test(restArg.trim())) { // the syntax lives here, not above every list
2725
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')})`);
@@ -2739,7 +3219,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2739
3219
  const hidden = stats ? Math.max(0, stats.awaiting - flat.length) : 0;
2740
3220
  if (!flat.length && !hidden && !statsErr) return say('Nothing waiting for you.', 'ok');
2741
3221
  const fArg = restArg.trim();
2742
- if (/^walkthrough$/i.test(fArg)) return walkStart(flat, { fresh: true });
3222
+ if (/^walkthrough$/i.test(fArg)) { // plain approvals walk as cards too, after any session questions
3223
+ const plain = flat.filter(x => !isDecision(x));
3224
+ if (plain.length && plain.length === flat.length) say(`No session questions; ${plain.length} approval${plain.length === 1 ? '' : 's'} waiting, starting with the first.`, 'warn');
3225
+ return walkStart(flat.map(approvalAsWalkItem), { fresh: true });
3226
+ }
2743
3227
  if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
2744
3228
  const it = flat[Number(fArg) - 1];
2745
3229
  if (!it) return say(`No such item: [${fArg}] (${flat.length} listed).`, 'warn');
@@ -2766,6 +3250,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2766
3250
  }
2767
3251
  if (flat.length && !shown.size) say(`Nothing matches "${fArg}".`, 'warn');
2768
3252
  if (flat.length) say(' ');
3253
+ if (shown.size && !(nDec && !fArg)) R.current.listShownAt = Date.now(); // a list, not a card: a bare y/n right after it has no target
2769
3254
  if (nDec && !fArg) walkStart(flat, { fresh: true, quiet: true });
2770
3255
  if (hidden) {
2771
3256
  const others = humanProfiles.filter(h => h !== profile);
@@ -2786,7 +3271,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2786
3271
  }
2787
3272
  case '/mouse': {
2788
3273
  const v = arg.trim().toLowerCase();
2789
- if (!v) return say(`Mouse is ${MO.current.on ? 'on' : 'off'}. /mouse ${MO.current.on ? 'off' : 'on'} to change it.`);
3274
+ if (!v) return say(`Mouse is ${MO.current.on ? 'on' : 'off'}. /mouse ${MO.current.on ? 'off' : 'on'} to change it.${MO.current.on ? ` ${MOUSE_HINT}` : ''}`);
2790
3275
  if (v !== 'on' && v !== 'off') return say('Usage: /mouse on|off', 'warn');
2791
3276
  MO.current.on = v === 'on'; MO.current.drag = null;
2792
3277
  api.saveSetting?.('mouse', v);
@@ -2820,6 +3305,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2820
3305
  case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
2821
3306
  case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
2822
3307
  case '/huh': return command(`/dragon explain${restArg ? ` ${restArg}` : ''}`); // owner 2026-10-15: shortcut for /dragon explain
3308
+ case '/devservers': { // DEVFLEET: list the dev servers. Handing the terminal to a remote session from inside the TUI is not done here: print the exact command.
3309
+ if (!api.devServers) return say('/devservers is not available in this mode.', 'warn');
3310
+ let rows; try { rows = await api.devServers(); } catch (e) { return say(`Couldn't list servers: ${e?.message || 'failed'}`, 'warn'); }
3311
+ if (!rows.length) return say('No dev servers registered. In a terminal: quox server mark <host> --purpose "main dev"');
3312
+ const pick = restArg.trim();
3313
+ if (pick) {
3314
+ const r = rows.find(x => x.name === pick);
3315
+ if (!r) return say(`No dev server "${pick}". /devservers lists them.`, 'warn');
3316
+ return say(`To open a Quox window on ${r.name}, run this in a terminal (the TUI does not hand over the terminal yet):\nquox server go ${r.name}`);
3317
+ }
3318
+ const lines = rows.map(r => `${r.current ? '*' : ' '} ${r.name} ${r.purpose || '-'} ${r.project || '-'} ${r.reachable ? 'reachable' : 'unreachable'}`);
3319
+ return say(`${lines.join('\n')}\n* this server. /devservers <name> shows the command to open a Quox window there (quox server go <name>).`);
3320
+ }
2823
3321
  case '/slots': return listSlots(/^all$/i.test(restArg.trim()));
2824
3322
  case '/mirror': {
2825
3323
  // The Mirror itself: this org's server-built Mirror, what CommanderQ is told every turn (not /mind).
@@ -2903,7 +3401,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2903
3401
  try { await go(); }
2904
3402
  catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
2905
3403
  okIds.push(x.id); walkAnsweredIds().add(x.id);
2906
- say(`✔ answered [${k}] ${x.payload.session || 'session'}: ${clip(x.title || x.id, Math.max(20, cols - 40))} -> (${keyOf(k)})`, 'ok');
3404
+ say(`✔ answered [${k}] ${x.payload.session || 'session'}: ${clip(decodeEntities(x.title || x.id), Math.max(20, cols - 40))} -> (${keyOf(k)})`, 'ok');
2907
3405
  if (x.payload.slot && api.slotSay) await api.slotSay(String(x.payload.slot).toLowerCase(), `Your question "${clip(x.title, 80)}" was answered: ${keyOf(k)}. Read it with: quox ask status ${x.id}`, { from: myLabel() });
2908
3406
  } catch (e) { failedK.push(`[${k}] ${e?.status === 404 || e?.status === 400 ? 'already handled or expired' : e.message}`); if (e?.stepUpDeclined) break; }
2909
3407
  }
@@ -2943,7 +3441,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2943
3441
  return dispatch({ type: 'error', text: `answer: ${e.message}` });
2944
3442
  }
2945
3443
  say(' ');
2946
- say(`✔ answered [${pa.n}] ${it.payload.session || 'session'}: ${clip(it.title || it.id, Math.max(20, cols - 40))} -> (${pa.key}) ${it.payload.options.find(o => o.key === pa.key).label}${pa.note ? ` (note: ${clip(pa.note, 40)})` : ''}`, 'ok');
3444
+ say(`✔ answered [${pa.n}] ${it.payload.session || 'session'}: ${clip(decodeEntities(it.title || it.id), Math.max(20, cols - 40))} -> (${pa.key}) ${decodeEntities(it.payload.options.find(o => o.key === pa.key).label)}${pa.note ? ` (note: ${clip(pa.note, 40)})` : ''}`, 'ok');
2947
3445
  say(' ');
2948
3446
  R.current.lastAnswered = it.id; walkAnsweredIds().add(it.id);
2949
3447
  if (it.payload.slot && api.slotSay) { // WALK-FIXES: the answer is already recorded; the slot post never holds up the next card
@@ -3011,7 +3509,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3011
3509
  try {
3012
3510
  const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
3013
3511
  const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
3014
- if (r?.gone) gone.push(k); else if (r?.ok) done.push(k);
3512
+ if (r?.gone) gone.push(k); else if (r?.ok) { done.push(k); R.current.lastAnswered = it.id; }
3015
3513
  } catch (e) { failed.push(`[${k}] ${e.message}`); }
3016
3514
  }
3017
3515
  if (done.length || gone.length || failed.length || missing.length) say(' ');
@@ -3053,7 +3551,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3053
3551
  return undefined;
3054
3552
  }
3055
3553
  default: {
3554
+ if (await extDispatch(cmd, restArg)) return undefined;
3056
3555
  const e = findEntry(cmd);
3556
+ if (e?.ext) return say(`${e.usage.split(/\s/)[0]} comes from the ${e.ext} extension, which is not loaded. npm i -g ${e.ext}, then quox ext add ${e.ext}, or /ext on ${e.ext}.`, 'warn');
3057
3557
  if (e && e.status === 'planned') return say(`${e.usage} is planned, not built yet. /help shows what works today.`, 'warn');
3058
3558
  if (!e && await slotNameCommand(cmd, restArg)) return undefined;
3059
3559
  const cliHit = resolveCliSlash(cmd, restArg); // QUOXON-FW: slash words that name a real quox CLI leaf
@@ -3066,7 +3566,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3066
3566
  if (!near.length) return say(`Unknown command ${cmd}. Try /help`, 'warn');
3067
3567
  // spelling-only hints keep the plain "/a or /b" line; keyword hints say what each command does
3068
3568
  const byWords = near.some(x => x.kw);
3069
- say(`Unknown command ${cmd}. Did you mean ${byWords ? near.map(x => (x.desc ? `${x.name} (${x.desc})` : x.name)).join(', ') : near.map(x => x.name).join(' or ')}? (/help lists them)`, 'warn');
3569
+ const viaAlias = (x) => { const en = findEntry(x.name); return en && en.id !== x.name.slice(1) ? `${x.name} (${en.id})` : x.name; }; // an alias names the command it runs
3570
+ say(`Unknown command ${cmd}. Did you mean ${byWords ? near.map(x => (x.desc ? `${x.name} (${x.desc})` : x.name)).join(', ') : near.map(viaAlias).join(' or ')}? (/help lists them)`, 'warn');
3070
3571
  if (near.length === 1) { // one strong match: offer it as a y/N card (vetted, never a human-typed command)
3071
3572
  const v = vetSuggestion(near[0].name);
3072
3573
  if (v.ok) { R.current.suggestAsk = { line: v.line, at: Date.now() }; ask(`Run ${v.line}?`, { action: v.line, reason: 'Nearest command to what you typed.' }); }
@@ -3098,7 +3599,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3098
3599
  function openAskStates() {
3099
3600
  const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
3100
3601
  const W = R.current.pendingWalk; const walkCardSt = W && W.card && Date.now() - W.at < WALK_TTL ? W.card : null; // WALK-FIXES: one card per decision, open as long as the walk (30 min), not the 2-minute ask clock
3101
- return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.profileAsk, R.current.cliAsk, R.current.updateAsk, R.current.orgPending, R.current.hatPending, HW.current.card(), RW.current.walk.card(), R.current.suggestAsk && !R.current.suggestLine ? R.current.suggestAsk : null, R.current.handoffAsk, R.current.askQuox, R.current.strayAsk, R.current.planAsk, SC.current.pendingFile, SC.current.reattach, CH.current.pending, SPN.current.pending].map(fresh)].filter(Boolean);
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);
3102
3603
  }
3103
3604
  const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || UW.current.pending?.() || runAskOpen();
3104
3605
  function touchAsks() { for (const x of openAskStates()) x.at = Date.now(); KC.current.touch?.(); UW.current.touch?.(); }
@@ -3236,7 +3737,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3236
3737
  let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
3237
3738
  if (askOpen() && !R.current.noteAsk) {
3238
3739
  const o = askOutcomeOf(text, { runWord: runAskOpen() }) || (askAnswer(text) === 'chat' && !S.current.turn && !R.current.pendingWalk && !isAskClarification(text) ? { kind: 'dismissed' } : null);
3239
- const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'profileAsk', 'cliAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
3740
+ const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'sayLastAsk', 'profileAsk', 'cliAsk', 'installAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
3240
3741
  const foreign = KC.current.pending?.() || UW.current.pending?.() || HW.current.card() || SC.current.pendingFile || SC.current.reattach || CH.current.pending || SPN.current.pending || runAskOpen();
3241
3742
  if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
3242
3743
  else if (o) {
@@ -3316,6 +3817,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3316
3817
  if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
3317
3818
  if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
3318
3819
  }
3820
+ if (R.current.sayLastAsk) { // SAY-LAST-TARGET "Send to <slot>? y send · n cancel": y sends the whole text
3821
+ const a = R.current.sayLastAsk; R.current.sayLastAsk = null;
3822
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { sendToSlot(a.slot, a.text); return; }
3823
+ if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3824
+ }
3319
3825
  if (R.current.slotCreateAsk) { // "No slot X. Create it? y/N/c" from a /slot near miss: y runs /slot X --create
3320
3826
  const a = R.current.slotCreateAsk; R.current.slotCreateAsk = null;
3321
3827
  if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { const l = `/slot ${a.nm} --create`; echoCmd(l); command(l); return; }
@@ -3336,6 +3842,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3336
3842
  }
3337
3843
  if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
3338
3844
  }
3845
+ 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
+ const a = R.current.installAsk; R.current.installAsk = null;
3847
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { installNow(a); return; }
3848
+ if (!text.startsWith('/')) { say(`Not installed. Nothing was downloaded.`, 'dim'); nextInstallOffer(); return; }
3849
+ }
3339
3850
  if (R.current.cliAsk) { // QUOXON-FW: a slash word named a WRITE quox command: "Run it? y/N" (typing y is the authorization)
3340
3851
  const a = R.current.cliAsk; R.current.cliAsk = null;
3341
3852
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { if (R.current.bang) say('A command is still running. Ctrl+C stops it.', 'warn'); else bangCommand(a.bang); bump(n => n + 1); return; }
@@ -3356,6 +3867,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3356
3867
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { const v = vetSuggestion(a.line); if (v.ok) { R.current.preConfirmed = v.id === 'org' ? 'org' : null; echoCmd(v.line); command(v.line); bump(n => n + 1); } return; }
3357
3868
  if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
3358
3869
  }
3870
+ if (R.current.objectiveAsk) { // "Make this an objective that runs until done?" (yes or no): yes creates + starts it, no does nothing
3871
+ const a = R.current.objectiveAsk; R.current.objectiveAsk = null;
3872
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runObjective(a); return; }
3873
+ if (!text.startsWith('/')) { say('No objective made.', 'dim'); return; }
3874
+ }
3359
3875
  if (R.current.handoffAsk) { // "Hand this question to CommanderQ?" (yes or no): yes asks it once, the attach stays
3360
3876
  const a = R.current.handoffAsk; R.current.handoffAsk = null;
3361
3877
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runHandoff(a); return; }
@@ -3396,6 +3912,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3396
3912
  else if (v === 'needs-run') { say(`Not run yet. ${ra.danger}, so type run to run it, or n to skip.`, 'warn'); return; }
3397
3913
  else if (v === 'no') { SC.current.runAsk = null; transcript?.bang?.(ra.cmd, 'declined'); say('Not run.', 'dim'); return; }
3398
3914
  }
3915
+ { 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
+ 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; } }
3399
3917
  if (text.startsWith('/') && !text.includes('\n')) { remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
3400
3918
  const roomSend = api.slotSay ? parseRoomSend(text) : null;
3401
3919
  if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
@@ -3485,9 +4003,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3485
4003
  const now = Date.now();
3486
4004
  const idx = hit.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
3487
4005
  if (idx != null) {
3488
- const dbl = M.last && now - M.last.at < DOUBLE_MS && M.last.x === e.x && M.last.y === e.y;
3489
- M.last = dbl ? null : { at: now, x: e.x, y: e.y };
3490
- if (dbl) { const w = wordAt(B.current.text, idx); edit({ type: 'select', anchor: w.from, cursor: w.to }); copySelection(); return; }
4006
+ const same = M.last && now - M.last.at < DOUBLE_MS && M.last.x === e.x && M.last.y === e.y;
4007
+ const n = same ? M.last.n + 1 : 1;
4008
+ M.last = n >= 3 ? null : { at: now, x: e.x, y: e.y, n };
4009
+ if (n === 2) { const w = wordAt(B.current.text, idx); edit({ type: 'select', anchor: w.from, cursor: w.to }); copySelection(); return; }
4010
+ if (n >= 3) { const l = lineAt(B.current.text, idx); edit({ type: 'select', anchor: l.from, cursor: l.to }); copySelection(); return; }
3491
4011
  edit({ type: 'place', index: idx }); K.current.composerClicked = true;
3492
4012
  M.drag = { anchor: B.current.cursor, cursorRow: hit.cursorRow ?? null };
3493
4013
  return;
@@ -3613,6 +4133,25 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3613
4133
  }
3614
4134
 
3615
4135
  /** COMPOSER-EDIT: cells the draft has per row (the composer box is `cols` wide, less the "› " prompt). */
4136
+ /** QUOXON-HISTORY-UP: still on the entry Up recalled (unedited): Up/Down jump whole entries, not rows of a long recalled message. */
4137
+ function browsingHistory() { return R.current.histIdx >= 0 && B.current.text === R.current.histText; }
4138
+ /** QUOXON-HISTORY-UP: Ctrl+R reverse search. The composer shows "(reverse-i-search) q: match"; Enter accepts, Esc cancels, Ctrl+R again = next older. */
4139
+ function rsShow() {
4140
+ const rs = R.current.rs; const hist = R.current.history; const q = rs.q.toLowerCase(); let n = rs.skip; rs.hit = null;
4141
+ for (let i = hist.length - 1; i >= 0; i--) if (hist[i].toLowerCase().includes(q) && n-- === 0) { rs.hit = hist[i]; break; }
4142
+ B.current = bufferReducer(initialBuffer(), { type: 'set', text: `(reverse-i-search) ${rs.q}: ${rs.hit == null ? '' : rs.hit.replace(/\s*\n\s*/g, ' ')}` }); bump(n2 => n2 + 1);
4143
+ }
4144
+ function rsEvent(e) {
4145
+ const rs = R.current.rs;
4146
+ if (e.type === 'key') {
4147
+ if (e.name === 'escape' || e.name === 'ctrl-c') { B.current = rs.saved; R.current.rs = null; bump(n => n + 1); return; }
4148
+ if (e.name === 'enter') { const hit = rs.hit; B.current = rs.saved; R.current.rs = null; if (hit != null) { R.current.histIdx = -1; edit({ type: 'set', text: hit }); } else bump(n => n + 1); return; }
4149
+ if (e.name === 'ctrl-r') { rs.skip++; return rsShow(); }
4150
+ if (e.name === 'backspace') { rs.q = rs.q.slice(0, -1); rs.skip = 0; return rsShow(); }
4151
+ return;
4152
+ }
4153
+ if (e.type === 'text' || e.type === 'paste') { rs.q += e.text.replace(/\s+/g, ' '); rs.skip = 0; rsShow(); }
4154
+ }
3616
4155
  function composerWidth() { return Math.max(1, cols - 2); }
3617
4156
  /** COMPOSER-EDIT: copy the composer selection to the clipboard with OSC 52 (works over SSH; needs a terminal that allows it). */
3618
4157
  function copySelection() {
@@ -3635,6 +4174,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3635
4174
  return;
3636
4175
  }
3637
4176
  if (name === 'ctrl-d') { if (!B.current.text) exit(); return; }
4177
+ if (LC.current.focused() && !s.approvals.length && !B.current.text && LC.current.key(name, e.text)) return; // QLOOP-LIVE: keys of a focused loop card
3638
4178
  if (R.current.roster.focus && !s.approvals.length && rosterKey(name)) return;
3639
4179
  // quick reply (owner 2026-10-15): Tab in an EMPTY composer pre-fills "/say <last sender> " (before the palette, which owns Tab otherwise)
3640
4180
  if (name === 'tab' && !B.current.text && R.current.lastSender && !headCard(s)) { edit({ type: 'set', text: `/say ${R.current.lastSender.toLowerCase()} ` }); return; }
@@ -3663,7 +4203,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3663
4203
  case 'delete': return edit({ type: 'delete' });
3664
4204
  case 'delete-word': return edit({ type: 'deleteWordBack' });
3665
4205
  case 'kill-line': return edit({ type: 'killLineBack' });
3666
- case 'left': case 'right': case 'home': case 'end': return edit({ type: name });
4206
+ case 'home': { // Ctrl+A stays line-start; a second Ctrl+A within 600 ms selects the whole draft (and copies it)
4207
+ if (e.via === 'ctrl-a') {
4208
+ const k = K.current;
4209
+ if (k.ctrlA && now - k.ctrlA.at < 600 && k.ctrlA.text === B.current.text && B.current.text) { k.ctrlA = 0; edit({ type: 'selectAll' }); copySelection(); return; }
4210
+ k.ctrlA = { at: now, text: B.current.text };
4211
+ }
4212
+ return edit({ type: 'home' });
4213
+ }
4214
+ case 'left': case 'right': case 'end': return edit({ type: name });
3667
4215
  case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'wordLeft' : 'wordRight' });
3668
4216
  case 'kill-line-forward': return edit({ type: 'killLineForward' }); // Ctrl+K
3669
4217
  case 'delete-word-forward': return edit({ type: 'deleteWordForward' }); // Alt+D, Ctrl+Delete
@@ -3678,21 +4226,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3678
4226
  case 'redo': return edit({ type: 'redo' });
3679
4227
  case 'up': {
3680
4228
  if (B.current.text === '' && s.pending.length) { const last = s.pending[s.pending.length - 1]; dispatch({ type: 'queue.pop' }); edit({ type: 'set', text: last.text }); return; }
3681
- if (atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
4229
+ if (browsingHistory() || atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
3682
4230
  const hist = R.current.history;
3683
4231
  if (B.current.text === '' || R.current.histIdx >= 0) {
3684
4232
  const i = R.current.histIdx < 0 ? hist.length - 1 : Math.max(0, R.current.histIdx - 1);
3685
- if (hist[i] !== undefined) { R.current.histIdx = i; edit({ type: 'set', text: hist[i] }); }
4233
+ if (hist[i] !== undefined) { R.current.histIdx = i; R.current.histText = hist[i]; edit({ type: 'set', text: hist[i] }); }
3686
4234
  }
3687
4235
  return;
3688
4236
  }
3689
4237
  return edit({ type: 'up', width: composerWidth() });
3690
4238
  }
3691
4239
  case 'down': {
4240
+ if (B.current.text === '' && R.current.histIdx < 0 && !s.approvals.length && LC.current.anyRunning() && !LC.current.focused()) { LC.current.focus(); return; } // QLOOP-LIVE: Down on an empty composer reaches the newest running card (its hint line says so)
3692
4241
  if (B.current.text === '' && R.current.histIdx < 0 && rosterOn()) { focusRoster(); return; }
3693
- if (atVisualEdge(B.current, 'down', composerWidth()) && R.current.histIdx >= 0) {
4242
+ if ((browsingHistory() || atVisualEdge(B.current, 'down', composerWidth())) && R.current.histIdx >= 0) {
3694
4243
  const i = R.current.histIdx + 1;
3695
- if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; edit({ type: 'set', text: R.current.history[i] }); }
4244
+ if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; R.current.histText = R.current.history[i]; edit({ type: 'set', text: R.current.history[i] }); }
3696
4245
  return;
3697
4246
  }
3698
4247
  return edit({ type: 'down', width: composerWidth() });
@@ -3746,6 +4295,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3746
4295
  if (LI.current.isOpen()) { LI.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the sign-in card takes every key
3747
4296
  if (CN.current.isOpen()) { CN.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the secure card takes every key
3748
4297
  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
+ 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
+ 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; }
3749
4300
  const card = headCard(S.current);
3750
4301
  const g = K.current.guard;
3751
4302
  // any different key ends the "this key was already down" suppression
@@ -3761,6 +4312,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3761
4312
  edit({ type: 'paste', text: e.text }); // a card never discards a paste
3762
4313
  } else if (e.type === 'text') {
3763
4314
  if (card && approvalKey('text', e.text, now)) { /* consumed by the approval dialog */ }
4315
+ else if (!card && !B.current.text && LC.current.focused() && LC.current.key(null, e.text)) { /* consumed by the focused loop card (s halts, y confirms) */ }
3764
4316
  else if (!card && R.current.roster.focus && rosterKey(null, e.text)) { /* consumed by the roster */ }
3765
4317
  else if (!card && askKey(e.text, now)) { /* consumed by the open ask */ }
3766
4318
  else edit({ type: 'insert', text: e.text });
@@ -3774,6 +4326,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3774
4326
  };
3775
4327
  onData.current = (chunk) => {
3776
4328
  const now = Date.now();
4329
+ DC.current.keypress(); // a keypress sends the companion dragon away, but not in its first moments
3777
4330
  finishIntroRef.current();
3778
4331
  // Enter is a newline only inside a real paste (bracketed, or several lines in one chunk: keys.js).
3779
4332
  // Its timing relative to the previous chunk says nothing: tmux, expect and SSH batch segments.
@@ -3888,6 +4441,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3888
4441
  stdin.on('data', fn);
3889
4442
  return () => {
3890
4443
  R.current.closed = true;
4444
+ LC.current.dispose();
3891
4445
  R.current.abort?.abort(); // closing quoxon cancels the active stream (and its HTTP body)
3892
4446
  for (const k of ['fallbackTimer', 'ctrlCTimer']) clearTimeout(R.current[k]);
3893
4447
  clearTimeout(K.current.danglingTimer);
@@ -3930,6 +4484,16 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3930
4484
  }
3931
4485
  pendingHist = s.history.slice(committedRef.current);
3932
4486
  }
4487
+ const staticItems = (() => { // QLOOP-LIVE: the committed history for <Static>; append-only, a settled loop card joins at the end
4488
+ let st = staticRef.current;
4489
+ const upto = Math.min(committedRef.current, s.history.length);
4490
+ if (st.seen > s.history.length) st = staticRef.current = { list: [], seen: 0, deferred: [] };
4491
+ let list = st.list;
4492
+ 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
+ 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]; } }
4494
+ st.list = list;
4495
+ return list;
4496
+ })();
3933
4497
  lastAppRender.current = nowR;
3934
4498
  // Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
3935
4499
  const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
@@ -3939,7 +4503,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3939
4503
  R.current.cardMidAnim = Boolean(card && motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000); // this render draws the card mid-animation (appDue keeps it due until a settled draw)
3940
4504
  if (motionStats && motionOn && card && card.displayedAt != null && !(motionStats.cards ||= []).some(x => x.card.callId === card.callId)) motionStats.cards.push({ card, org: orgShown() || null }); // the card took the draw-in path (a test renders it at fixed motionE)
3941
4505
  R.current.armedShown = armed;
3942
- const ctxParts = [demoLabel && profile === 'demo' ? null : profile, orgShown(), (R.current.noAi && !s.ctx?.model ? NO_AI_STATUS : modelLabel(s.ctx, MC.current)), demoLabel ? (demoAuto ? 'demo data, nothing real runs' : null) : MD.current, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules), AR.current.on ? '↻ auto' : null]; // [5]: auto-restart, context row of the status
4506
+ const ctxParts = [demoLabel && profile === 'demo' ? null : profile, orgShown(), (R.current.noAi && !s.ctx?.model ? NO_AI_STATUS : modelLabel(s.ctx, MC.current)), demoLabel ? (demoAuto ? 'demo data, nothing real runs' : null) : MD.current, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules), AR.current.on ? '↻ auto' : null, SV.current.chip ? { name: SV.current.chip.name, role: SV.current.chip.role } : null, SV.current.chip?.remote || null]; // [6],[7]: server chip, remote instance; [5]: auto-restart, context row of the status
3943
4507
  const caps = R.current.caps;
3944
4508
  const hint = (busy ? `${caps.steer ? 'Enter steer' : 'Enter queue'} · Tab queue · ${caps.interrupt ? 'Esc stop' : 'Esc disconnects'}` : ''); // owner 22:12 "Lose this: Enter send · /help": only the hint a running turn needs is shown
3945
4509
  const placeholder = busy ? (caps.steer ? 'Type to steer (Enter) or queue (Tab)' : 'Type to queue (Enter or Tab)') : R.current.askArmed ? ASK_PROMPT : 'Ask Quox anything…';
@@ -3983,39 +4547,41 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3983
4547
  if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
3984
4548
  const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
3985
4549
  return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
3986
- h(Static, { key: `static-${epoch}`, items: s.history.slice(0, committedRef.current) }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(s.history[i - 1], it, termRows(stdout)) }))),
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)) }))),
3987
4551
  h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
3988
4552
  }
3989
4553
  const ruleAbove = endsInRule({ card, asks: s.asks || [], background: s.background, pending: s.pending, steering: s.steering, countdown: Boolean(AR.current.timer && AR.current.endsAt) }) && !(SU.current.isOpen() || LI.current.isOpen() || CN.current.isOpen());
3990
4554
  return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
3991
4555
  // Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
3992
4556
  // already-printed items would drop it (and reprint the rest).
3993
- h(Static, { key: `static-${epoch}`, items: introDone.current ? s.history.slice(0, committedRef.current) : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(s.history[i - 1], it, termRows(stdout)) }))),
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)) }))),
3994
4558
  introDone.current ? null : h(Box, { key: 'intro-live', flexDirection: 'column' }, h(Intro, { item: introItem, cols, dragon: introDragon, progress: Math.min(1, (Date.now() - introStart.current) / introMs) })),
3995
4559
  liveRegion([
3996
4560
  ...pendingHist.map((it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(FxItem, { item: it, cols, fxFor, gap: historyGap(i ? pendingHist[i - 1] : s.history[committedRef.current - 1], it, termRows(stdout)), active: busy && it.kind === 'trace' && i === pendingHist.length - 1 && !s.blocks.length }))),
3997
4561
  ...s.blocks.map((b, i) => { const gap = historyGap(i ? s.blocks[i - 1] : (pendingHist[pendingHist.length - 1] ?? s.history[committedRef.current - 1]), b, termRows(stdout)); return h(Box, { key: b.id, flexDirection: 'column' }, motionOn && revealTimingFor(b) ? h(FxItem, { item: b, cols, live: true, fxFor, ignition: feats().ignition, gap }) : h(HistoryItem, { item: b, cols, live: true, gap })); })]),
3998
4562
  h(DragonLive, { key: 'dragon', cols }),
3999
- flyActive(nowR) ? h(FlyLive, { key: 'dragon-fly', cols, rows: termRows(stdout), startedAt: R.current.fly.startedAt, still: R.current.fly.still }) : null,
4563
+ flyActive(nowR) ? h(FlyLive, { key: 'dragon-fly', cols, rows: termRows(stdout), startedAt: R.current.fly.startedAt, still: R.current.fly.still, chromeH: chromeH.current }) : null,
4000
4564
  dragonTalkVisible(nowR) ? h(DragonTalk, { key: 'dragon-talk', cols, label: R.current.dragonTalk.label, talking: !R.current.dragonTalk.armed && !R.current.dragonTalk.endedAt, since: R.current.dragonTalk.since, still: !motionOn || mono() }) : null,
4565
+ h(DragonCompanionLive, { key: 'dragon-companion', ctl: DC.current, cols, rows: termRows(stdout) ?? 24 }),
4001
4566
  h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
4002
4567
  busy ? h(Working, { key: 'working', motion: motionOn && !R.current.exiting, handoff: feats().handoff, tokens: turnTokens(s.turn), act: s.turn.act, activity: describeActivity(s), since: s.turn.startedAt, interrupting: s.turn.status === 'interrupting', escLabel: caps.interrupt ? null : 'Esc disconnects' }) : null,
4003
- card ? h(ApprovalCard, { card, org: orgShown() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, draftHint: K.current.cardFocus ? 'focus' : (B.current.text ? 'draft' : null), confirm: K.current.confirm, position: { i: 1, n: queue.length } }) : null,
4568
+ ...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,
4004
4570
  ...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
4005
4571
  // TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
4006
4572
  s.background > 0 ? h(Box, { paddingLeft: 2 }, h(Text, { color: c('dim') }, `Waiting for ${s.background} background agent${s.background === 1 ? '' : 's'} to finish`)) : null,
4007
4573
  h(Pending, { pending: s.pending, steering: s.steering, cols }),
4008
- AR.current.timer && AR.current.endsAt ? h(Box, { key: 'restart-countdown', paddingLeft: 2 }, h(Text, { color: c('accent'), bold: true }, countdownLine(Math.max(1, Math.ceil((AR.current.endsAt - Date.now()) / 1000)), cols))) : null,
4574
+ 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,
4009
4575
  ruleAbove ? null : h(Box, { marginTop: 1 }),
4010
4576
  SU.current.isOpen() ? h(StepUpCard, { view: SU.current.view(), cols }) : LI.current.isOpen() ? h(LoginCard, { ...LI.current.view(), cols, profile }) : CN.current.isOpen() ? h(ConnectCard, { ...CN.current.view(), cols })
4011
- : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
4012
- : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove }),
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) } }),
4013
4579
  !CN.current.isOpen() && PK.current.isOpen() && !card ? h(ListView, { ...PK.current.view(), cols }) : !CN.current.isOpen() && PL.current.isOpen() && !card ? h(ListView, { ...PL.current.view(), cols }) : null,
4014
- h(RowsBar, { rows: RW.current.view(), cols }),
4580
+ h(RowsBar, { rows: RW.current.view(), cols, limit: { grid: QPE_GRID, n: qpeRowsSetting(api.settings?.()) } }),
4015
4581
  h(PresentBar, { stepUp: SU.current, cols }),
4016
4582
  h(StatusLine, { cols, slot: where.current.slot, agent: where.current.agent, groups: [...SC.current.rooms].map(([name, e]) => ({ name, unread: e.unread })), parts: ctxParts, right: hint }),
4017
4583
  h(WaitingNotice, { items: s.waiting.filter(x => K.current.dismissed.has(x.callId)), cols }),
4018
- h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
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)] }),
4019
4585
  rosterNode())));
4020
4586
  }
4021
4587