quoxcli 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/lib/api/endpoints/admin.js +21 -0
  3. package/lib/api/endpoints/calendarq.js +8 -0
  4. package/lib/api/endpoints/governance.js +7 -0
  5. package/lib/api/endpoints/inboxq.js +39 -0
  6. package/lib/api/endpoints/index.js +4 -0
  7. package/lib/api/endpoints/lens.js +34 -0
  8. package/lib/api/endpoints/lucid.js +18 -0
  9. package/lib/api/endpoints/mcp.js +35 -0
  10. package/lib/api/endpoints/mon.js +6 -7
  11. package/lib/api/endpoints/notesq.js +2 -0
  12. package/lib/api/endpoints/quoxplan.js +51 -0
  13. package/lib/api/endpoints/v1.js +57 -0
  14. package/lib/commands/actions.js +51 -13
  15. package/lib/commands/admin.js +86 -0
  16. package/lib/commands/approvals.js +5 -1
  17. package/lib/commands/calendar.js +27 -0
  18. package/lib/commands/chat.js +1 -1
  19. package/lib/commands/contacts.js +2 -0
  20. package/lib/commands/fleet.js +164 -0
  21. package/lib/commands/fleetRecipe.js +174 -0
  22. package/lib/commands/lens.js +268 -0
  23. package/lib/commands/lucid.js +6 -0
  24. package/lib/commands/lucidGoals.js +423 -0
  25. package/lib/commands/mail.js +287 -7
  26. package/lib/commands/mcp.js +190 -0
  27. package/lib/commands/mon.js +14 -0
  28. package/lib/commands/monitoring.js +73 -1
  29. package/lib/commands/notesSeal.js +380 -22
  30. package/lib/commands/offload.js +51 -10
  31. package/lib/commands/plan.js +507 -54
  32. package/lib/commands/research.js +78 -0
  33. package/lib/commands/settings.js +7 -1
  34. package/lib/commands/volt.js +6 -1
  35. package/lib/commands/wazuh.js +74 -9
  36. package/lib/commands/wazuhIntake.js +55 -6
  37. package/lib/core/commandRegistrars.js +2 -0
  38. package/lib/core/config.js +14 -1
  39. package/lib/core/offload.js +83 -0
  40. package/lib/core/output.js +29 -4
  41. package/lib/core/selfUpdate.js +2 -0
  42. package/lib/core/version.js +15 -2
  43. package/lib/dragon/select.js +2 -2
  44. package/lib/quoxon/api.js +4 -0
  45. package/lib/quoxon/app.js +258 -49
  46. package/lib/quoxon/approvalsView.js +3 -1
  47. package/lib/quoxon/ask.js +4 -2
  48. package/lib/quoxon/bang.js +7 -1
  49. package/lib/quoxon/commandShort.js +1 -1
  50. package/lib/quoxon/commands.js +5 -5
  51. package/lib/quoxon/delegationDragon.js +49 -0
  52. package/lib/quoxon/dnd.js +47 -0
  53. package/lib/quoxon/dragonCompanion.js +24 -12
  54. package/lib/quoxon/events.js +12 -1
  55. package/lib/quoxon/extensions.js +2 -0
  56. package/lib/quoxon/foreignText.js +49 -0
  57. package/lib/quoxon/inboxView.js +5 -5
  58. package/lib/quoxon/launcher.js +2 -1
  59. package/lib/quoxon/loopCard.js +2 -1
  60. package/lib/quoxon/main.js +8 -2
  61. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  62. package/lib/quoxon/onboarding/view.js +1 -1
  63. package/lib/quoxon/render.js +5 -2
  64. package/lib/quoxon/resend.js +14 -0
  65. package/lib/quoxon/rows.js +3 -1
  66. package/lib/quoxon/secretShape.js +2 -0
  67. package/lib/quoxon/slash/broadcast.js +30 -6
  68. package/lib/quoxon/slash/broadcastRun.js +12 -6
  69. package/lib/quoxon/slash/cliLeaves.json +54 -0
  70. package/lib/quoxon/slash/dashboard.js +2 -0
  71. package/lib/quoxon/slash/listView.js +2 -1
  72. package/lib/quoxon/slash/palette.js +41 -0
  73. package/lib/quoxon/slash/paletteController.js +6 -2
  74. package/lib/quoxon/slash/restart.js +2 -0
  75. package/lib/quoxon/slotChat.js +7 -3
  76. package/lib/quoxon/slotRun.js +2 -0
  77. package/lib/quoxon/webPty.js +15 -0
  78. package/lib/quoxon/whatsnew.jsonl +7 -0
  79. package/lib/render/planBrief.js +614 -0
  80. package/lib/toolOutputShape.js +6 -3
  81. package/package.json +1 -1
  82. package/vendor/dragonlab/SOURCE +3 -3
  83. package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
  84. package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
  85. package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
  86. package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
  87. package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
  88. package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
  89. package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
  90. package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
  91. package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
  92. package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
  93. package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
  94. package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
  95. package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
  96. package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
  97. package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
  98. package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
  99. package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
  100. package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
  101. package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
  102. package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
  103. package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
  104. package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
  105. package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
  106. package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
  107. package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
  108. package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
  109. package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
  110. package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
  111. package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
  112. package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
  113. package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
  114. package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
  115. package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
  116. package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
  117. package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
  118. package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
  119. package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
  120. package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
  121. package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
  122. package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
  123. package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
  124. package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
  125. package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
  126. package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
  127. package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
  128. package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
  129. package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
  130. package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
package/lib/quoxon/app.js CHANGED
@@ -19,7 +19,7 @@ import { batchFor, batchModel, batchLabel, collapseArrivals } from './approvalBa
19
19
  import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, approvalAsWalkItem, parseAnswer, parsePairs, needsPin } from './approvalsView.js';
20
20
  import { createAutoOpen, noteArrivals, openDecision, takeQueue, strayAnswer, parseApprovalsArg, autoOn, APPROVALS_OPTIONS } from './autoOpen.js';
21
21
  import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
22
- import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
22
+ import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate, consentOf } from './events.js';
23
23
  import { turnTokens } from './indicator.js';
