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