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