24
24
  import {
25
25
  Composer, RingComposer, Pending, ApprovalCard, BatchCard, LoopCardView, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
@@ -58,6 +58,7 @@ import { runWithSpinner, spinnerVerb } from './slashSpinner.js';
58
58
  import { parseMail, mailTable, mailDetail, noMailboxLines, mailNewestFirst, MAIL_USAGE } from './mailView.js';
59
59
  import { inboxBarText, arrivals, loadSeen, withSeen, arrivalLine, inboxTable, announceFresh, activeMutes, mutesLines, inboxDetail, parseInbox, INBOX_USAGE, newestFirst, unreadItems, readItems, isApprovalItem, isMuted } from './inboxView.js';
60
60
  import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
61
+ import { isWebPty, SHELL_REFUSED } from './webPty.js';
61
62
  import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
62
63
  import { packageVersion } from './welcome.js';
63
64
  import { feedbackPayload, feedbackPreview } from './feedback.js';
@@ -80,6 +81,7 @@ import { createConfirm } from './slash/confirm.js';
80
81
  import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
81
82
  import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, limitedOffer, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
82
83
  import { humanSessionLines } from '../core/sessionsOverview.js';
84
+ import { resolutionFromItem } from '../core/inboxResolution.js';
83
85
  import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
84
86
  import { capabilityLines, offerFor, findCapability } from './capabilities.js';
85
87
  import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
@@ -94,7 +96,7 @@ import { createSound } from './sound/index.js';
94
96
  import { createPalette } from './slash/paletteController.js';
95
97
  import { capabilityMap } from './slash/palette.js';
96
98
  import { createArgSources } from './slash/argSources.js';
97
- import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
99
+ import { runBroadcast, runAck, parsePokeArgs, slotNames } from './slash/broadcastRun.js';
98
100
  import { parseBroadcastText } from './slash/broadcast.js';
99
101
  import { isOwnBroadcast } from './ownBroadcast.js';
100
102
  import { orgLabel, isOrgId } from './orgLabel.js';
@@ -113,6 +115,7 @@ import { createLoopCards, rowsWithoutLoopCards } from './loopCards.js';
113
115
  import { initLoop, applySnapshot as applyLoopSnapshot, cardLines } from './loopCard.js';
114
116
  import { createCompanionController, companionCommand, DragonCompanionLive, takeGreeting, detachGreeting } from './dragonCompanion.js';
115
117
  import { dragonForAgent } from '../dragon/select.js';
118
+ import { delegationDragonOn, markDelegationDragon } from './delegationDragon.js';
116
119
  import { createDragonJobs } from '../dragon/jobs.js';
117
120
  import { createModelCmd } from './slash/modelCmd.js';
118
121
  import { createChallengeCmd } from './slash/challenge.js';
@@ -133,7 +136,7 @@ import { compactResult, compactError, contextFacts, compactShowLines } from './s
133
136
  import { intentPanel } from './intent.js';
134
137
  import { awayDue, parseSince } from './away.js';
135
138
  import { parseSlashArgs as parseCallMeSlashArgs, statusLines as callMeStatusLines } from '../core/callMe.js';
136
- import { parseDndArgs, untilEpoch, sleepIntent, afkMeansSleep, newCollected, morningLines, waitingCount, DND_NOTE } from './dnd.js';
139
+ import { parseDndArgs, untilEpoch, sleepIntent, afkMeansSleep, newCollected, morningLines, sinceLines, isIncident, waitingCount, DND_NOTE } from './dnd.js';
137
140
  import { sitrepData } from './objects/sitrep.js';
138
141
  import { mirrorErrorNote } from './objects/mirror.js';
139
142
  import { launchEgg } from './egg.js';
@@ -146,7 +149,7 @@ import { matchOrg, orgLines, orgRows, orgCandidates } from './orgSwitch.js';
146
149
  import { INSTALL_TOOLS, SUGGEST_LINE, planLine, offerQuestion, runInstall, missingTools } from './installCli.js';
147
150
  import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
148
151
  import { slotOutbox, isConnectivityFailure, queuedLine, sentLine, fullLine } from './slotOutbox.js';
149
- import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
152
+ import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING, isTurnIdTaken, HELD_NOT_SHOWN } from './resend.js';
150
153
  import { gripeFiledLine, gripeFailedLine, gripeQueuedFiledLine } from '../core/quoxglyph.js';
151
154
  import { crossOrgTuiLines } from '../core/crossOrgApprovals.js';
152
155
  const gripeLead = '📣 GRIPE │';
@@ -167,7 +170,8 @@ const CARD_GONE_MS = 400;
167
170
  const STEER_OFF_NOTE = "This model can't take steering mid-turn; queued for next turn.";
168
171
  const newTurnId = () => `QXN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
169
172
 
170
- function openEditor(text) {
173
+ export function openEditor(text) {
174
+ if (isWebPty(process.env)) return null; // QUOXON web PTY: no $EDITOR on the server
171
175
  const dir = mkdtempSync(join(tmpdir(), 'quoxon-'));
172
176
  const file = join(dir, 'prompt.md');
173
177
  writeFileSync(file, text, 'utf8');
@@ -191,9 +195,10 @@ const GRIPE_LINE = /^\/gripe!?(\s|$)/i;
191
195
  const isOneLineCommand = (t) => t.startsWith('/') && (!t.includes('\n') || GRIPE_LINE.test(t));
192
196
 
193
197
  export const INBOX_BAR_MAX_MS = 300000; // the bar never waits longer than this between reads
198
+ export const INBOX_BAR_READ = 10; // the bar counts the latest 10 items, in the poll AND in /inbox (one basis: no '7 new' then '25 new')
194
199
  export const INBOX_BAR_COST_X = 20; // wait 20x the last read's duration (a 3s read -> 60s; a fast read keeps the poll cadence)
195
200
 
196
- export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, restartDeclined = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], startupGraceMs = 60000, startupRetryMs = 3000, neverAnswered = false, onConnect = null, restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, restartCooloffMs = null, presenceRetryDelays = [2000, 5000, 10000, 20000, 30000, 60000], presencePollMs = 30000, autoIdleMs = null, autoTickMs = 1000, autoResumeMs = 3000, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, startupSwitchOffer = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, outboxPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined }) {
201
+ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, restartDeclined = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], startupGraceMs = 60000, startupRetryMs = 3000, neverAnswered = false, onConnect = null, restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, restartCooloffMs = null, presenceRetryDelays = [2000, 5000, 10000, 20000, 30000, 60000], presencePollMs = 30000, autoIdleMs = null, autoTickMs = 1000, autoResumeMs = 3000, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, startupSwitchOffer = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, outboxPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined, walkTtlMs = 1800000 }) {
197
202
  // Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
198
203
  // one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
199
204
  // PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
@@ -686,9 +691,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
686
691
  return () => clearInterval(id);
687
692
  }, []);
688
693
  // ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
689
- // Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): attach state + real stream events; art and reducer live in dragonlab.
694
+ // Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady, Weaver): attach state + real stream events; art and reducer live in dragonlab.
690
695
  const DC = React.useRef(null); DC.current ??= createCompanionController({ settings: () => api.settings?.() || {}, isTTY: Boolean(stdout?.isTTY), typing: () => B.current.text !== '', jobs: createDragonJobs({ reads: api.dragonReads || {}, context: () => ({ envelopeId: receiptMentions([...S.current.history, ...S.current.blocks])[0]?.value || null }) }) }); // each dragon's one job: a real read (dragon/jobs.js)
691
- const dispatchEv = (a) => { DC.current.tap(a); dispatch(a); };
696
+ const dispatchEv = (a) => { DC.current.tap(a); dispatch(markDelegationDragon(a, a?.ev?.tool === 'delegate_to_agent' && delegationDragonOn(api.settings?.() || {}, env))); }; // dragons on: CommanderQ's hand-off gets a static dragon
692
697
  // QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
693
698
  const LC = React.useRef(null); LC.current ??= createLoopCards({ api, say: (t, tone) => say(t, tone), addItem: (it) => dispatch({ type: 'loopcard', card: it.card }), bump: () => { if (!R.current.closed) bump(n => n + 1); }, dragon: { summon: (d, cap) => DC.current.summon(d, cap) } });
694
699
  const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
@@ -1190,7 +1195,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1190
1195
  R.current.inboxBusy = true; R.current.inboxAt = t;
1191
1196
  const epoch = R.current.inboxEpoch || 0;
1192
1197
  try {
1193
- const items = await api.inboxItems({ limit: 10 });
1198
+ const items = await api.inboxItems({ limit: INBOX_BAR_READ });
1194
1199
  R.current.inboxCost = Date.now() - t;
1195
1200
  if (epoch !== (R.current.inboxEpoch || 0)) return; // an org switch landed while this read was in flight: it belongs to the old org
1196
1201
  if (!R.current.inboxSeen && items.length) { try { R.current.inboxSeen = loadSeen(api.settings?.(), R.current.orgId ?? api.orgId?.() ?? null); } catch { /* no saved watermark: baseline from this read */ } }
@@ -1225,6 +1230,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1225
1230
  if (BL.current) return;
1226
1231
  const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
1227
1232
  try {
1233
+ const readAt = Date.now();
1228
1234
  const items = await api.approvals();
1229
1235
  if (N.authLost) say('Signed in again: approvals are back.', 'ok');
1230
1236
  else if (N.slow) say('Quox server is back.', 'ok');
@@ -1240,6 +1246,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1240
1246
  retryOrgName();
1241
1247
  dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
1242
1248
  R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
1249
+ walkWatch(R.current.openIds.ids, readAt); // TUI-CARD-SYNC: the card on screen, answered in another window, closes here too (never awaited)
1243
1250
  // Provider paths that emit no approval.requested still surface here, as the same card.
1244
1251
  for (const raw of items) {
1245
1252
  const item = await enrichPlanItem(raw, { objectiveGet: api.objectiveGet, agents: api.agents, orgs: api.orgs, cache: (R.current.planCache ||= new Map()) });
@@ -1456,7 +1463,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1456
1463
  // After approving from the terminal the tool runs asynchronously: poll its stored result.
1457
1464
  const pollResult = async (run) => {
1458
1465
  if (!run.approvalId || !api.approvalResult) return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
1459
- const t0 = Date.now();
1466
+ const t0 = Date.now(); let undecided = 0;
1460
1467
  while (Date.now() - t0 < resultTimeoutMs) {
1461
1468
  await new Promise(r => { const t = setTimeout(() => { R.current.pollTimers.delete(t); r(); }, resultPollMs); R.current.pollTimers.add(t); R.current.pollWake = r; });
1462
1469
  if (R.current.closed) return undefined;
@@ -1464,6 +1471,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1464
1471
  try {
1465
1472
  const r = await api.approvalResult(run.approvalId);
1466
1473
  if (r?.status === 'denied') return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'denied' });
1474
+ // Live 2026-11-08 (contacts_dial_batch): the inbox took the yes but the runner never got it, so the approval stayed
1475
+ // pending with nobody as the decider. The runner moves a row off pending the moment it receives a decision, so two
1476
+ // reads like that after our yes are proof nothing ran: say so, never "result not known yet".
1477
+ if (r && r.status === 'pending' && !r.decided_by) { if (++undecided >= 2) return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'unrecorded' }); }
1478
+ else undecided = 0;
1467
1479
  if (r && (r.status === 'approved' || r.status === 'error') && r.executed_at) {
1468
1480
  const text = r.result == null ? null : clip(typeof r.result === 'string' ? r.result : JSON.stringify(r.result), cols - 20);
1469
1481
  // Command reached the host and exited >= 1 (not a connect/auth failure): say "ran, exit N", not "failed".
@@ -1543,30 +1555,114 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1543
1555
  // server took it; a line typed while an answer is still on its way is never applied to the card just answered; Esc or any
1544
1556
  // command ends the walk with a summary; an id answered in this window never comes back, and a list held from earlier
1545
1557
  // (a bulk card, a range) is re-read from the server before its first card.
1546
- const WALK_TTL = 1800000;
1558
+ const WALK_TTL = walkTtlMs; // 30 minutes; a prop only so a test can lapse a card
1547
1559
  const walkAnsweredIds = () => (R.current.walkAnswered ||= new Set());
1548
- const walkCloseCard = (W, why = 'next') => { if (W?.card) { W.card.at = Date.now(); W.card.walkWhy = why; W.card = null; } }; // why: next/done settle silently; stop = interrupted
1560
+ // POPUP-DEDUPE (owner 2026-11-06, hit twice): what this window answered on the inline prompt, by inbox id and by approval id,
1561
+ // so the auto-popup never re-opens it a second later while the server still lists it, and a walk card says so plainly.
1562
+ const inlineAnswered = () => (R.current.inlineAnswered ||= { ids: new Map(), approvals: new Map() });
1563
+ const itemApprovalId = (it) => it?.payload?.approval_id || it?.approval_id || it?.approvalId || it?.aee_correlation_id || null;
1564
+ const answeredInline = (it) => { const m = inlineAnswered(); return m.ids.get(it?.id) || m.approvals.get(itemApprovalId(it)) || null; };
1565
+ const walkCloseCard = (W, why = 'next', settle = null) => { if (W?.card) { W.card.at = Date.now(); W.card.walkWhy = why; if (settle) { W.card.settleText = settle.text; W.card.settleTone = settle.tone; } W.card = null; } }; // why: next/done settle silently; stop = interrupted, gone = closed by the server (settle = the line it leaves)
1549
1566
  const walkKeysText = (it) => { const ks = it.payload.options.map(o => o.key); return ks.length > 1 ? `${ks.slice(0, -1).join(', ')} or ${ks[ks.length - 1]}` : ks.join(''); };
1550
1567
  // EXPIRED-ASK (owner 2026-10-17 21:23): a question whose time ran out is gone: say so plainly and move on, never "the question stays".
1551
1568
  const askExpiry = (it) => { const t = Date.parse(it?.due_at ?? it?.payload?.expires_at); return Number.isFinite(t) ? t : null; };
1552
1569
  const expiredText = (it) => { const t = askExpiry(it); return t ? `That question expired at ${askClock(t)}, so there's nothing to answer.` : "That question is no longer open, so there's nothing to answer."; };
1553
1570
  const walkGoneElsewhere = (W, id) => { const o = R.current.openIds; return Boolean(o && o.at > W.startedAt && !o.ids.has(id)); };
1554
- const walkSummaryLines = (W, head) => {
1571
+ // TUI-CARD-SYNC (owner 2026-11-08, two windows on one user: a card answered "a" in one window stayed open in the other and
1572
+ // later said "expired, not answered"): what the SERVER says became of a walk item that is no longer in the open list.
1573
+ // { kind: 'elsewhere', choice, at } | { kind: 'expired', at } | { kind: 'closed', at }. Read only for an item already gone.
1574
+ const walkFateOfGone = async (it) => {
1575
+ let item = null;
1576
+ if (api.inboxItem) { try { item = await api.inboxItem(it.id); } catch { item = null; } }
1577
+ const res = item && item.id ? resolutionFromItem(item) : null;
1578
+ const at = res?.resolved_at ? Date.parse(res.resolved_at) : NaN;
1579
+ if (!res) return { kind: 'closed', at };
1580
+ if (res.outcome === 'expired') return { kind: 'expired', at };
1581
+ const r = item.response || null;
1582
+ if (!r || r.housekeeping || ['withdrawn', 'cancelled'].includes(res.outcome)) return { kind: 'closed', at };
1583
+ const opt = typeof r.option === 'string' ? r.option : null;
1584
+ const label = opt ? it.payload?.options?.find(o => o.key === opt)?.label : null;
1585
+ const choice = opt ? (label && label !== opt ? `${opt}: ${decodeEntities(label)}` : opt) : res.outcome === 'approved' ? 'approved' : res.outcome === 'rejected' ? 'denied' : res.outcome;
1586
+ return { kind: 'elsewhere', choice, at };
1587
+ };
1588
+ const walkTitleQ = (it) => `"${clip(decodeEntities(it?.title || it?.id || 'the question'), Math.max(20, cols - 50))}"`;
1589
+ const walkGoneSettle = (it, fate) => ({ text: settledAskLine(walkTitleQ(it), fate, Number.isFinite(fate.at) ? fate.at : Date.now()), tone: settledAskTone(fate.kind) });
1590
+ // The poll's open list no longer has the card on screen: close it with what the server says, then on to the next card.
1591
+ const walkWatch = async (ids, readAt) => {
1592
+ const W = R.current.pendingWalk; const card = W?.card; const id = W?.current; const it = id ? W.items[id] : null;
1593
+ if (!card || !it || it.__orgId || W.done.has(id) || ids.has(id) || R.current.walkSend || R.current.walkWatching || readAt < (card.shownAt ?? card.at)) return;
1594
+ R.current.walkWatching = true;
1595
+ try {
1596
+ const fate = await walkFateOfGone(it);
1597
+ if (R.current.pendingWalk !== W || W.card !== card || W.current !== id || R.current.walkSend) return; // answered or stopped here meanwhile
1598
+ W.done.add(id); (W.dropped ||= new Set()).add(id);
1599
+ walkCloseCard(W, 'gone', walkGoneSettle(it, fate));
1600
+ walkShow();
1601
+ } finally { R.current.walkWatching = false; }
1602
+ };
1603
+ // A card left open past WALK_TTL: ask the server before saying anything (it may have been answered in another window).
1604
+ const walkLapsed = async (card) => {
1605
+ const W = R.current.pendingWalk; const it = W?.items?.[card.walk];
1606
+ if (!it) { card.settleText = ''; return; }
1607
+ if (it.__orgId) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered · /approvals`; card.settleTone = 'plain'; return; }
1608
+ const now = await openIdsNow();
1609
+ if (!now.ids) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered; couldn't check it with the server (${now.error}) · /approvals`; card.settleTone = 'plain'; return; }
1610
+ if (now.ids.has(it.id)) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered; the question is still open · /walkthrough`; card.settleTone = 'plain'; return; }
1611
+ const fate = await walkFateOfGone(it);
1612
+ if (R.current.pendingWalk === W) { W.done.add(it.id); (W.dropped ||= new Set()).add(it.id); }
1613
+ const st = walkGoneSettle(it, fate); card.settleText = st.text; card.settleTone = st.tone;
1614
+ };
1615
+ // fresh = the ids the server still has open (null: not re-checked), so an item answered elsewhere is never "still waiting"
1616
+ const walkSummaryLines = (W, head, fresh = null) => {
1555
1617
  const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null;
1556
- const left = W.ids.filter(id => !W.done.has(id) && id !== sending).map(id => W.items[id].title || id);
1557
- const lines = walkSummary({ answered: W.answered, skipped: W.skipped.map(id => W.items[id].title || id), left });
1618
+ const stillOpen = (id) => !fresh || fresh.has(id);
1619
+ const left = W.ids.filter(id => !W.done.has(id) && id !== sending && stillOpen(id)).map(id => W.items[id].title || id);
1620
+ const skipped = W.skipped.filter(stillOpen);
1621
+ const lines = walkSummary({ answered: W.answered, skipped: skipped.map(id => W.items[id].title || id), left });
1558
1622
  if (head) lines[0] = lines[0].replace(/^Walkthrough done/, head);
1559
1623
  if (sending) lines.push(` … still sending: ${W.items[sending].title || sending}`);
1560
- return { lines, open: left.length + W.skipped.length };
1624
+ return { lines, open: left.length + skipped.length };
1625
+ };
1626
+ // WALK-REVALIDATE (owner 2026-11-08 03:56: a window open since the day before listed 8 "still waiting", most answered
1627
+ // hours earlier in another window): the list a walk holds is re-checked with the server before it is shown; a failed
1628
+ // or slow re-check shows the held list, labelled as possibly out of date.
1629
+ const WALK_RECHECK_MS = 4000;
1630
+ const openIdsNow = async () => {
1631
+ let timer;
1632
+ try {
1633
+ const items = await Promise.race([api.approvals(), new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('timed out')), WALK_RECHECK_MS); })]);
1634
+ return { ids: new Set((Array.isArray(items) ? items : []).map(x => x?.id).filter(Boolean)), error: null };
1635
+ } catch (e) { return { ids: null, error: clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80) }; }
1636
+ finally { clearTimeout(timer); }
1561
1637
  };
1562
1638
  const walkEnd = (why) => { // Esc, a command, or a card left too long: the walk stops here and says where it stands
1563
1639
  const W = R.current.pendingWalk; if (!W) return false;
1640
+ const card = W.card; const cur = W.current && !W.done.has(W.current) ? W.items[W.current] : null; // the card on screen: its line comes from the server
1564
1641
  R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); walkCloseCard(W, 'stop');
1565
- const { lines, open } = walkSummaryLines(W, `Walkthrough stopped (${why})`);
1566
- say(' ');
1567
- lines.forEach((l, k) => say(l, k === 0 ? 'ok' : undefined));
1568
- if (open) say('Nothing else was sent. /walkthrough picks up what is still waiting.', 'dim');
1569
- queueMicrotask(() => reconcileAsks());
1642
+ const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null; // read now: the send settles on its own
1643
+ const held = W.ids.some(id => !W.done.has(id) && id !== sending) || W.skipped.length;
1644
+ // TUI-CARD-SYNC (owner 2026-11-08: /say during a card printed "The question closed before you answered." while it was
1645
+ // still open): the stopped card says closed only when the server's open list no longer has it.
1646
+ const settleCard = async ({ ids, error }) => {
1647
+ if (!card) return;
1648
+ if (!cur || sending || cur.__orgId) { card.settleText = cur && cur.__orgId && !sending ? `Not answered; it is still open in ${cur.__orgName || 'its org'}.` : ''; card.settleTone = 'plain'; return; }
1649
+ if (!ids) { card.settleText = `Not answered; couldn't check whether the question is still open${error ? ` (${error})` : ''}.`; card.settleTone = 'plain'; return; }
1650
+ if (ids.has(cur.id)) { card.settleText = 'Not answered; the question is still open.'; card.settleTone = 'plain'; return; }
1651
+ const fate = await walkFateOfGone(cur);
1652
+ if (fate.kind === 'elsewhere') { const st = walkGoneSettle(cur, fate); card.settleText = st.text; card.settleTone = st.tone; return; }
1653
+ card.settleText = 'The question closed before you answered.'; card.settleTone = 'plain';
1654
+ };
1655
+ const show = ({ ids, error }) => {
1656
+ settleCard({ ids, error }).catch(() => { if (card) card.settleText = ''; });
1657
+ const { lines, open } = walkSummaryLines(W, `Walkthrough stopped (${why})`, ids);
1658
+ say(' ');
1659
+ lines.forEach((l, k) => say(l, k === 0 ? 'ok' : undefined));
1660
+ if (error && held) say(`⚠ couldn't re-check with the server (${error}); this list may be out of date. /approvals to look again`, 'warn');
1661
+ if (open) say('Nothing else was sent. /walkthrough picks up what is still waiting.', 'dim');
1662
+ queueMicrotask(() => reconcileAsks());
1663
+ };
1664
+ if (!held) { show({ ids: null, error: null }); return true; }
1665
+ R.current.walkEnding = openIdsNow().then(show, () => show({ ids: null, error: 'unknown error' })).finally(() => { R.current.walkEnding = null; });
1570
1666
  return true;
1571
1667
  };
1572
1668
  // TUI-WALK (owner 2026-10-19: he always runs /approvals again to confirm it is empty): the walk ends with a FRESH server
@@ -1577,6 +1673,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1577
1673
  catch (e) { say(`⚠ couldn't re-check approvals (${clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80)}); /approvals to look again`, 'warn'); return; }
1578
1674
  const gone = walkAnsweredIds();
1579
1675
  const open = (Array.isArray(items) ? items : []).filter(x => x && !gone.has(x.id));
1676
+ // TUI-CARD-SYNC (owner 2026-11-08: "Nothing else is currently waiting" above a status line still saying "⏸ 1 approval
1677
+ // waiting"): the counter is set from this same read, so the line and the bar never disagree.
1678
+ R.current.openItems = open; syncApprovalRows(); R.current.openIds = { ids: new Set(open.map(i => i.id)), at: Date.now() };
1679
+ dispatch({ type: 'notify', patch: { approvals: open.length, error: null, slow: false } });
1580
1680
  // the closing line repeats the other-org count + switch hint the walk opened with (the helper's own cache: no new token mints)
1581
1681
  const sayOthers = async () => { try { const v = api.otherOrgApprovals ? await api.otherOrgApprovals() : null; for (const l of crossOrgTuiLines(v)) say(l, 'warn'); } catch { /* the hint is optional */ } };
1582
1682
  if (!open.length) { say('✔ Nothing else is currently waiting for you.', 'ok'); await sayOthers(); return; }
@@ -1599,13 +1699,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1599
1699
  if (W.done.has(id)) continue;
1600
1700
  if (gone.has(id)) { W.done.add(id); continue; } // answered in this window already: never shown again
1601
1701
  { const ex = askExpiry(W.items[id]); if (ex && ex <= Date.now()) { W.done.add(id); (W.dropped ||= new Set()).add(id); refreshNotify(); say(`"${clip(W.items[id].title || id, 60)}": ${expiredText(W.items[id])}`, 'accent'); continue; } }
1602
- if (walkGoneElsewhere(W, id)) { W.done.add(id); say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
1702
+ if (walkGoneElsewhere(W, id)) { W.done.add(id); const was = answeredInline(W.items[id]); if (was) { say(`Already ${was === 'approve' ? 'approved' : 'denied'} (you answered it inline). Moving on.`, 'ok'); continue; } say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
1603
1703
  nextId = id; break;
1604
1704
  }
1605
1705
  if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); walkRecheck(W); queueMicrotask(() => reconcileAsks()); return; }
1606
1706
  const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
1607
1707
  const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
1608
- W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
1708
+ W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId, shownAt: W.at };
1609
1709
  say(' ');
1610
1710
  if (it.__orgName) say(`in ${it.__orgName} · answered there directly; this window stays in ${orgShown() || 'its own org'}`, 'warn'); // APPROVALS-AUTO: another org's card says whose it is
1611
1711
  c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
@@ -1625,9 +1725,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1625
1725
  say(`That is not the name, so nothing was deleted. Type ${d.name} exactly, or Esc to cancel.`, 'warn');
1626
1726
  };
1627
1727
  const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
1728
+ if (R.current.walkEnding) await R.current.walkEnding; // the stopped walk's summary prints before the next walk's first card
1628
1729
  let decs = (list || []).filter(isDecision);
1629
1730
  if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
1630
- try { const open = new Set((await api.approvals()).map(x => x.id)); decs = decs.filter(x => open.has(x.id)); } catch { /* slow server: the answered-in-this-window set below still applies */ }
1731
+ const now = await openIdsNow();
1732
+ if (now.ids) decs = decs.filter(x => now.ids.has(x.id));
1733
+ else say(`⚠ couldn't re-check these with the server (${now.error}); they may be out of date.`, 'warn'); // the answered-in-this-window set below still applies
1631
1734
  }
1632
1735
  const gone = walkAnsweredIds();
1633
1736
  decs = decs.filter(x => !gone.has(x.id));
@@ -1647,10 +1750,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1647
1750
  // APPROVALS-AUTO (owner 2026-11-06: "just make approvals pop up, i am tired of opening them"): a NEW approval or decision
1648
1751
  // opens by itself as the walkthrough card. It only OPENS; it never answers. A draft, typing in the last 2s, a streaming
1649
1752
  // turn or another card on screen holds it until the next idle point; an open walk simply gains the card.
1650
- const autoSkip = () => new Set([...S.current.approvals.map(x => x.inboxItemId), ...walkAnsweredIds(), ...(R.current.pendingWalk ? R.current.pendingWalk.ids : [])].filter(Boolean));
1753
+ const autoSkip = (items = []) => new Set([...S.current.approvals.map(x => x.inboxItemId), ...walkAnsweredIds(), ...(R.current.pendingWalk ? R.current.pendingWalk.ids : []), ...items.filter(x => x && !x.__orgId && answeredInline(x)).map(x => x.id)].filter(Boolean));
1651
1754
  const autoBusy = () => Boolean(askOpen() || headCard(S.current) || SU.current.isOpen() || LI.current?.isOpen?.() || CN.current?.isOpen?.() || PK.current?.isOpen?.() || R.current.rs || R.current.noteAsk || R.current.delConfirm || R.current.denyReason || K.current.cardFocus || BL.current || AWAY.current.afk);
1652
1755
  const autoOpenItems = (items, how) => {
1653
- const skip = autoSkip(); const list = items.filter(x => !skip.has(x.id));
1756
+ const skip = autoSkip(items); const list = items.filter(x => !skip.has(x.id)); // re-checked right before opening: answered here since it queued
1654
1757
  if (!list.length) return;
1655
1758
  if (how === 'open') { const n = list.length; say(' '); say(`⏸ ${n === 1 ? 'A new approval just arrived; it is open below' : `${n} new approvals just arrived; they are open below, one at a time`}. Esc stops · /approvals auto off stops them opening by themselves.`, 'warn'); }
1656
1759
  const had = R.current.pendingWalk;
@@ -1663,10 +1766,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1663
1766
  const d = openDecision(A.st, { draft: B.current.text, lastKeyAt: K.current.lastKeyAt || 0, now: Date.now(), streaming: Boolean(S.current.turn), walkOpen: Boolean(R.current.pendingWalk), askOpen: autoBusy() });
1664
1767
  if (!d) return;
1665
1768
  if (d === 'open' || d === 'join') { A.waitSaid = false; autoOpenItems(takeQueue(A.st), d); return; }
1666
- if (d === 'wait-draft' && !A.waitSaid) { A.waitSaid = true; say(`⏸ ${A.st.queue.length === 1 ? 'A new approval is' : `${A.st.queue.length} new approvals are`} waiting; it opens once your draft is empty.`, 'dim'); }
1769
+ if (d === 'wait-draft' && !A.waitSaid) { // TUI-CARD-SYNC: another org's card says whose it is, so it never reads against "Nothing waiting for you"
1770
+ A.waitSaid = true; const q = A.st.queue; const orgs = [...new Set(q.map(x => x.__orgId ? x.__orgName || x.__orgId : ''))];
1771
+ const where = orgs.length === 1 && orgs[0] ? ` in ${orgs[0]}` : orgs.some(Boolean) ? ` (${q.filter(x => x.__orgId).length} in other orgs)` : '';
1772
+ say(`⏸ ${q.length === 1 ? 'A new approval is' : `${q.length} new approvals are`} waiting${where}; it opens once your draft is empty.`, 'dim');
1773
+ }
1667
1774
  A.timer = setTimeout(autoTry, 400); A.timer.unref?.();
1668
1775
  };
1669
- const autoArrive = (items, opts = {}) => { const A = AO.current; const r = noteArrivals(A.st, items, { skipIds: autoSkip(), ...opts }); if (A.st.queue.length) autoTry(); return r; };
1776
+ const autoArrive = (items, opts = {}) => { const A = AO.current; const r = noteArrivals(A.st, items, { skipIds: autoSkip(opts.orgId ? [] : items), ...opts }); if (A.st.queue.length) autoTry(); return r; };
1670
1777
  // Other orgs: read an org's open items only when its count changed (the counts helper caches 60s), with the user's own token.
1671
1778
  const autoOthers = async (v) => {
1672
1779
  const A = AO.current; const first = !A.othersDone; A.othersDone = true;
@@ -1771,7 +1878,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1771
1878
  try { const pl = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: pl }); R.current.shownIds = pl.map(x => x.id); n = pl.findIndex(x => x.id === it.id) + 1; }
1772
1879
  catch (e) { say(`Could not re-read approvals (${clip(String(e?.message || e), 80)}); nothing was sent. Say the choice again.`, 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1773
1880
  }
1774
- if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
1881
+ if (!n) { const was = answeredInline(it); say(was ? `Already ${was === 'approve' ? 'approved' : 'denied'} (you answered it inline). Moving on.` : `"${it.title}" is no longer waiting (answered or expired). Moving on.`, was ? 'ok' : 'warn'); W.done.add(it.id); walkShow(); return true; }
1775
1882
  if (askExpiry(it) && askExpiry(it) <= Date.now()) { W.done.add(it.id); (W.dropped ||= new Set()).add(it.id); refreshNotify(); say(expiredText(it), 'accent'); walkShow(); return true; }
1776
1883
  if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
1777
1884
  const o = it.payload.options.find(x => x.key === rep.key);
@@ -1798,14 +1905,29 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1798
1905
  if (!card.inboxItemId && card.approvalId && !batch) { // an event card that never learned its inbox id: find it, never answer null
1799
1906
  try { card = { ...card, inboxItemId: (await api.approvals()).find(i => (i.payload?.approval_id || i.approval_id || i.aee_correlation_id) === card.approvalId)?.id || null }; } catch { /* falls through to the 404 path */ }
1800
1907
  }
1801
- await respondWithStepUp(card, action, { proactive, reason, bulk });
1908
+ const res = await respondWithStepUp(card, action, { proactive, reason, bulk });
1909
+ // A 2xx is not a recorded yes on its own: the server reports the card's real status (an expired approval comes
1910
+ // back as status expired, nothing ran). Only "resolved" lets this window say approved.
1911
+ const st = action === 'approve' && res && typeof res === 'object' ? res.item?.status : null;
1912
+ if (st && st !== 'resolved') {
1913
+ const text = st === 'expired'
1914
+ ? `Not approved: ${res.message || 'this approval had expired, so nothing ran. Ask the agent again to get a fresh one.'}`
1915
+ : `Your approval was NOT recorded: the server still shows it ${st}. Nothing ran. Answer it again from /approvals.`;
1916
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, batch, notRecorded: text });
1917
+ return { notRecorded: true };
1918
+ }
1919
+ if (card.inboxItemId) { walkAnsweredIds().add(card.inboxItemId); inlineAnswered().ids.set(card.inboxItemId, action); } // POPUP-DEDUPE
1920
+ if (card.approvalId) inlineAnswered().approvals.set(card.approvalId, action);
1802
1921
  // The demo's stream carries on to its own tool result, so no "running, result later" note, no poll, no stale count.
1803
- dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
1922
+ const consent = action === 'approve' && card.consent && api.grantContactability ? card.consent : null;
1923
+ if (consent) R.current.consentAsk = { ...consent, at: Date.now() }; // the next line typed is the lawful basis (or n)
1924
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch, consent });
1804
1925
  if (demoLabel) { B.current = initialBuffer(); dispatch({ type: 'notify', patch: { approvals: 0, error: null } }); } // (a stray early keystroke is not left in the composer)
1805
- if (action === 'approve' && card.tool !== 'hat.switch' && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1926
+ if (action === 'approve' && card.tool !== 'hat.switch' && !consent && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1806
1927
  return { ok: true };
1807
1928
  } catch (e) {
1808
1929
  if (e?.status === 404 || (e?.status === 400 && /not open for response/i.test(`${e?.code || ''} ${e?.body?.error || ''} ${e?.message || ''}`))) { // already resolved elsewhere (another window, /approve all, the dashboard)
1930
+ if (card.inboxItemId) walkAnsweredIds().add(card.inboxItemId); // POPUP-DEDUPE: handled elsewhere is never popped up again
1809
1931
  if (!batch) dispatch({ type: 'approval.gone', callId: card.callId, inboxItemId: card.inboxItemId || null });
1810
1932
  return { gone: true };
1811
1933
  }
@@ -1938,6 +2060,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1938
2060
  const open = (signal) => api.stream({ turnId, message: withShared(queued ? QUEUED_FRAME + text : text, shared), agent: via || SN.current.agent || 'quox', conversationId: S.current.ctx.conversationId, ...(handoffFrom ? { handoffFrom } : {}), heldSlot: where.current.slot ? { name: where.current.slot, session: slotSession(), display: myDisplay() } : null, context: turnContext(), overrideId: takeOverride(), ...(MD.current === 'plan' ? { mode: 'plan' } : {}), ...streamFields(MC.current) }, signal);
1939
2061
  // QUOXON-FW3: the server was unreachable before it said anything (a redeploy). Keep the message, wait for it
1940
2062
  // to come back, and send once. Only a send that produced NO event is ever retried, so nothing runs twice.
2063
+ // Owner 2026-11-07: a "down" send can still have REACHED the server (it registered the turn_id, then the socket
2064
+ // dropped before any event). The resend keeps the same id; if the server says it already holds that id, attach to
2065
+ // that turn and show its answer. If it cannot be shown, the text goes back to the composer (a re-send there mints
2066
+ // a new id), never a bare "turn_id already ran" with the message lost.
2067
+ const resend = async function* (signal) {
2068
+ const it = open(signal)[Symbol.asyncIterator]();
2069
+ let first;
2070
+ try { first = await it.next(); } catch (e) { if (!isTurnIdTaken(e)) throw e; first = null; }
2071
+ if (first && !(first.done === false && isTurnIdTaken(first.value))) {
2072
+ if (first.done) return;
2073
+ yield first.value;
2074
+ for (let n = await it.next(); !n.done; n = await it.next()) yield n.value;
2075
+ return;
2076
+ }
2077
+ if (first) await it.return?.();
2078
+ const conversationId = S.current.ctx.conversationId;
2079
+ const held = Object.assign(new Error('turn held by the server but not attachable'), { code: 'turn_held_unattachable' });
2080
+ if (!conversationId || !api.reattach) throw held;
2081
+ let seen = false;
2082
+ try { for await (const ev of api.reattach({ conversationId, turnId, after: 0 }, signal)) { seen = true; yield ev; } }
2083
+ catch (e) { if (!seen && (e?.status === 404 || e?.status === 410)) throw held; throw e; }
2084
+ };
1941
2085
  let r = await consumeTurn(turnId, open, { keepOnDown: true });
1942
2086
  if (!r?.down) return r;
1943
2087
  say(SERVER_RESTARTING, 'warn');
@@ -1951,7 +2095,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1951
2095
  edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
1952
2096
  return say(res === 'cancelled' ? 'Cancelled. Your message is back in the composer; it was not sent.' : 'The Quox server did not come back in time. Your message is back in the composer; it was not sent.', 'warn');
1953
2097
  }
1954
- r = await consumeTurn(turnId, open, { keepOnDown: true });
2098
+ r = await consumeTurn(turnId, resend, { keepOnDown: true });
2099
+ if (r?.held) {
2100
+ dispatch({ type: 'turn.deferred' });
2101
+ edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
2102
+ return say(HELD_NOT_SHOWN, 'warn');
2103
+ }
1955
2104
  }
1956
2105
  if (r?.down) { dispatch({ type: 'turn.deferred' }); edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text }); say('The server keeps dropping the connection. Your message is back in the composer; it was not sent.', 'warn'); }
1957
2106
  return r;
@@ -2026,7 +2175,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2026
2175
  dispatchEv({ type: 'turn.begin', turnId });
2027
2176
  const ctrl = new AbortController();
2028
2177
  R.current.abort = ctrl;
2029
- let offered = false, sawEvent = false, down = false;
2178
+ let offered = false, sawEvent = false, down = false, held = false;
2030
2179
  let answerText = '', suggested = false, gate = null; // the safety net reads the answer when no suggestion came
2031
2180
  try {
2032
2181
  for await (const ev of open(ctrl.signal)) {
@@ -2065,7 +2214,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2065
2214
  dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
2066
2215
  }
2067
2216
  } catch (e) {
2068
- if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
2217
+ if (keepOnDown && !sawEvent && !ctrl.signal.aborted && (isServerDown(e) || e?.code === 'turn_held_unattachable')) { down = true; held = e?.code === 'turn_held_unattachable'; }
2069
2218
  else if (!ctrl.signal.aborted) {
2070
2219
  // FW4: settle in-flight tools explicitly (never leave a row "running"), then explain the cut with a way forward.
2071
2220
  if (e?.status === 423 && typeof e?.body?.block_id === 'string') { // QUOXON-STEPUP: a policy block; a person may override it once
@@ -2118,7 +2267,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2118
2267
  const ra = R.current.reask; R.current.reask = null; // y/N/c: the chat about an ask is over: the same ask again, fresh window
2119
2268
  if (ra && !newOffer && askOpen()) { touchAsks(); if (ra.redo) ra.redo(); else say(ra.text, 'ask'); }
2120
2269
  }
2121
- return down ? { down: true } : undefined;
2270
+ return down ? { down: true, ...(held ? { held: true } : {}) } : undefined;
2122
2271
  }
2123
2272
 
2124
2273
  React.useEffect(() => {
@@ -2166,6 +2315,38 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2166
2315
  bump(n => n + 1);
2167
2316
  return true;
2168
2317
  }
2318
+ // MORNINGREPORT-ALWAYS (owner 2026-11-09): with nothing held (DND off) /morningreport still digests what happened since
2319
+ // you were last here (the same since-marker as /away): approvals, slot messages to you, sessions that need you,
2320
+ // incidents, then the /away card (agent actions). Reuses /sitrep, the held slot's messages and /away; never a new path.
2321
+ async function sinceDigest() {
2322
+ const p = parseSince('', { lastSeen: AWAY.current.last }); const sMs = Date.parse(p.since);
2323
+ const part = (fn) => (fn ? Promise.resolve().then(fn).then((v) => ({ ok: true, v }), (e) => ({ ok: false, error: e?.status ? `HTTP ${e.status}` : e?.message || 'failed' })) : Promise.resolve(null));
2324
+ const slot = where.current.slot || null;
2325
+ const [sit, apl, sl, aw] = await Promise.all([
2326
+ part(api.sitrep && (() => api.sitrep())),
2327
+ part(!api.sitrep && api.approvals && (() => api.approvals())),
2328
+ part(slot && api.slotMessages && (() => api.slotMessages(slot, 50))),
2329
+ part(api.away && (() => api.away(p.since)))
2330
+ ]);
2331
+ const blind = [];
2332
+ const sd = sit?.ok ? sitrepData(sit.v?.situation, sit.v?.approvals) : null;
2333
+ if (sit && !sit.ok) blind.push(`sitrep (${sit.error})`);
2334
+ let approvals = sd?.approvals || null;
2335
+ if (!approvals && apl?.ok) approvals = (apl.v || []).map((a) => ({ title: a.title || a.summary || a.id }));
2336
+ if (!approvals) approvals = [...S.current.approvals, ...S.current.waiting].map((a) => ({ title: a.title || a.tool || a.callId }));
2337
+ const label = myLabel();
2338
+ const slots = sl?.ok ? (sl.v || []).map((m) => ({ ...m, ts: Date.parse(m.created_at || m.ts) }))
2339
+ .filter((m) => Number.isFinite(m.ts) && m.ts >= sMs && !(label && m.from === label) && !isOwnBroadcast(m, { label, display: myDisplay(), userId: api.userId?.() || null })) : [];
2340
+ if (sl && !sl.ok) blind.push(`slot ${slot} (${sl.error})`);
2341
+ const sessions = [...new Set([...(R.current.roster.allRows || []).filter((x) => x.state === 'waiting').map((x) => String(x.name || x.key || 'a session')), ...(sd?.needs || []).map((n) => String(n.title))])];
2342
+ const incidents = aw?.ok ? (aw.v?.items || []).filter((it) => { const t = Date.parse(it.ts); return (!Number.isFinite(t) || t >= sMs) && (it.kind === 'alert' || /_failed$/.test(String(it.kind || '')) || isIncident(it)); }) : [];
2343
+ for (const [t, tone] of sinceLines({ approvals, slots, sessions, incidents })) say(t, tone);
2344
+ if (blind.length) say(`Could not read: ${blind.join(', ')}. Nothing is assumed there.`, 'warn');
2345
+ if (aw?.ok) dispatch({ type: 'object', obj: { type: 'away', data: aw.v } });
2346
+ else if (aw) say(`away: could not read the digest (${aw.error}). Nothing is assumed.`, 'warn');
2347
+ else say('/away is not available in this mode.', 'warn');
2348
+ return undefined;
2349
+ }
2169
2350
  async function dndOn(until = null) {
2170
2351
  const at = Date.now(); const untilMs = until ? untilEpoch(until, at) : null;
2171
2352
  const note = dndNote(untilMs);
@@ -2504,6 +2685,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2504
2685
 
2505
2686
  // ---- QUOXON-FW8: ! commands ---------------------------------------------------------------------
2506
2687
  function bangCommand(bang, { echoed = false } = {}) {
2688
+ if (isWebPty(env)) return say(SHELL_REFUSED, 'warn'); // QUOXON web PTY: the browser terminal has no shell
2507
2689
  if (bang.kind === 'reserved') return say('!! is reserved. Use ! followed by a command, e.g. !ls or !quox inbox list.', 'warn');
2508
2690
  if (bang.kind === 'empty') return say('Usage: !<command> runs it here, !quox <args> runs this quox as you. Output stays on your screen; /share sends it to Quox.', 'warn');
2509
2691
  let plan = { cmd: bang.cmd };
@@ -2525,7 +2707,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2525
2707
  out = cleanOutput(out).replace(/\n+$/, '');
2526
2708
  if (out) dispatch({ type: 'bang.out', text: out });
2527
2709
  };
2528
- const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
2710
+ const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, columns: cols - 2, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
2529
2711
  const slow = setTimeout(() => { if (R.current.bang?.run === run && !R.current.exiting) say('still running (Ctrl+C stops it)', 'accent'); }, 5000);
2530
2712
  R.current.bang = { run, cmd };
2531
2713
  run.done.then((r) => {
@@ -3434,6 +3616,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3434
3616
  case '/org': {
3435
3617
  if (!api.orgs) return say('Org switching is not available in this mode.', 'warn');
3436
3618
  const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
3619
+ // Already authorised: the person typed this /org line themselves, or said y to an agent's suggested one (owner 2026-10-15,
3620
+ // 2026-11-06: a typed command is the authorisation, never double-approve). Read once and cleared, so it never carries over.
3621
+ const authorised = R0.preConfirmed === 'org' || R0.typedOrg === line.trim(); R0.preConfirmed = null; R0.typedOrg = null;
3437
3622
  const sub = (restArg.match(/^(\S+)\s*(.*)$/) || [])[1] || '';
3438
3623
  const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
3439
3624
  let list;
@@ -3472,7 +3657,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3472
3657
  if (!p) return say('No previous org in this session yet. /org lists yours.', 'warn');
3473
3658
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
3474
3659
  R0.orgPending = { id: p.id, name: p.name, at: Date.now() };
3475
- if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
3660
+ if (authorised) return command('/org yes');
3476
3661
  return ask(`Switch back to ${p.name}?`, { action: `/org switch ${p.id} (${p.name})`, reason: 'This starts a new conversation in that org.' });
3477
3662
  }
3478
3663
  if (sub !== 'switch') return say('Usage: /org, /org switch <name or id>, or /org back', 'warn');
@@ -3483,8 +3668,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3483
3668
  if (!m.org) return say(`No org of yours matches "${query}". /org lists them.`, 'warn');
3484
3669
  if (m.org.id === curId) return say(`Already in ${m.org.name}.`);
3485
3670
  R0.orgPending = { id: m.org.id, name: m.org.name, at: Date.now() };
3486
- // Already said yes to the suggested "/org switch X? y/N": one confirmation, not two (owner 2026-10-15).
3487
- if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
3671
+ if (authorised) return command('/org yes'); // typed, or a y to the suggestion: one confirmation at most, never two
3488
3672
  return ask(`Switch to ${m.org.name}?`, { action: `/org switch ${m.org.id} (${m.org.name})`, reason: 'This starts a new conversation in that org.' });
3489
3673
  }
3490
3674
  case '/todo': case '/todos': {
@@ -3681,7 +3865,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3681
3865
  const held = (c) => Boolean(c && ((c.inbox?.length || 0) + (c.slots?.length || 0)));
3682
3866
  if (!win || (!AWAY.current.afk && !held(win.col))) { // nothing held in the last window: same since-last-seen digest as /away, honest note first
3683
3867
  say(win ? 'Nothing was held in your last do not disturb window. Here is what happened since you were last here.' : 'Nothing was held: do not disturb was off. Here is what happened since you were last here.', 'dim');
3684
- return command('/away');
3868
+ return sinceDigest();
3685
3869
  }
3686
3870
  const col = win.col;
3687
3871
  const approvals = Math.max(S.current.notify?.approvals || 0, [...S.current.approvals, ...S.current.waiting].length);
@@ -3727,8 +3911,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3727
3911
  return say(`✔ noted for the observer (quoxon${r.viaOrgName ? ` in ${r.viaOrgName}` : ''})`, 'ok');
3728
3912
  }
3729
3913
  case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
3730
- const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
3731
- if (!m) return say(`Usage: ${cmd} <slot or session> [text] (an urgent page; they are asked to acknowledge, and an idle session is woken to read it, even with an unsent draft, which is set aside and put back)`, 'warn');
3914
+ const m = parsePokeArgs(restArg, restArg.trim().includes(' ') ? await slotNames(api) : null); // FORCE-WAKE: --force is a no-op, never page text; TUI-WAKE-MULTI: a,b,c or a b c pages every one
3915
+ if (!m) return say(`Usage: ${cmd} <slot>[,<slot>...] [text] (several slots: a,b,c or a b c; an urgent page; they are asked to acknowledge, and an idle session is woken to read it, even with an unsent draft, which is set aside and put back)`, 'warn');
3732
3916
  return command(`/page ${m.slot} ${m.text ? m.text.trim() : `are you there? (${cmd.slice(1)} from ${myLabel() || 'the owner'})`}`);
3733
3917
  }
3734
3918
  case '/away': {
@@ -3764,13 +3948,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3764
3948
  catch (e) { return dispatch({ type: 'error', text: `Could not read the inbox (${e.message}). That does not mean it is empty; try again or run: quox inbox list` }); }
3765
3949
  const top = newestFirst(items).slice(0, 10);
3766
3950
  R.current.inboxList = top; R.current.inboxSeen = arrivals(items, R.current.inboxSeen || null).ids;
3767
- dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
3951
+ const barText = () => inboxBarText(newestFirst(items).slice(0, INBOX_BAR_READ), cols, barOpts()); // TUI-CARD-SYNC: the poll's basis, never a 30-item count
3952
+ dispatch({ type: 'notify', patch: { inboxBar: barText() } });
3768
3953
  if (q.kind === 'mutes') { for (const l of mutesLines(inboxMutes())) say(l.text, l.tone); return undefined; }
3769
3954
  if (q.kind === 'mute' || q.kind === 'unmute') {
3770
3955
  const key = q.source.toLowerCase(); const next = { ...inboxMutes() };
3771
3956
  if (q.kind === 'mute') next[key] = Date.now() + q.ms; else if (!next[key]) return say(`${q.source} is not muted. ${mark('/inbox mutes')} lists them.`, 'warn'); else delete next[key];
3772
3957
  if (api.saveSetting) api.saveSetting('inboxMutes', next); else (R.current.sessionSettings ||= {}).inboxMutes = next;
3773
- dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
3958
+ dispatch({ type: 'notify', patch: { inboxBar: barText() } });
3774
3959
  return say(q.kind === 'mute' ? `✔ muted ${q.source} for ${Math.round(q.ms / 360000) / 10}h: no arrival lines, out of the bar count, still listed in /inbox. Approvals are never muted. ${mark(`/inbox unmute ${q.source}`)} undoes it.` : `✔ unmuted ${q.source}`, 'ok');
3775
3960
  }
3776
3961
  const pick = (n) => { const it = top[n - 1]; if (!it) say(`No item ${n}: the list has ${top.length}. ${mark('/inbox')} shows it.`, 'warn'); return it; };
@@ -4157,7 +4342,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4157
4342
  if (verb === 'approve' && privOf(it) && !(confirm && !all && !agent)) { heldPriv.push(k); continue; }
4158
4343
  try {
4159
4344
  const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
4160
- const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
4345
+ const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null, consent: bulkish || targets.length > 1 ? null : consentOf(it) }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
4161
4346
  if (r?.gone) gone.push(k); else if (r?.ok) { done.push(k); R.current.lastAnswered = it.id; }
4162
4347
  } catch (e) {
4163
4348
  failed.push(`[${k}] ${e.message}`);
@@ -4283,13 +4468,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4283
4468
  }
4284
4469
  const st = bind.get(cd.id);
4285
4470
  if (askStateOpen(st)) continue;
4471
+ if (st && typeof st === 'object' && st.walk && st.walkWhy !== 'next' && st.walkWhy !== 'done') { // a walkthrough card that did not close normally: its line comes from the server
4472
+ if (!st.walkWhy) { st.walkWhy = 'lapsed'; st.at = now; walkLapsed(st); } // TUI-CARD-SYNC: left open past its window; never "expired" before the server says so
4473
+ if (st.settleText == null && now - (st.at ?? now) < 15000) continue; // still checking
4474
+ bind.delete(cd.id); takeAnswer();
4475
+ if (st.settleText) dispatch({ type: 'ask.settle', id: cd.id, text: st.settleText, tone: st.settleTone || 'plain' });
4476
+ else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
4477
+ continue;
4478
+ }
4286
4479
  bind.delete(cd.id);
4287
4480
  const expired = st && typeof st === 'object' ? now - (st.at ?? 0) >= ASK_TTL : false;
4288
4481
  if (expired && st === SC.current.reattach && st.slot) R.current.declinedSlot = st.slot; // an unanswered reattach counts as a decline across the auto-restart of this window
4289
4482
  if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
4290
4483
  takeAnswer();
4291
- if (st.walkWhy === 'stop') dispatch({ type: 'ask.settle', id: cd.id, text: 'The question closed before you answered.', tone: 'plain' });
4292
- else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
4484
+ dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
4293
4485
  continue;
4294
4486
  }
4295
4487
  const outcome = expired ? { kind: 'expired' } : takeAnswer() || { kind: st === 'any' || st === 'kc' || st === 'uw' ? 'expired' : 'closed' };
@@ -4610,6 +4802,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4610
4802
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { if (R.current.bang) say('A command is still running. Ctrl+C stops it.', 'warn'); else bangCommand(a.bang); bump(n => n + 1); return; }
4611
4803
  if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
4612
4804
  }
4805
+ if (R.current.consentAsk && !text.startsWith('/')) { // "needs the lawful basis: type it now" after approving a contact-consent card
4806
+ const a = R.current.consentAsk; R.current.consentAsk = null;
4807
+ const basis = text.trim();
4808
+ if (!basis || /^no?$/i.test(basis) || Date.now() - a.at > 600000) { say(`No consent recorded. Contact with ${a.recipient} via ${a.channel} stays denied by default.`, 'dim'); return; }
4809
+ const pr = a.proposed || {};
4810
+ const body = { recipient: a.recipient, channel: a.channel, basis };
4811
+ for (const k of ['purpose', 'quiet_start', 'quiet_end', 'tz_offset_minutes', 'min_interval_minutes', 'max_per_day']) if (pr[k] !== undefined && pr[k] !== null && pr[k] !== '') body[k] = pr[k];
4812
+ (async () => {
4813
+ try {
4814
+ const r = await api.grantContactability(body);
4815
+ if (!r || r.granted !== true) { say(`Consent NOT recorded for ${a.recipient} via ${a.channel}: the server did not confirm it.`, 'warn'); return; }
4816
+ say(`Consent recorded: ${a.recipient} via ${a.channel}, basis "${basis}". Evidence: consent artifact ${r.consent_artifact_id || '(no id returned)'}.`, 'ok');
4817
+ } catch (e) { say(`Consent NOT recorded for ${a.recipient} via ${a.channel} (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.body?.message || e?.message || 'failed'}).`, 'warn'); }
4818
+ })();
4819
+ return;
4820
+ }
4613
4821
  if (R.current.hatPending && !text.startsWith('/')) { // "Put on the NSOC hat? y/N" from /hat: y switches (/hat yes still works)
4614
4822
  const a = R.current.hatPending;
4615
4823
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/hat yes'); return; }
@@ -4672,7 +4880,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
4672
4880
  }
4673
4881
  { const ls = R.current.listShownAt; R.current.listShownAt = null; // APPROVALS-UX: y/n right after a list (not a card) is not chat to the agent
4674
4882
  if (ls && Date.now() - ls < 600000 && /^(y(es)?|no?)$/i.test(text.trim())) { remember(text); R.current.histIdx = -1; say('Which one? /approve 1 or /approvals 1 to see it.', 'warn'); R.current.listShownAt = ls; return; } }
4675
- if (isOneLineCommand(text)) { PL.current.submitted(buf.text); remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
4883
+ if (isOneLineCommand(text)) { if (/^\/org\s+(switch\s+\S|back\s*$|-\s*$)/i.test(text.trim())) R.current.typedOrg = text.trim(); PL.current.submitted(buf.text); remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
4676
4884
  if (!SC.current.active && BARE_COMMAND_WORDS.has(text.toLowerCase()) && findEntry('/' + text.toLowerCase())?.kind === 'command') { // owner 2026-10-26 18:22: "clear" went to the model as chat. Offer the command BEFORE anything is sent; n sends it as chat.
4677
4885
  const line = '/' + text.toLowerCase();
4678
4886
  remember(text); R.current.histIdx = -1;
@@ -5024,6 +5232,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
5024
5232
  return edit({ type: 'down', width: composerWidth() });
5025
5233
  }
5026
5234
  case 'ctrl-g': {
5235
+ if (isWebPty(env)) { say(SHELL_REFUSED, 'warn'); return; } // QUOXON web PTY: no external editor
5027
5236
  const full = sanitizeForSend({ ...B.current }).text ?? B.current.text;
5028
5237
  mouseTerm(false); // the editor gets a plain terminal (QUOXON-MOUSE), and tracking comes back after
5029
5238
  if (isRawModeSupported) setRawMode(false);