quoxcli 0.3.1 → 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/actions.js +15 -0
- package/lib/api/endpoints/admin.js +27 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +295 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +47 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +61 -7
- package/lib/commands/approvals.js +198 -3
- package/lib/commands/ask.js +4 -2
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +142 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +7 -33
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +155 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +1123 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +318 -0
- 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 +53 -3
- package/lib/commands/offload.js +2 -3
- package/lib/commands/org.js +21 -4
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +87 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +20 -2
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +150 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +24 -5
- 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/vm.js +2 -2
- 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/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -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 +37 -3
- package/lib/core/commandRegistrars.js +17 -0
- package/lib/core/config.js +29 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +136 -8
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +73 -7
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +8 -8
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +146 -0
- package/lib/quoxon/api.js +364 -27
- package/lib/quoxon/app.js +1629 -183
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +64 -8
- package/lib/quoxon/approvalSentence.js +48 -0
- package/lib/quoxon/approvalsView.js +124 -7
- 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 +22 -3
- package/lib/quoxon/commands.js +110 -15
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +285 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +160 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +67 -10
- package/lib/quoxon/extensions.js +636 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +182 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +4 -4
- package/lib/quoxon/loopCard.js +460 -0
- package/lib/quoxon/loopCards.js +189 -0
- package/lib/quoxon/loopStream.js +98 -0
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +29 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- 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/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +49 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +8 -3
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +187 -41
- package/lib/quoxon/roster.js +36 -17
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -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/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +139 -2
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +23 -2
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +31 -4
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- 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 +34 -3
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/toolCards.js +30 -0
- 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 +81 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +4 -2
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- 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/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -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/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
package/lib/quoxon/app.js
CHANGED
|
@@ -11,19 +11,22 @@ import { mkdtempSync, writeFileSync, writeSync, readFileSync, unlinkSync } from
|
|
|
11
11
|
import { tmpdir, hostname as osHostname } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { bufferReducer, initialBuffer, sanitizeForSend, wordAt, selection, selectedText, atVisualEdge } from './textBuffer.js';
|
|
14
|
-
import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, screenHit, classifyClick, composerIndexAt } from './mouse.js';
|
|
14
|
+
import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, lineAt, MOUSE_HINT, screenHit, classifyClick, composerIndexAt } from './mouse.js';
|
|
15
15
|
import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
|
|
16
16
|
import { isHeldFromBulk, heldReason } from './bulkRisk.js';
|
|
17
|
-
import {
|
|
17
|
+
import { isDestructive, destructiveObject } from './destructive.js';
|
|
18
|
+
import { batchFor, batchModel, batchLabel, collapseArrivals } from './approvalBatch.js';
|
|
19
|
+
import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, approvalAsWalkItem, parseAnswer, parsePairs, needsPin } from './approvalsView.js';
|
|
20
|
+
import { createAutoOpen, noteArrivals, openDecision, takeQueue, strayAnswer, parseApprovalsArg, autoOn, APPROVALS_OPTIONS } from './autoOpen.js';
|
|
18
21
|
import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
|
|
19
|
-
import { reduce, initialState, approvalKeyAllowed, groupApprovals, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
|
|
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, 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,19 +36,27 @@ 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';
|
|
41
|
+
import { objectiveFromEvent, objectiveAskText, objectiveStartedNote, objectiveWatchLine, objectiveFailedNote, watchObjective } from './objectiveOffer.js';
|
|
37
42
|
import { createHandoffStreak, handoffBiggerAskText, handoffFromEvent, handoffAskText, handoffAnsweredNote, handoffSupersededNote, handoffSkippedNote } from './handoffOffer.js';
|
|
38
43
|
import * as selfUpdate from '../core/selfUpdate.js';
|
|
44
|
+
import { parseDone, todoDoneLine, parseTodo, todoAddedLine, todoBoardText, numberBoard, resolveRef, todoMovedLine } from './slash/todoCmd.js';
|
|
39
45
|
import { runRequest, runAnswer, handOverRun, runFooter } from './slotRun.js';
|
|
40
|
-
import { profileChooserRows, liveApi, switchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
|
|
41
|
-
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';
|
|
42
49
|
import { runObjectCommand } from './objects/index.js';
|
|
50
|
+
import { runEvidenceCommand } from './slash/evidenceCmd.js';
|
|
43
51
|
import { peekLines as peekIdLines } from '../core/peek.js';
|
|
44
52
|
import { turnsFromItems, asideNoteText } from './asideView.js';
|
|
45
53
|
import { createMentionCache, loadMentions, receiptMentions } from './mentions.js';
|
|
46
54
|
const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile', '/fleet', '/prove', '/replay', '/health', '/core']);
|
|
47
55
|
import { intentHint } from './intent.js';
|
|
48
56
|
import { observerLines } from './observers.js';
|
|
57
|
+
import { runWithSpinner, spinnerVerb } from './slashSpinner.js';
|
|
58
|
+
import { parseMail, mailTable, mailDetail, noMailboxLines, mailNewestFirst, MAIL_USAGE } from './mailView.js';
|
|
59
|
+
import { inboxBarText, arrivals, loadSeen, withSeen, arrivalLine, inboxTable, announceFresh, activeMutes, mutesLines, inboxDetail, parseInbox, INBOX_USAGE, newestFirst, unreadItems, readItems, isApprovalItem, isMuted } from './inboxView.js';
|
|
49
60
|
import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
|
|
50
61
|
import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
|
|
51
62
|
import { packageVersion } from './welcome.js';
|
|
@@ -53,9 +64,10 @@ import { feedbackPayload, feedbackPreview } from './feedback.js';
|
|
|
53
64
|
import { vetSuggestion, namedCommand } from './suggestCommand.js';
|
|
54
65
|
import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // PLAN MODE (owner 2026-10-16)
|
|
55
66
|
import { echoLine } from './echo.js';
|
|
56
|
-
import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
|
|
67
|
+
import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, looksLikeAddress, sayWhoText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
|
|
57
68
|
import { pushHistory } from './promptHistory.js';
|
|
58
|
-
import {
|
|
69
|
+
import { secretKinds, redactSecrets } from './secretShape.js';
|
|
70
|
+
import { termRows, liveCols } from './term.js';
|
|
59
71
|
import { cardTallerThanScreen } from './objects/view.js';
|
|
60
72
|
import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows, confirmLanAllow } from './connect.js';
|
|
61
73
|
import { ConnectCard } from './connectCard.js';
|
|
@@ -64,15 +76,16 @@ import { createLoginController, LoginCard, SIGNED_OUT_LINE, signedOutCard, isSes
|
|
|
64
76
|
import { createStepUp } from './slash/stepUp.js';
|
|
65
77
|
import { StepUpCard, PresentBar } from './slash/stepUpCard.js';
|
|
66
78
|
import { createStepUpCmds, declinedText } from './slash/stepUpCmds.js';
|
|
79
|
+
import { createConfirm } from './slash/confirm.js';
|
|
67
80
|
import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
|
|
68
|
-
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';
|
|
69
82
|
import { humanSessionLines } from '../core/sessionsOverview.js';
|
|
70
83
|
import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
|
|
71
84
|
import { capabilityLines, offerFor, findCapability } from './capabilities.js';
|
|
72
85
|
import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
|
|
73
|
-
import { DragonLive, playDragon, dragonActive, resetDragon } from './qmotion.js';
|
|
86
|
+
import { DragonLive, playDragon, dragonActive, resetDragon, clipRoom, flyRoom } from './qmotion.js';
|
|
74
87
|
import { AUTOPLAY, AUTOPLAY_REQUEST, AUTOPLAY_PROOF, typingPlan } from './demo.autoplay.js';
|
|
75
|
-
import { motionLevel, levelFeatures,
|
|
88
|
+
import { motionLevel, levelFeatures, ignitionGlow, normaliseLevel } from './motion/level.js';
|
|
76
89
|
import { CLIPS as DRAGON_CLIPS } from './qdragonScenes.js';
|
|
77
90
|
import { DRAGON_USAGE, DRAGON_EXPLAINS, DRAGON_ANSWERS, NOTHING_TO_EXPLAIN, ASK_PROMPT, WAKE_MS, explainPrompt, askPrompt, lastOutput, parseDragonArg, sourceLines } from './dragonModes.js';
|
|
78
91
|
import { SleepScreen, DragonTalk, FlyLive } from './dragonView.js';
|
|
@@ -85,13 +98,22 @@ import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
|
|
|
85
98
|
import { parseBroadcastText } from './slash/broadcast.js';
|
|
86
99
|
import { isOwnBroadcast } from './ownBroadcast.js';
|
|
87
100
|
import { orgLabel, isOrgId } from './orgLabel.js';
|
|
88
|
-
import {
|
|
101
|
+
import { enrichPlanItem } from './planSteps.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';
|
|
89
103
|
import { homedir as osHome } from 'node:os';
|
|
90
104
|
import { createRowCmd } from './slash/rowCmd.js';
|
|
91
105
|
import { createKillerCmds } from './slash/killer.js';
|
|
92
106
|
import { createUndoWish } from './slash/undoWish.js';
|
|
93
107
|
import { createHatWalk } from './slash/hatWalk.js';
|
|
94
108
|
import { createRowEngine, runShell } from './rows.js';
|
|
109
|
+
import { qpeCommand, qpeRowsSetting, QPE_GRID } from './slash/qpeCmd.js';
|
|
110
|
+
import { deckOffer, runDeckOffer, declineText } from './deckLazy.js';
|
|
111
|
+
import { createExtensionHost, pickDraft as deckPickDraft, readExtList, extSetEnabled, extAdd, panelKeyName } from './extensions.js';
|
|
112
|
+
import { createLoopCards, rowsWithoutLoopCards } from './loopCards.js';
|
|
113
|
+
import { initLoop, applySnapshot as applyLoopSnapshot, cardLines } from './loopCard.js';
|
|
114
|
+
import { createCompanionController, companionCommand, DragonCompanionLive, takeGreeting, detachGreeting } from './dragonCompanion.js';
|
|
115
|
+
import { dragonForAgent } from '../dragon/select.js';
|
|
116
|
+
import { createDragonJobs } from '../dragon/jobs.js';
|
|
95
117
|
import { createModelCmd } from './slash/modelCmd.js';
|
|
96
118
|
import { createChallengeCmd } from './slash/challenge.js';
|
|
97
119
|
import { createSpawnCmd } from './slash/spawn.js';
|
|
@@ -101,15 +123,17 @@ import { createLedger, costModel } from './slash/ledger.js';
|
|
|
101
123
|
import { statusModel, contextModel, doctorModel, chatChecks, modelText } from './slash/panels.js';
|
|
102
124
|
import { parseAgentsArgs, filterAgents, agentPickerRow, toolLines, whichReply, looseCommand } from './slash/agents.js';
|
|
103
125
|
import { createPicker } from './slash/picker.js';
|
|
104
|
-
import { resumeRows, pickerRow, matchConversation, turnsToHistory } from './slash/resume.js';
|
|
126
|
+
import { resumeRows, pickerRow, matchConversation, turnsToHistory, cleanTitle, titleFromTurns } from './slash/resume.js';
|
|
105
127
|
import { settingsList, applySettings, configRows, cycle } from './slash/settings.js';
|
|
106
128
|
import { countdownLine } from './countdownLine.js';
|
|
107
|
-
import { startBuildWatch, DIFFERENT_NOTICE } from './slash/restart.js';
|
|
129
|
+
import { startBuildWatch, DIFFERENT_NOTICE, waitForHealthy, SERVER_WAIT_EVERY_MS, SERVER_WAIT_MAX_MS } from './slash/restart.js';
|
|
108
130
|
import { dashboardLines } from './slash/dashboard.js';
|
|
109
131
|
import { supportsHyperlinks } from './markdown.js';
|
|
110
132
|
import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
|
|
111
133
|
import { intentPanel } from './intent.js';
|
|
112
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';
|
|
113
137
|
import { sitrepData } from './objects/sitrep.js';
|
|
114
138
|
import { mirrorErrorNote } from './objects/mirror.js';
|
|
115
139
|
import { launchEgg } from './egg.js';
|
|
@@ -118,11 +142,17 @@ import { Clock, FRAME_MS, SECOND_MS, motionAllowed, createFrames } from './motio
|
|
|
118
142
|
import { FrameContext, useFrame } from './motion/frame.js';
|
|
119
143
|
import { ringMode, ringAnimating } from './motion/ring.js';
|
|
120
144
|
import { TIMING, revealSpan } from './motion/reveal.js';
|
|
121
|
-
import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
|
|
145
|
+
import { matchOrg, orgLines, orgRows, orgCandidates } from './orgSwitch.js';
|
|
146
|
+
import { INSTALL_TOOLS, SUGGEST_LINE, planLine, offerQuestion, runInstall, missingTools } from './installCli.js';
|
|
122
147
|
import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
|
|
148
|
+
import { slotOutbox, isConnectivityFailure, queuedLine, sentLine, fullLine } from './slotOutbox.js';
|
|
123
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 │';
|
|
124
153
|
|
|
125
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
|
|
126
156
|
const PASTE_GUARD_MS = 40;
|
|
127
157
|
const ASK_ECHO_MS = 1500; // TUI-INPUT: how long an Enter after a single-key ask answer is treated as the confirm habit
|
|
128
158
|
const MAX_ROSTER = 4;
|
|
@@ -156,8 +186,14 @@ export const friendlyError = (e, profile) =>
|
|
|
156
186
|
const byId = (id) => CHOICES.find(x => x.id === id) || null;
|
|
157
187
|
// the status line shows the new ask until the server answers
|
|
158
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));
|
|
159
192
|
|
|
160
|
-
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 }) {
|
|
161
197
|
// Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
|
|
162
198
|
// one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
|
|
163
199
|
// PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
|
|
@@ -183,11 +219,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
183
219
|
const exit = (afterMs = 0) => {
|
|
184
220
|
const R0 = R.current;
|
|
185
221
|
if (R0.exiting) return undefined;
|
|
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
|
|
186
224
|
R0.exiting = true; R0.sleep = null; R0.fly = null;
|
|
187
225
|
R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
|
|
188
226
|
resetDragon();
|
|
189
227
|
bump(n => n + 1);
|
|
190
|
-
return setTimeout(() => inkExit(), 60 + afterMs);
|
|
228
|
+
return setTimeout(() => { Promise.resolve(extClosing).then(() => inkExit(), () => inkExit()); }, 60 + afterMs);
|
|
191
229
|
};
|
|
192
230
|
const [epoch, setEpoch] = React.useState(0);
|
|
193
231
|
|
|
@@ -232,6 +270,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
232
270
|
// Drop-in reveals: history items enter <Static> only once their reveal has ended (at most ~1 s); until then
|
|
233
271
|
// they render in the live region. committedRef = how many history items <Static> has been given.
|
|
234
272
|
const committedRef = React.useRef(introAnimating ? 0 : S.current.history.length);
|
|
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)
|
|
235
274
|
const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
|
|
236
275
|
const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
|
|
237
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 });
|
|
@@ -240,7 +279,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
240
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 } });
|
|
241
280
|
// QUOXON-HISTORY: Up recalls what was typed in earlier runs too (most recent first); main.js passes the file store.
|
|
242
281
|
if (!R.current.histLoaded) { R.current.histLoaded = true; try { R.current.history = historyStore?.load?.() || []; } catch { R.current.history = []; } }
|
|
243
|
-
|
|
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 */ } } };
|
|
244
284
|
// E5: when this profile was last seen (read once at launch, before it is refreshed), and a throttled touch.
|
|
245
285
|
const AWAY = React.useRef({ last: null, touched: 0 });
|
|
246
286
|
const touchSeen = (force = false) => {
|
|
@@ -252,10 +292,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
252
292
|
|
|
253
293
|
const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
|
|
254
294
|
/** QUOXON-FW15: the one card shown: the oldest waiting approval (this conversation first, then other sessions/plugins) not dismissed with Tab. */
|
|
255
|
-
const queueOf = (st) => { const all = [...st.approvals, ...st.waiting]; const live = new Set(all.map(x => x.callId)); for (const id of K.current.dismissed) if (!live.has(id)) K.current.dismissed.delete(id); return all.filter(x => !K.current.dismissed.has(x.callId)); };
|
|
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)); };
|
|
256
296
|
const headCard = (st) => queueOf(st)[0] || null;
|
|
257
297
|
const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
|
|
258
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; }
|
|
259
302
|
if (a.type === 'note') SCX.current.notice(a.text, a.tone);
|
|
260
303
|
else if (a.type === 'error') SCX.current.error(a.text);
|
|
261
304
|
else if (a.type === 'event' && a.ev) {
|
|
@@ -263,8 +306,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
263
306
|
else if (a.ev.type === 'turn.finished' && a.ev.status && !['ok', 'success', 'completed'].includes(a.ev.status)) SCX.current.failedTurn(a.ev.status);
|
|
264
307
|
}
|
|
265
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
|
|
266
|
-
const prevS = S.current; S.current = reduce(prevS, { now: Date.now(), ...a }); SND.current.observe(prevS, S.current, a);
|
|
267
|
-
if (motionOn && !mono() && feats().moods) { const m =
|
|
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)
|
|
268
312
|
// Per shown card: remember the key that was already down when it appeared, and restart the typed word.
|
|
269
313
|
const c0 = headCard(S.current);
|
|
270
314
|
if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
|
|
@@ -282,6 +326,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
282
326
|
// (never printed to scrollback while open); reconcileAsks settles it to one line once it is no longer open.
|
|
283
327
|
const askSeq = React.useRef(0);
|
|
284
328
|
const say = (text, tone) => {
|
|
329
|
+
R.current.spinStop?.(); // INSTANT-FEEDBACK: the first printed line replaces the spinner
|
|
285
330
|
if (tone !== 'ask') return dispatch({ type: 'note', text, tone });
|
|
286
331
|
const id = `ask-${++askSeq.current}`;
|
|
287
332
|
R.current.lastAsk = { text, question: askQuestion(text), id };
|
|
@@ -292,6 +337,33 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
292
337
|
};
|
|
293
338
|
// QUOXON-ASK: every question for the human is ONE shape (▶ reason. question? keys), highlighted. Never say a raw y/N.
|
|
294
339
|
const ask = (question, opts) => say(askText(question, opts), 'ask');
|
|
340
|
+
// LAZY-AI-CLI (owner 2026-10-19): when something needs Claude Code or Codex and this server lacks it, offer to
|
|
341
|
+
// install it THEN, as one y/N card (never "type this command"). The server installs the vendor's own npm package.
|
|
342
|
+
const offerInstall = async (tool) => {
|
|
343
|
+
const t = INSTALL_TOOLS[tool];
|
|
344
|
+
const plan = api.providerCliPlan ? await api.providerCliPlan(tool) : null;
|
|
345
|
+
if (!plan?.ok) {
|
|
346
|
+
if (plan?.status === 403) say(`${t.name} isn't installed on this server. An instance admin can install it from the Quox terminal.`, 'warn');
|
|
347
|
+
else if (plan?.status === 404 || plan?.status === 405 || !api.providerCliPlan) say(`${t.name} isn't installed on this server, and this server is too old to install it for you. Update the server first.`, 'warn');
|
|
348
|
+
else say(`${t.name} isn't installed on this server, and Quox could not check npm right now (${plan?.error || 'no answer'}).`, 'warn');
|
|
349
|
+
return nextInstallOffer();
|
|
350
|
+
}
|
|
351
|
+
say(planLine(plan), 'dim');
|
|
352
|
+
R.current.installAsk = { tool, plan, at: Date.now() };
|
|
353
|
+
return ask(offerQuestion(tool, plan), { action: `install ${t.name}`, reason: `${t.vendor}'s own package, downloaded from npm onto this server.` });
|
|
354
|
+
};
|
|
355
|
+
function nextInstallOffer() {
|
|
356
|
+
const q = R.current.installQueue;
|
|
357
|
+
if (!q || !q.length) return undefined;
|
|
358
|
+
return offerInstall(q.shift());
|
|
359
|
+
}
|
|
360
|
+
async function installNow(a) {
|
|
361
|
+
say(`◐ installing ${INSTALL_TOOLS[a.tool].name} ${a.plan?.version || ''} on the server (a minute or two)`.trim(), 'dim');
|
|
362
|
+
const r = await runInstall(api, a.tool, { onProgress: (l) => say(l, 'dim') });
|
|
363
|
+
for (const l of r.lines) say(l, r.ok ? 'ok' : 'warn');
|
|
364
|
+
if (r.ok) bump(n => n + 1);
|
|
365
|
+
return nextInstallOffer();
|
|
366
|
+
}
|
|
295
367
|
// PLAN MODE: this window's mode ('ask' | 'plan'). Window-scoped: rides /restart, never the shared profile.
|
|
296
368
|
const MD = React.useRef(null); MD.current ??= normMode(mode);
|
|
297
369
|
const askerName = () => String(SN.current?.agent || where.current?.agent || 'Quox').toUpperCase(); // a model-suggested command: who asks
|
|
@@ -307,10 +379,23 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
307
379
|
const CN = React.useRef(null);
|
|
308
380
|
CN.current ??= createConnectController({ api, note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), refresh: () => bump(n => n + 1) });
|
|
309
381
|
// /login card: email (prefilled), masked password, MFA code if asked. Same login path as `quox login`.
|
|
310
|
-
|
|
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
|
|
311
396
|
const LI = React.useRef(null);
|
|
312
397
|
LI.current ??= createLoginController({ api, note: (t, tone) => say(t, tone), refresh: () => bump(n => n + 1), onSignedIn: () => {
|
|
313
|
-
setSignedOut(null); BL.current = blockedProp || null;
|
|
398
|
+
R.current.loginAsk = null; soNoticed.current = false; setSignedOut(null); BL.current = blockedProp || null;
|
|
314
399
|
R.current.notify = { fails: 0, authLost: false };
|
|
315
400
|
dispatch({ type: 'notify', patch: { error: null } });
|
|
316
401
|
refreshNotify(); refreshRoster();
|
|
@@ -319,9 +404,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
319
404
|
// QUOXON-STEPUP: the PIN card. The digits live in its closure box only: never dispatched, buffered, in history,
|
|
320
405
|
// undo, the transcript or any log. Agent logins never get a PIN field (slash/stepUp.js).
|
|
321
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
|
|
322
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; } } });
|
|
323
409
|
const SUC = React.useRef(null);
|
|
324
|
-
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 });
|
|
325
411
|
// L0 palette: opens under the composer while the draft is a bare /word (slash/paletteController.js).
|
|
326
412
|
const PL = React.useRef(null);
|
|
327
413
|
// QUOXON-FW6: model favourites + default (server-side user preferences; profile = cache).
|
|
@@ -355,6 +441,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
355
441
|
const SPN = React.useRef(null);
|
|
356
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 });
|
|
357
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
|
+
}, []);
|
|
358
458
|
// QUOXON-FW17: custom rows above the status line (rows.js engine, slash/rowCmd.js).
|
|
359
459
|
const RW = React.useRef(null);
|
|
360
460
|
RW.current ??= createRowCmd({
|
|
@@ -365,11 +465,68 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
365
465
|
const rowsLoaded = React.useRef(false);
|
|
366
466
|
React.useEffect(() => { if (!blocked && !demoLabel && !rowsLoaded.current) { rowsLoaded.current = true; RW.current.load(); } }, [blocked]);
|
|
367
467
|
React.useEffect(() => () => RW.current.stopEngine(), []);
|
|
468
|
+
// EXTENSIONS (extensions.js): optional npm packages listed in ~/.quox/extensions.json. They can open an approval or
|
|
469
|
+
// preselect an answer; the user confirms here. Core ships no extension-specific code.
|
|
470
|
+
const DK = React.useRef(null);
|
|
471
|
+
DK.current ??= createExtensionHost({
|
|
472
|
+
env,
|
|
473
|
+
getSettings: () => api.settings?.() || {},
|
|
474
|
+
handlers: {
|
|
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; }
|
|
482
|
+
const it = (R.current.deckItems || [])[n - 1];
|
|
483
|
+
const draft = deckPickDraft(it, n, option);
|
|
484
|
+
if (!draft) return say(`Extension: no such option "${option}" for [${n}].`, 'warn');
|
|
485
|
+
edit({ type: 'set', text: draft }); // preselected only: the answer goes when the user presses Enter
|
|
486
|
+
say(`Preselected: ${draft.trim()}. Press Enter to confirm.`, 'accent');
|
|
487
|
+
},
|
|
488
|
+
note: (t, tone) => say(t, tone),
|
|
489
|
+
changed: () => { setExtEntries(DK.current.commands()); bump(n => n + 1); }
|
|
490
|
+
},
|
|
491
|
+
log: () => {}
|
|
492
|
+
});
|
|
493
|
+
const extDispatch = async (cmd, restArg) => { // a loaded extension's own slash command; true when one handled it
|
|
494
|
+
const xr = await DK.current.runCommand(cmd, restArg, { say: (t, tone) => say(t, tone), org: orgShown(), host: DK.current });
|
|
495
|
+
if (!xr.handled) return false;
|
|
496
|
+
if (xr.error) say(`${cmd}: ${xr.error}`, 'warn'); else if (typeof xr.result === 'string') say(xr.result); else if (xr.result?.text) say(xr.result.text, xr.result.tone);
|
|
497
|
+
return true;
|
|
498
|
+
};
|
|
499
|
+
const extLoad = async (name, quiet) => { const r = await DK.current.load(name); if (!quiet || r.ok) say(r.text, r.ok ? 'ok' : 'warn'); return r; };
|
|
500
|
+
React.useEffect(() => {
|
|
501
|
+
if (!blocked && !demoLabel) for (const x of readExtList()) if (x.enabled) extLoad(x.name, true).catch(() => {});
|
|
502
|
+
return () => { DK.current.unloadAll(); };
|
|
503
|
+
}, []);
|
|
504
|
+
React.useEffect(() => { // fleet for extensions: a light 30 s poll of the existing host list, only while an extension is loaded
|
|
505
|
+
if (!DK.current.armed() || !api.mentionHosts) return undefined;
|
|
506
|
+
let dead = false;
|
|
507
|
+
const poll = () => api.mentionHosts().then((h) => { if (!dead) { R.current.fleetHosts = h; bump(n => n + 1); } }, () => { if (!dead) { R.current.fleetHosts = null; } });
|
|
508
|
+
poll(); const id = setInterval(poll, 30000);
|
|
509
|
+
return () => { dead = true; clearInterval(id); };
|
|
510
|
+
}, [DK.current.armed()]);
|
|
511
|
+
React.useEffect(() => { // push what this window already holds; the host dedupes and throttles to 1/s
|
|
512
|
+
if (!DK.current.armed()) return;
|
|
513
|
+
const rows = R.current.roster?.allRows || [];
|
|
514
|
+
DK.current.push({
|
|
515
|
+
org: { id: R.current.orgId ?? api.orgId?.() ?? null, name: orgShown() },
|
|
516
|
+
items: R.current.deckItems || [],
|
|
517
|
+
health: { collector: R.current.notify?.fails > 0 || S.current.notify?.error ? 'down' : 'ok', rows: rows.some(r => r.state === 'failed') ? 'warn' : 'ok' },
|
|
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' })),
|
|
519
|
+
fleet: R.current.fleetHosts || null,
|
|
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
|
|
522
|
+
sessions: { active: rows.filter(r => r.state === 'running').length }
|
|
523
|
+
});
|
|
524
|
+
});
|
|
368
525
|
PL.current ??= createPalette({
|
|
369
526
|
getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
|
|
370
527
|
run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
|
|
371
528
|
ctx: () => ({ api, caps: R.current.contract, blocked: BL.current }),
|
|
372
|
-
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 || [] }) }),
|
|
373
530
|
refresh: () => bump(n => n + 1),
|
|
374
531
|
// MENTIONS: agents, hosts and tasks fetched once per session on the first @; receipts are this session's own tool envelopes
|
|
375
532
|
mentions: blocked ? null : { cache: createMentionCache({ load: () => loadMentions(api), refresh: () => bump(n => n + 1) }), local: () => receiptMentions([...S.current.history, ...S.current.blocks]) }
|
|
@@ -378,33 +535,80 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
378
535
|
flagged: modelChoice != null, off: Boolean(blocked || demoLabel),
|
|
379
536
|
dispatchCtx: () => dispatch({ type: 'ctx', ctx: NO_RAN })
|
|
380
537
|
}), []);
|
|
538
|
+
// TUI-STATUS: the registered server this runs on, read once at start (a restart re-reads); null = show nothing
|
|
539
|
+
const SV = React.useRef(null); SV.current ??= { chip: null };
|
|
540
|
+
React.useEffect(() => { // retry every 3 min (max 20) until the lookup succeeds: a collector restart at startup must not hide the chip for the whole session; failing shows nothing
|
|
541
|
+
if (blocked || demoLabel || !api.serverChip) return undefined;
|
|
542
|
+
let stopped = false, timer = null, tries = 0;
|
|
543
|
+
const attempt = () => { tries++; Promise.resolve().then(() => api.serverChip()).then((c) => { if (stopped) return; if (c?.name) { SV.current.chip = c; dispatch({ type: 'notify', patch: {} }); } else if (tries < 20) timer = setTimeout(attempt, 180000); }).catch(() => { if (!stopped && tries < 20) timer = setTimeout(attempt, 180000); }); };
|
|
544
|
+
attempt();
|
|
545
|
+
return () => { stopped = true; if (timer) clearTimeout(timer); };
|
|
546
|
+
}, []);
|
|
381
547
|
const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
|
|
382
548
|
// /restart auto: saved per profile (profile.quoxon.restartAuto). `newId` = the newest installed build seen; `cancelledId` = the build whose countdown a key cancelled.
|
|
383
|
-
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();
|
|
384
555
|
// Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
|
|
385
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; };
|
|
386
557
|
const cdLog = (phase, seconds = null) => transcript?.action({ type: 'restart.countdown', phase, seconds, build: AR.current.newId }); // lifecycle in the dev transcript
|
|
387
|
-
const stopCountdown = () => { clearTimeout(AR.current.timer); AR.current.timer = null; AR.current.endsAt = 0; };
|
|
558
|
+
const stopCountdown = () => { clearTimeout(AR.current.timer); AR.current.timer = null; AR.current.endsAt = 0; AR.current.waiting = null; };
|
|
559
|
+
// A land refreshes the CLI and redeploys the collector together: an AUTO restart waits for the server's /health (every
|
|
560
|
+
// serverPollMs, up to serverWaitMs), then restarts anyway. Manual /restart and Enter never wait.
|
|
561
|
+
const serverUp = serverProbe || (api.health ? () => api.health(3000) : null);
|
|
562
|
+
const autoFire = () => {
|
|
563
|
+
const a = AR.current;
|
|
564
|
+
if (!serverUp) { cdLog('timed-out'); doRestart(); return; }
|
|
565
|
+
const token = {}; a.waiting = token; // set first: cancels and the idle check see a restart in flight
|
|
566
|
+
Promise.resolve().then(() => serverUp()).catch(() => null).then((first) => {
|
|
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
|
|
569
|
+
if (first?.ok === true) { stopCountdown(); cdLog('timed-out'); doRestart(); return; } // healthy: no waiting line, as before
|
|
570
|
+
beginWait(token);
|
|
571
|
+
});
|
|
572
|
+
};
|
|
573
|
+
const beginWait = (token) => {
|
|
574
|
+
const a = AR.current;
|
|
575
|
+
a.endsAt = Date.now() || 1;
|
|
576
|
+
a.timer = setTimeout(() => {}, serverWaitMs + 60000); // holds the countdown slot (keys, held-by-draft check); cleared by stopCountdown
|
|
577
|
+
cdLog('waiting-for-server');
|
|
578
|
+
bump(n => n + 1);
|
|
579
|
+
waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => AR.current.waiting !== token || R.current.closed }).then((up) => {
|
|
580
|
+
if (AR.current.waiting !== token || R.current.closed) return; // cancelled (Esc, Enter, a draft) or closed
|
|
581
|
+
stopCountdown();
|
|
582
|
+
if (AR.current.on && autoIdle()) { cdLog(up ? 'server-back' : 'wait-timed-out'); doRestart(); } else { cdLog('held'); bump(n => n + 1); }
|
|
583
|
+
});
|
|
584
|
+
};
|
|
388
585
|
React.useEffect(() => (startSha ? buildWatch({ startSha, repeat: true, onNotice: (t, id) => {
|
|
389
586
|
AR.current.newId = id || t;
|
|
390
587
|
if (!AR.current.on) say(t === DIFFERENT_NOTICE ? t : 'a newer quox is installed: /restart (or /restart auto on)', 'dim');
|
|
391
588
|
} }) : undefined), []);
|
|
392
589
|
const autoIdle = () => { // nothing for a restart to interrupt
|
|
393
590
|
const s = S.current;
|
|
394
|
-
|
|
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)
|
|
395
595
|
&& !R.current.restarting && !R.current.exiting && !LI.current.isOpen() && !SU.current.isOpen() && !CN.current.isOpen() && !PK.current.isOpen() && !blocked;
|
|
396
596
|
};
|
|
397
597
|
React.useEffect(() => {
|
|
398
598
|
const id = setInterval(() => {
|
|
399
599
|
const a = AR.current;
|
|
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)
|
|
400
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
|
|
401
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
|
|
402
|
-
if (!autoIdle()) return;
|
|
606
|
+
if (!autoIdle() || Date.now() - a.busyAt < autoResumeMs) return;
|
|
403
607
|
const ms = countdownMs();
|
|
404
|
-
if (ms <= 0) { cdLog('timed-out', 0);
|
|
608
|
+
if (ms <= 0) { cdLog('timed-out', 0); autoFire(); return; }
|
|
405
609
|
a.endsAt = Date.now() + ms;
|
|
406
610
|
cdLog('started', Math.round(ms / 1000));
|
|
407
|
-
a.timer = setTimeout(() => {
|
|
611
|
+
a.timer = setTimeout(() => { if (AR.current.on && autoIdle()) { clearTimeout(a.timer); a.timer = null; a.endsAt = 0; autoFire(); } else { stopCountdown(); cdLog('held'); bump(n => n + 1); } }, ms);
|
|
408
612
|
bump(n => n + 1);
|
|
409
613
|
}, autoTickMs);
|
|
410
614
|
return () => { clearInterval(id); stopCountdown(); };
|
|
@@ -412,18 +616,53 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
412
616
|
// /restart carried a slot: re-check it out under this window's label before the old one is gone. /restart auto tends to
|
|
413
617
|
// fire just as a new build lands, which is often while the collector redeploys, so a timeout or 5xx is retried; if it
|
|
414
618
|
// never answers, the "Reattach to slot X? y/N" ask is offered instead of leaving the window silently slotless.
|
|
415
|
-
React.useEffect(() => { if (restartSlot && !blocked && api.checkoutSlot) carrySlot(restartSlot); }, []);
|
|
619
|
+
React.useEffect(() => { if (restartSlot && !blocked && api.checkoutSlot) waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => R.current.closed }).then(() => { if (!R.current.closed) carrySlot(restartSlot); }); }, []);
|
|
416
620
|
// /restart carried an /attach (agent or slot): re-attach once the window can act (owner 2026-10-15 "why dont u reattach").
|
|
417
621
|
const attachCarried = React.useRef(false);
|
|
418
622
|
React.useEffect(() => {
|
|
419
623
|
if (!restartAttach || blocked || attachCarried.current) return;
|
|
420
624
|
attachCarried.current = true;
|
|
421
625
|
if (S.current.turn) { R.current.pendingAttach = restartAttach; say(`Re-attaching to ${restartAttach.replace(/^(agent|slot):/, '')} when this turn ends.`, 'dim'); return; } // a resumed turn would refuse it
|
|
422
|
-
|
|
626
|
+
waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => R.current.closed }).then(() => { // the server may still be redeploying: attach only once it answers
|
|
627
|
+
if (R.current.closed) return;
|
|
628
|
+
if (S.current.turn) { R.current.pendingAttach = restartAttach; return; }
|
|
629
|
+
R.current.noGreeting = true; command(`/attach ${restartAttach}`);
|
|
630
|
+
});
|
|
423
631
|
}, [blocked]);
|
|
424
632
|
React.useEffect(() => { // any other way the resumed turn ends (reattach path, Esc): carry the attach then
|
|
425
633
|
if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
|
|
426
634
|
});
|
|
635
|
+
/**
|
|
636
|
+
* Move THIS window to org `t` ({ id, name }) now: new conversation, status bar, agent re-attach as on /restart.
|
|
637
|
+
* `quick` (picker / `/org <name>`): the pick is the authorization, so one short line, and `makeDefault` also
|
|
638
|
+
* changes the profile-wide default (the same change as `quox org switch` in a shell).
|
|
639
|
+
*/
|
|
640
|
+
async function switchOrgNow(t, { quick = false, makeDefault = false } = {}) {
|
|
641
|
+
const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
|
|
642
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
643
|
+
if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
|
|
644
|
+
if (t.id === curId && !makeDefault) return say(`Already in ${t.name}.`);
|
|
645
|
+
let done;
|
|
646
|
+
try { done = await api.switchOrg(t.id); } catch (e) { return say(`Not switched. ${e?.message || 'The server refused.'} You are still in ${R0.orgName ?? org ?? 'the same org'}.`, 'warn'); }
|
|
647
|
+
const att = SN.current.agent ? `agent:${SN.current.agent}` : null;
|
|
648
|
+
if (t.id !== curId) R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
|
|
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: '' } });
|
|
652
|
+
dispatch({ type: 'ctx', ctx: { conversationId: null } });
|
|
653
|
+
SN.current.name = null; SN.current.saved = null;
|
|
654
|
+
dispatch({ type: 'history.reset' });
|
|
655
|
+
if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
656
|
+
setEpoch(e => e + 1); bump(n => n + 1);
|
|
657
|
+
let scope = 'this window only; the profile default is unchanged';
|
|
658
|
+
let tone = 'ok';
|
|
659
|
+
if (makeDefault) {
|
|
660
|
+
try { await api.makeOrgDefault(done.id); scope = 'this window and the profile default'; }
|
|
661
|
+
catch (e) { scope = `this window only; could not change the profile default (${e?.message || 'refused'})`; tone = 'warn'; }
|
|
662
|
+
}
|
|
663
|
+
if (att) { R.current.noGreeting = true; command(`/attach ${att}`); }
|
|
664
|
+
return say(quick ? `now in ${done.name} (${scope})` : `Switched to ${done.name}. New conversation in that org.`, tone);
|
|
665
|
+
}
|
|
427
666
|
async function carrySlot(nm) {
|
|
428
667
|
for (let i = 0; ; i++) {
|
|
429
668
|
if (R.current.closed || SC.current.slot) return; // closed, or this window checked a slot out meanwhile (/slot); a slot merely inherited from a parent Claude session does not count
|
|
@@ -436,7 +675,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
436
675
|
ask(`Reattach to slot ${nm}?`, { action: `/slot ${nm} --take` });
|
|
437
676
|
return;
|
|
438
677
|
}
|
|
439
|
-
if (i === 0) say(
|
|
678
|
+
if (i === 0) say(reattachSlowLine({ slot: nm, message: r.message, restartSeenAt: R.current.restartSeenAt, lastGoodAt: R.current.lastGoodAt }), 'dim');
|
|
440
679
|
await new Promise((res) => { const t = setTimeout(res, slotRetryDelays[i]); t.unref?.(); });
|
|
441
680
|
}
|
|
442
681
|
}
|
|
@@ -447,6 +686,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
447
686
|
return () => clearInterval(id);
|
|
448
687
|
}, []);
|
|
449
688
|
// ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
|
|
689
|
+
// Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): attach state + real stream events; art and reducer live in dragonlab.
|
|
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)
|
|
691
|
+
const dispatchEv = (a) => { DC.current.tap(a); dispatch(a); };
|
|
692
|
+
// QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
|
|
693
|
+
const LC = React.useRef(null); LC.current ??= createLoopCards({ api, say: (t, tone) => say(t, tone), addItem: (it) => dispatch({ type: 'loopcard', card: it.card }), bump: () => { if (!R.current.closed) bump(n => n + 1); }, dragon: { summon: (d, cap) => DC.current.summon(d, cap) } });
|
|
450
694
|
const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
|
|
451
695
|
const realCwd = () => (cwd ? String(cwd).replace(/^~(?=\/|$)/, osHome()) : process.cwd());
|
|
452
696
|
const myDisplay = () => { try { return api.displayName?.() || null; } catch { return null; } };
|
|
@@ -461,9 +705,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
461
705
|
open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: label }, signal),
|
|
462
706
|
wait: api.slotWait ? ({ since }) => api.slotWait(nm, { since, excludeFrom: label }) : null,
|
|
463
707
|
onMessage: (m0) => {
|
|
464
|
-
//
|
|
465
|
-
if (m0.from_slot || !api.slots
|
|
466
|
-
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));
|
|
467
711
|
},
|
|
468
712
|
onState: (st) => {
|
|
469
713
|
if (st.error && st.failures === 1 && !R.current.closed) { paused = true; streamPausedNote(`slot ${nm}`, st.error); }
|
|
@@ -476,9 +720,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
476
720
|
noteReply(m); noteSender(m);
|
|
477
721
|
if (R.current.closed || SC.current.slot !== nm || (label && m.from === label)) return;
|
|
478
722
|
if (isOwnBroadcast(m, { label, display: myDisplay(), userId: api.userId?.() || null })) return; // your own broadcast is not news: the "BROADCAST sent" card told you
|
|
479
|
-
|
|
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)
|
|
480
726
|
rememberSender(m);
|
|
481
|
-
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 */ } }
|
|
482
728
|
}
|
|
483
729
|
}
|
|
484
730
|
SC.current.listener = l; l.start({});
|
|
@@ -502,6 +748,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
502
748
|
const runAskOpen = () => Boolean(SC.current.runAsk && runNow() < SC.current.runAsk.expiresAt); // one clock: the run-ask clock decides open and expired alike
|
|
503
749
|
/** ASK-SETTLE: history may go to <Static> (scrollback, immutable) only up to the first armed run card. */
|
|
504
750
|
function commitLimit(s0) {
|
|
751
|
+
// QLOOP-LIVE: a running loop's card is drawn pinned above the composer, so it never holds the history after it live (that re-laid-out every later row on every card tick); syncStatic defers the card itself until it settles
|
|
505
752
|
const held = SC.current.runCards;
|
|
506
753
|
if (held?.size) for (let i = committedRef.current; i < s0.history.length; i++) { const r = s0.history[i]?.line?.run; if (r?.askId && !r.settled && held.has(r.askId)) return i; }
|
|
507
754
|
return s0.history.length;
|
|
@@ -528,7 +775,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
528
775
|
const wait = SC.current.waits ??= new Map();
|
|
529
776
|
clearTimeout(wait.get(slot)); wait.delete(slot);
|
|
530
777
|
if (p.listening) {
|
|
531
|
-
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);
|
|
532
779
|
t.unref?.(); wait.set(slot, t);
|
|
533
780
|
} else if (r.presence?.state === 'not_listening') {
|
|
534
781
|
// Transcript fp 6a482d60fe83 (2026-10-16): an attached window got nothing for 4 minutes after "not listening".
|
|
@@ -553,8 +800,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
553
800
|
ttlMs: replyWatchMs,
|
|
554
801
|
skipSlot: (nm) => nm === where.current.slot || SC.current.rooms?.has(nm),
|
|
555
802
|
isMine: (m) => { const l = myLabel(); return Boolean(l && m?.from === l); },
|
|
556
|
-
onReply: (nm,
|
|
803
|
+
onReply: async (nm, m0) => {
|
|
557
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; }
|
|
558
807
|
noteReply(m); noteSender(m);
|
|
559
808
|
const ln = slotLine(m); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() });
|
|
560
809
|
if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: ln });
|
|
@@ -568,6 +817,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
568
817
|
api.bindHolderSession?.(() => slotSession());
|
|
569
818
|
/** Any inbound line from a slot we are waiting on cancels its "no reply" nudge. */
|
|
570
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());
|
|
571
822
|
const waits = SC.current.waits; if (!waits?.size) return;
|
|
572
823
|
for (const [slot, t] of waits) if (isHolderLine(m, slot)) { clearTimeout(t); waits.delete(slot); } // case-insensitive: LUCID is the holder of lucid
|
|
573
824
|
}
|
|
@@ -595,7 +846,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
595
846
|
}
|
|
596
847
|
/** A dropped slot stream: a server restart (connection cut) is routine and says so calmly; anything else keeps the error. */
|
|
597
848
|
function streamPausedNote(where, err) {
|
|
598
|
-
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'); }
|
|
599
850
|
return say(`${where}: live messages paused (${err}); retrying.`, 'warn');
|
|
600
851
|
}
|
|
601
852
|
/** QUOXON-REPLY (owner 2026-10-15: "click the sender to reply"; no mouse tracking, so /r): who last messaged me. */
|
|
@@ -619,14 +870,69 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
619
870
|
for (const n of new Set(list.map(r => r.human_presence?.away && r.human_presence.notice).filter(Boolean))) lines.push(`⏸ ${n}`); // PRESENCE
|
|
620
871
|
return say(lines.join('\n'));
|
|
621
872
|
}
|
|
622
|
-
|
|
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}` } = {}) {
|
|
623
919
|
const target = await sayTarget(slot, { senders: true });
|
|
624
920
|
slot = target.slot;
|
|
625
921
|
if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
|
|
626
922
|
if (SC.current.rooms.has(slot)) return postRoom(slot, text); // a joined room posts as a member
|
|
627
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
|
|
628
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');
|
|
629
933
|
if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
|
|
934
|
+
R.current.lastSay = { slot, at: Date.now() }; // SAY-LAST-TARGET
|
|
935
|
+
if (r.viaOrgId) say(`${slot} lives in ${r.viaOrgName || 'another of your orgs'}; sent there.`, 'dim');
|
|
630
936
|
const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
|
|
631
937
|
dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
|
|
632
938
|
announceDelivery(slot, r);
|
|
@@ -661,10 +967,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
661
967
|
onMessage: (m) => {
|
|
662
968
|
noteReply(m); noteSender(m);
|
|
663
969
|
if (R.current.closed || !SC.current.rooms.has(nm) || isMine(m)) return;
|
|
970
|
+
if (holdSlotMsg(m, `#${nm}`)) { rememberSender(m); return; } // DND
|
|
664
971
|
if (SC.current.active !== nm) entry.unread += 1; // before the dispatch, so the status line's count is current
|
|
665
972
|
dispatch({ type: 'slotmsg', line: roomLine(nm, m) });
|
|
666
973
|
rememberSender(m);
|
|
667
|
-
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 */ } }
|
|
668
975
|
},
|
|
669
976
|
onState: (st) => { if (st.error && st.failures === 1 && !R.current.closed) streamPausedNote(`#${nm}`, st.error); },
|
|
670
977
|
});
|
|
@@ -748,13 +1055,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
748
1055
|
SC.current.pendingFile = { slot, f, at: Date.now() };
|
|
749
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'}.` });
|
|
750
1057
|
}
|
|
751
|
-
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 });
|
|
752
1059
|
}
|
|
753
1060
|
// Startup: the slot this profile last held in this directory, free or stale (or still under our old label), is offered once.
|
|
754
1061
|
React.useEffect(() => {
|
|
755
1062
|
if (blocked || demoLabel || restartSlot || !api.checkoutSlot || !api.slots) return;
|
|
756
1063
|
const rec = loadLastSlot({ profile, cwd: realCwd(), path: slotRecordPath });
|
|
757
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)
|
|
758
1066
|
api.slots().then((rows) => {
|
|
759
1067
|
const row = (Array.isArray(rows) ? rows : []).find(x => String(x.name) === rec.slot);
|
|
760
1068
|
if (!row || SC.current.slot || R.current.closed) return; // only this window's own checkout suppresses the ask, not an inherited one
|
|
@@ -765,12 +1073,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
765
1073
|
}
|
|
766
1074
|
}).catch(() => { /* no ask when the server cannot say */ });
|
|
767
1075
|
}, []);
|
|
768
|
-
React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume)); }, []);
|
|
1076
|
+
React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume), { fromRestart: Boolean(restartAttach) }); }, []);
|
|
769
1077
|
React.useEffect(() => { if (!blocked && !demoLabel) aiConnected().then((st) => { R.current.noAi = st === false ? true : st === true ? false : undefined; bump(n => n + 1); }); }, []);
|
|
770
1078
|
// Tips (tips.js): mode, rotation and "already used" persist on the profile (quoxon.tips / tipsSeen / tipsUsed).
|
|
771
|
-
// QUOXON-MOUSE: optional mouse (default off, saved per profile). `tty` = tracking is on at the terminal right now.
|
|
1079
|
+
// QUOXON-MOUSE: optional mouse (default off, saved per profile; selection-safe.test.jsx forbids tracking by default). `tty` = tracking is on at the terminal right now.
|
|
772
1080
|
const MO = React.useRef(null);
|
|
773
1081
|
MO.current ??= { on: (api.settings?.() || {}).mouse === 'on', tty: false, chain: Promise.resolve(), drag: null, last: null, onCursor: null, exitFn: null };
|
|
1082
|
+
const TODO_MAP = React.useRef(null); // /todos short ids (td1, do2, dn3) -> real item ids, as last shown
|
|
774
1083
|
const TP = React.useRef(null);
|
|
775
1084
|
TP.current ??= (() => { const q = api.settings?.() || {}; const arr = (v) => (Array.isArray(v) ? v.filter(x => typeof x === 'string') : []); return { mode: normaliseMode(q.tips) || DEFAULT_MODE, seen: arr(q.tipsSeen), used: arr(q.tipsUsed) }; })();
|
|
776
1085
|
const showTip = async ({ startup: atLaunch = false } = {}) => {
|
|
@@ -807,6 +1116,25 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
807
1116
|
const shown = showWhatsNew();
|
|
808
1117
|
if (tipStartup && !welcome && !shown) showTip({ startup: true });
|
|
809
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
|
+
}, []);
|
|
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)
|
|
1125
|
+
if (blocked || !welcome || !api.providerCliStatus || !api.providerCliPlan) return;
|
|
1126
|
+
api.providerCliStatus().then(async (st) => {
|
|
1127
|
+
const missing = missingTools(st);
|
|
1128
|
+
if (!missing.length) return;
|
|
1129
|
+
const first = await api.providerCliPlan(missing[0]);
|
|
1130
|
+
if (!first?.ok) return; // not an instance admin (or npm unreachable): no suggestion they cannot act on
|
|
1131
|
+
say(SUGGEST_LINE);
|
|
1132
|
+
R.current.installQueue = missing.slice(1);
|
|
1133
|
+
say(planLine(first), 'dim');
|
|
1134
|
+
R.current.installAsk = { tool: missing[0], plan: first, at: Date.now() };
|
|
1135
|
+
ask(offerQuestion(missing[0], first), { action: `install ${INSTALL_TOOLS[missing[0]].name}`, reason: `${INSTALL_TOOLS[missing[0]].vendor}'s own package, downloaded from npm onto this server.` });
|
|
1136
|
+
}, () => {});
|
|
1137
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
810
1138
|
React.useEffect(() => {
|
|
811
1139
|
if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); startupTip(); }, () => startupTip());
|
|
812
1140
|
else startupTip();
|
|
@@ -817,7 +1145,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
817
1145
|
// Only the live region repaints at the new width (R3 D2): the stdout proxy (term.js) erases the old frame
|
|
818
1146
|
// by its reflowed height, so no ghost composers, and history is never reprinted into scrollback.
|
|
819
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
|
|
820
1149
|
stdout?.on?.('resize', onResize);
|
|
1150
|
+
onResize(); // a resize between first render and this subscription (a restart replay) must not leave the old width
|
|
821
1151
|
return () => stdout?.off?.('resize', onResize);
|
|
822
1152
|
}, [stdout, columns]);
|
|
823
1153
|
|
|
@@ -846,6 +1176,51 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
846
1176
|
if (nm && !isOrgId(nm)) { R.current.orgName = nm; dispatch({ type: 'notify', patch: {} }); }
|
|
847
1177
|
} catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
|
|
848
1178
|
};
|
|
1179
|
+
// HITL-INBOX-BAR: unread inbox items on the status area + one stream line per new arrival. A failed read keeps the last bar.
|
|
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 } = {}) => {
|
|
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;
|
|
1192
|
+
try {
|
|
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;
|
|
1198
|
+
const a = arrivals(items, R.current.inboxSeen || null);
|
|
1199
|
+
R.current.inboxSeen = a.ids;
|
|
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));
|
|
1223
|
+
};
|
|
849
1224
|
const refreshNotify = async () => {
|
|
850
1225
|
if (BL.current) return;
|
|
851
1226
|
const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
|
|
@@ -853,13 +1228,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
853
1228
|
const items = await api.approvals();
|
|
854
1229
|
if (N.authLost) say('Signed in again: approvals are back.', 'ok');
|
|
855
1230
|
else if (N.slow) say('Quox server is back.', 'ok');
|
|
856
|
-
N.fails = 0; N.authLost = false; N.slow = false;
|
|
857
|
-
|
|
1231
|
+
N.fails = 0; N.authLost = false; N.slow = false; R.current.readOkAt = Date.now();
|
|
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
|
|
1233
|
+
R.current.openItems = items; syncApprovalRows();
|
|
1234
|
+
dispatch({ type: 'notify', patch: { approvals: items.length, error: null, slow: false } });
|
|
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
|
+
}
|
|
858
1240
|
retryOrgName();
|
|
859
1241
|
dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
|
|
860
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
|
|
861
1243
|
// Provider paths that emit no approval.requested still surface here, as the same card.
|
|
862
|
-
for (const
|
|
1244
|
+
for (const raw of items) {
|
|
1245
|
+
const item = await enrichPlanItem(raw, { objectiveGet: api.objectiveGet, agents: api.agents, orgs: api.orgs, cache: (R.current.planCache ||= new Map()) });
|
|
1246
|
+
dispatchEv({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
|
|
1247
|
+
}
|
|
1248
|
+
autoArrive(items); // APPROVALS-AUTO: after the loop, so this conversation's own inline cards are already known (and skipped)
|
|
863
1249
|
} catch (e) {
|
|
864
1250
|
N.fails++; // counted apart from an empty inbox
|
|
865
1251
|
const msg = e?.message || 'failed';
|
|
@@ -870,7 +1256,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
870
1256
|
return;
|
|
871
1257
|
}
|
|
872
1258
|
if (e?.transient && !N.slow) { N.slow = true; say('Quox server is slow or unreachable, retrying.', 'warn'); } // QUOXON-FW12: not signed out
|
|
873
|
-
|
|
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 } });
|
|
874
1261
|
}
|
|
875
1262
|
};
|
|
876
1263
|
React.useEffect(() => {
|
|
@@ -907,7 +1294,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
907
1294
|
// model's provider, else the provider the last turn ran on. Unknown yet: say nothing; a failing turn says why.
|
|
908
1295
|
const mine = String(MC.current?.provider || S.current?.ctx?.provider || '').toLowerCase();
|
|
909
1296
|
const hits = list ? list.filter(e => mine && String(e.provider || '').toLowerCase() === mine) : null;
|
|
910
|
-
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
|
+
}
|
|
911
1315
|
}
|
|
912
1316
|
if (!stopped) timer = setTimeout(tick, 60000);
|
|
913
1317
|
};
|
|
@@ -925,10 +1329,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
925
1329
|
applyProgress(r.rows, (R0.progress ||= new Map()), Date.now());
|
|
926
1330
|
R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
|
|
927
1331
|
r = { ...r, rows: selectRows(r.rows, { all: R0.all }) };
|
|
928
|
-
R0.rows = r.rows;
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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; }
|
|
932
1337
|
if (!R.current.closed) bump(n => n + 1);
|
|
933
1338
|
} catch { /* api.roster handles per-source failures; nothing to show */ }
|
|
934
1339
|
};
|
|
@@ -1008,6 +1413,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1008
1413
|
d.push({ every: FRAME_MS });
|
|
1009
1414
|
if (s0.divider.endedAt != null) d.push({ at: s0.divider.endedAt + (s0.divider.phase === 'done' ? GREEN_MS : RED_MS) + 1 });
|
|
1010
1415
|
}
|
|
1416
|
+
if (DC.current.animating()) d.push({ every: motionOn && !mono() ? FRAME_MS : 500 }); // companion dragon: paced like the classic one, frames come from the cache (dragon/frames.js)
|
|
1011
1417
|
if (dragonActive(now, !motionOn || mono())) d.push({ every: motionOn && !mono() ? 40 : 500 }); // /dragon and the cinematic moods (qmotion DragonLive)
|
|
1012
1418
|
if (motionOn && fxBusy(now)) d.push({ every: FRAME_MS }); // reveals in flight
|
|
1013
1419
|
const sl = R.current.sleep;
|
|
@@ -1017,6 +1423,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1017
1423
|
return d;
|
|
1018
1424
|
};
|
|
1019
1425
|
React.useEffect(() => { if (!R.current.closed) clockRef.current.schedule(demands()); });
|
|
1426
|
+
// The companion dragon appears and leaves outside any App render (its own leaf and timers): re-arm the clock when it does, or it would get no frames until something else rendered.
|
|
1427
|
+
const rearm = React.useRef(null); rearm.current = () => { if (!R.current.closed) clockRef.current.schedule(demands()); };
|
|
1428
|
+
React.useEffect(() => DC.current.subscribe(() => rearm.current()), []);
|
|
1020
1429
|
|
|
1021
1430
|
const fxEnd = (e) => Math.max(e.at + revealSpan(e.timing) * 1000, e.settleAt != null ? e.settleAt + revealSpan(TIMING.settle) * 1000 : 0);
|
|
1022
1431
|
const fxActive = (item, now) => { const e = fxRef.current.get(item.id); return Boolean(e) && now < fxEnd(e); };
|
|
@@ -1057,7 +1466,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1057
1466
|
if (r?.status === 'denied') return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'denied' });
|
|
1058
1467
|
if (r && (r.status === 'approved' || r.status === 'error') && r.executed_at) {
|
|
1059
1468
|
const text = r.result == null ? null : clip(typeof r.result === 'string' ? r.result : JSON.stringify(r.result), cols - 20);
|
|
1060
|
-
|
|
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 } : {}) });
|
|
1061
1473
|
}
|
|
1062
1474
|
} catch { /* keep polling until the deadline */ }
|
|
1063
1475
|
}
|
|
@@ -1088,7 +1500,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1088
1500
|
};
|
|
1089
1501
|
// QUOXON-STEPUP: the server decides what needs a person. We try with any live step-up token; on 403 step_up_required
|
|
1090
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 }); };
|
|
1091
1506
|
const respondWithStepUp = async (card, action, { proactive = false, reason = null, bulk = false } = {}) => {
|
|
1507
|
+
ownerActed(action === 'approve' ? 'approval' : 'deny');
|
|
1092
1508
|
const via = `tty ${sessionLabel()}`;
|
|
1093
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 };
|
|
1094
1510
|
const ask = async () => {
|
|
@@ -1153,6 +1569,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1153
1569
|
queueMicrotask(() => reconcileAsks());
|
|
1154
1570
|
return true;
|
|
1155
1571
|
};
|
|
1572
|
+
// TUI-WALK (owner 2026-10-19: he always runs /approvals again to confirm it is empty): the walk ends with a FRESH server
|
|
1573
|
+
// read, never the local count. Empty is only ever claimed after a successful fetch.
|
|
1574
|
+
const walkRecheck = async (W) => {
|
|
1575
|
+
let items;
|
|
1576
|
+
try { items = await api.approvals(); }
|
|
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; }
|
|
1578
|
+
const gone = walkAnsweredIds();
|
|
1579
|
+
const open = (Array.isArray(items) ? items : []).filter(x => x && !gone.has(x.id));
|
|
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; }
|
|
1583
|
+
const fresh = open.filter(x => !W.ids.includes(x.id)).length;
|
|
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');
|
|
1589
|
+
else say(`⏸ ${open.length} still waiting (skipped) · /approvals`, 'warn');
|
|
1590
|
+
await sayOthers();
|
|
1591
|
+
};
|
|
1156
1592
|
const walkShow = () => {
|
|
1157
1593
|
endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
|
|
1158
1594
|
const W = R.current.pendingWalk; if (!W) return;
|
|
@@ -1166,14 +1602,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1166
1602
|
if (walkGoneElsewhere(W, id)) { W.done.add(id); say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
|
|
1167
1603
|
nextId = id; break;
|
|
1168
1604
|
}
|
|
1169
|
-
if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); queueMicrotask(() => reconcileAsks()); return; }
|
|
1605
|
+
if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); walkRecheck(W); queueMicrotask(() => reconcileAsks()); return; }
|
|
1170
1606
|
const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
|
|
1171
1607
|
const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
|
|
1172
1608
|
W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
|
|
1173
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
|
|
1174
1611
|
c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
|
|
1175
1612
|
ask('Your choice, with an optional note', { who: it.payload.session || '', action: '', reason: '', keys: '· Esc stops', plain: true });
|
|
1176
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
|
+
};
|
|
1177
1627
|
const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
|
|
1178
1628
|
let decs = (list || []).filter(isDecision);
|
|
1179
1629
|
if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
|
|
@@ -1194,6 +1644,89 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1194
1644
|
walkShow();
|
|
1195
1645
|
return undefined;
|
|
1196
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); }, []);
|
|
1197
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".
|
|
1198
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
|
|
1199
1732
|
const noteCard = (st) => {
|
|
@@ -1227,7 +1760,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1227
1760
|
if (rep.notYesNo) { say(walkNotYesNo(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
|
|
1228
1761
|
if (rep.defer) { W.done.add(it.id); W.skipped.push(it.id); walkShow(); return true; }
|
|
1229
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; }
|
|
1230
|
-
|
|
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
|
+
}
|
|
1231
1774
|
if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
|
|
1232
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; }
|
|
1233
1776
|
if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
|
|
@@ -1237,7 +1780,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1237
1780
|
}
|
|
1238
1781
|
R.current.lastAnswered = null;
|
|
1239
1782
|
R.current.walkSend = true;
|
|
1240
|
-
try { await command(`/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
|
|
1783
|
+
try { await command(it.__approval ? (rep.key === 'y' ? `/approve ${n}` : `/deny ${n}${rep.note ? ` ${rep.note}` : ''}`) : `/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
|
|
1241
1784
|
if (R.current.lastAnswered === it.id) {
|
|
1242
1785
|
walkAnsweredIds().add(it.id);
|
|
1243
1786
|
W.done.add(it.id); W.answered.push({ title: it.title, word: `${it.payload.options.find(o => o.key === rep.key).label}${rep.note ? ` (note: ${rep.note.length} characters)` : ''}` });
|
|
@@ -1257,9 +1800,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1257
1800
|
}
|
|
1258
1801
|
await respondWithStepUp(card, action, { proactive, reason, bulk });
|
|
1259
1802
|
// The demo's stream carries on to its own tool result, so no "running, result later" note, no poll, no stale count.
|
|
1260
|
-
dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
|
|
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 });
|
|
1261
1804
|
if (demoLabel) { B.current = initialBuffer(); dispatch({ type: 'notify', patch: { approvals: 0, error: null } }); } // (a stray early keystroke is not left in the composer)
|
|
1262
|
-
if (action === 'approve' && card.tool !== 'hat.switch' && !
|
|
1805
|
+
if (action === 'approve' && card.tool !== 'hat.switch' && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
|
|
1263
1806
|
return { ok: true };
|
|
1264
1807
|
} catch (e) {
|
|
1265
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)
|
|
@@ -1272,6 +1815,35 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1272
1815
|
finally { R.current.answering.delete(card.callId); }
|
|
1273
1816
|
};
|
|
1274
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
|
+
|
|
1275
1847
|
// ---- turn runner ---------------------------------------------------------------------------
|
|
1276
1848
|
// Z2H-R2B: with no AI connected a turn is answered here, whole and wrapped, without a server call.
|
|
1277
1849
|
async function aiConnected() {
|
|
@@ -1432,10 +2004,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1432
2004
|
say(handoffAnsweredNote(a.h), 'dim');
|
|
1433
2005
|
bump(n => n + 1);
|
|
1434
2006
|
}
|
|
2007
|
+
// QUOXON objective offer: the same path as `quox objective create <goal> --checkpoint` + start. Offered, never auto-run.
|
|
2008
|
+
async function runObjective(a) {
|
|
2009
|
+
say('Making the objective…', 'dim');
|
|
2010
|
+
try {
|
|
2011
|
+
if (typeof api.startObjective !== 'function') throw new Error('this build cannot create objectives from here');
|
|
2012
|
+
const o = await api.startObjective(a.o.goal, a.o.loop || null);
|
|
2013
|
+
say(objectiveStartedNote(o.id), 'dim');
|
|
2014
|
+
say(objectiveWatchLine(o.id), 'dim');
|
|
2015
|
+
if (a.o.loop && api.loopEvents) LC.current.watch(o.id, { title: a.o.goal }); // QLOOP-LIVE: the loop's live card appears in the chat stream
|
|
2016
|
+
// Report back into this conversation when a plan objective ends (the owner inbox item covers a closed window).
|
|
2017
|
+
if (!a.o.loop && typeof api.objectiveGet === 'function') {
|
|
2018
|
+
watchObjective({ get: () => api.objectiveGet(o.id), say: (t) => { say(t, 'dim'); bump(n => n + 1); }, stopped: () => R.current.closed === true }).catch(() => {});
|
|
2019
|
+
}
|
|
2020
|
+
} catch (e) { say(objectiveFailedNote(a.o.goal, e), 'dim'); }
|
|
2021
|
+
bump(n => n + 1);
|
|
2022
|
+
}
|
|
1435
2023
|
async function consumeTurn(turnId, open, { keepOnDown = false } = {}) {
|
|
1436
2024
|
R.current.caps.steer = true; R.current.steerNoted = false; // steer support is learned per turn (provider paths differ)
|
|
1437
2025
|
R.current.durable = false; // learned from the server: v1.1 events carry a seq
|
|
1438
|
-
|
|
2026
|
+
dispatchEv({ type: 'turn.begin', turnId });
|
|
1439
2027
|
const ctrl = new AbortController();
|
|
1440
2028
|
R.current.abort = ctrl;
|
|
1441
2029
|
let offered = false, sawEvent = false, down = false;
|
|
@@ -1446,8 +2034,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1446
2034
|
sawEvent = true;
|
|
1447
2035
|
if (typeof ev?.seq === 'number') R.current.durable = true;
|
|
1448
2036
|
LG.current.observe(ev);
|
|
1449
|
-
|
|
2037
|
+
dispatchEv({ type: 'event', ev });
|
|
1450
2038
|
if (ev.type === 'tool.finished' && ev.suggest_command) { suggested = true; offerSuggestion(ev); }
|
|
2039
|
+
// QLOOP-LIVE: the chat's objective tool made a LOOP: open its live card in this conversation (the tool answer names the title, not the id)
|
|
2040
|
+
if (ev.type === 'tool.finished' && /objective/i.test(String(ev.tool || '')) && ev.status === 'ok' && /^\s*Loop "/.test(String(ev.result_summary || '')) && api.loopEvents && !(ev.call_id && SC.current.loopSeen?.has(ev.call_id))) {
|
|
2041
|
+
(SC.current.loopSeen ??= new Set()).add(ev.call_id);
|
|
2042
|
+
LC.current.discover({ title: (/^\s*Loop "(.*?)(?:"|$)/.exec(String(ev.result_summary)) || [])[1] || '' }).catch(() => {});
|
|
2043
|
+
}
|
|
2044
|
+
if (ev.type === 'turn.objective_suggested') { const ob = objectiveFromEvent(ev, SN.current.agent || (ev.objective_suggested?.mode === 'loop' && ev.objective_suggested?.from === 'quox' ? 'quox' : null)); if (ob) R.current.objectivePending = { o: ob, text: R.current.lastSent || '' }; } // QUOXON objective offer; unattached chat is Quox itself, so its keep-going offer still shows
|
|
1451
2045
|
if (ev.type === 'turn.handoff_suggested') { const ho = handoffFromEvent(ev, SN.current.agent); if (ho) R.current.handoffPending = { h: ho, text: R.current.lastSent || '' }; } // QUOXON handoff offer
|
|
1452
2046
|
if (ev.type === 'chunk' || ev.type === 'text.delta') { const g = textGate(gate, ev); gate = g.st; if (g.append && answerText.length < 20000) answerText += String(ev.content); }
|
|
1453
2047
|
// Z2H-3B: a server default that is not connected must not linger in the status bar.
|
|
@@ -1468,7 +2062,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1468
2062
|
if (S.current.turn?.id === turnId) {
|
|
1469
2063
|
// stream closed without a terminal event
|
|
1470
2064
|
// never claim completion the server did not confirm
|
|
1471
|
-
|
|
2065
|
+
dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
|
|
1472
2066
|
}
|
|
1473
2067
|
} catch (e) {
|
|
1474
2068
|
if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
|
|
@@ -1479,15 +2073,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1479
2073
|
say(`Blocked by policy (${e.body.block_id}). A person can override this one action: /override last <reason>`, 'warn');
|
|
1480
2074
|
}
|
|
1481
2075
|
// TUI-DIVIDER: a refusal before the stream opened names the layers it really evaluated; the rule lights them.
|
|
1482
|
-
if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers)
|
|
2076
|
+
if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers) dispatchEv({ type: 'event', ev: { type: 'aocl.layer', ...l } });
|
|
1483
2077
|
const why = sawEvent ? explainTermination(e) : null;
|
|
1484
|
-
if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running')
|
|
2078
|
+
if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running') dispatchEv({ type: 'event', ev: { type: 'tool.finished', call_id: b.callId, tool: b.tool, status: 'interrupted', result_summary: 'connection lost before a result; verify before retrying' } });
|
|
1485
2079
|
const dead = !why && (e?.status === 401 || isSessionDead(e));
|
|
1486
|
-
|
|
2080
|
+
dispatchEv({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
|
|
1487
2081
|
if (dead) markSignedOut(e?.reason || null);
|
|
1488
2082
|
if (why) say(RETRY_HINT, 'warn');
|
|
1489
2083
|
// A lost reattach is not a failed turn: the server may still be finishing it (C5).
|
|
1490
|
-
if (S.current.turn?.id === turnId)
|
|
2084
|
+
if (S.current.turn?.id === turnId) dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
|
|
1491
2085
|
}
|
|
1492
2086
|
} finally {
|
|
1493
2087
|
clearTimeout(R.current.fallbackTimer);
|
|
@@ -1499,6 +2093,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1499
2093
|
// QUOXON handoff offer: the attached agent lacks the tools for this ask. The handoff card answers the actual question,
|
|
1500
2094
|
// so it wins over the agent's own suggested command (owner live 22:42: "Run /vm list?" took the slot and the handoff
|
|
1501
2095
|
// card was dropped silently). Nothing is dropped silently: a superseded or skipped card says so in one line.
|
|
2096
|
+
const op = R.current.objectivePending; R.current.objectivePending = null;
|
|
2097
|
+
const opOk = Boolean(op && !down && !ctrl.signal.aborted && !planDone);
|
|
2098
|
+
if (opOk) { R.current.handoffPending = null; R.current.suggestLine = null; R.current.suggestAsk = null; } // one ask, not two: the objective card replaces the handoff and the agent's own command
|
|
1502
2099
|
const hp = R.current.handoffPending; R.current.handoffPending = null;
|
|
1503
2100
|
const hpOk = Boolean(hp && !down && !ctrl.signal.aborted && hp.text);
|
|
1504
2101
|
if (hpOk && !planDone && R.current.suggestLine) {
|
|
@@ -1506,8 +2103,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1506
2103
|
R.current.suggestLine = null; R.current.suggestAsk = null;
|
|
1507
2104
|
say(handoffSupersededNote(hp.h, line), 'dim');
|
|
1508
2105
|
}
|
|
1509
|
-
const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone);
|
|
2106
|
+
const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone) || opOk;
|
|
1510
2107
|
flushSuggestion();
|
|
2108
|
+
if (opOk) { R.current.objectiveAsk = { ...op, at: Date.now() }; say(objectiveAskText(op.o, cols), 'ask'); }
|
|
1511
2109
|
if (hpOk && planDone) say(handoffSkippedNote(hp.h), 'dim');
|
|
1512
2110
|
else if (hpOk) {
|
|
1513
2111
|
const hs = (R.current.handoffStreak ??= createHandoffStreak()).offered(hp.h.from);
|
|
@@ -1524,7 +2122,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1524
2122
|
}
|
|
1525
2123
|
|
|
1526
2124
|
React.useEffect(() => {
|
|
1527
|
-
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
|
|
1528
2126
|
const s = S.current;
|
|
1529
2127
|
if (!s.turn && s.autoDrain && s.pending.length) {
|
|
1530
2128
|
const next = s.pending[0];
|
|
@@ -1535,7 +2133,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1535
2133
|
|
|
1536
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.
|
|
1537
2135
|
function runQueued(text, { checked = false } = {}) {
|
|
1538
|
-
if (
|
|
2136
|
+
if (isOneLineCommand(text)) { command(text); return; }
|
|
1539
2137
|
const stray = checked ? null : strayCardAnswer(text, commandNames()); // TUI-INPUT: "n/help" = a card answer that leaked into the composer
|
|
1540
2138
|
if (stray) { R.current.strayAsk = { text, command: stray.command, at: Date.now() }; ask(strayQuestion(stray)); return; }
|
|
1541
2139
|
dispatch({ type: 'user', text, queued: true });
|
|
@@ -1549,30 +2147,91 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1549
2147
|
|
|
1550
2148
|
// PRESENCE (owner 2026-10-16): /afk and /sleep tell the server, so sessions and LLMs know you are not at this
|
|
1551
2149
|
// terminal (approvals still queue; nothing is approved). /back or any typed turn marks you present.
|
|
1552
|
-
async function setPresence(state, note) {
|
|
2150
|
+
async function setPresence(state, note, { human = false, dnd = false } = {}) {
|
|
1553
2151
|
if (!api.setPresence) return { ok: true, local: true }; // demo / offline: the local mark is all there is
|
|
1554
|
-
|
|
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 }); }
|
|
1555
2157
|
catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
|
|
1556
2158
|
}
|
|
1557
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');
|
|
1558
|
-
|
|
1559
|
-
|
|
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;
|
|
1560
2216
|
if (AWAY.current.afk) { // this window set the flag: a typed turn here is the owner back (explicit, as before)
|
|
2217
|
+
archiveWindow();
|
|
1561
2218
|
AWAY.current.afk = null;
|
|
1562
|
-
setPresence('present').then((r) => { if (!r.ok) presenceFail(r); }, () => {});
|
|
2219
|
+
setPresence('present', undefined, { human: true }).then((r) => { if (!r.ok) presenceFail(r); }, () => {});
|
|
1563
2220
|
return;
|
|
1564
2221
|
}
|
|
1565
2222
|
// PRESENCE (owner 2026-10-19): a window that never set the flag (restarted TUI) still clears the server's stale one,
|
|
1566
2223
|
// but only from a real terminal (api.noteActivity is a no-op for --print, pipes and proof runs). Welcome shows once.
|
|
1567
2224
|
if (!api.noteActivity) return;
|
|
1568
|
-
api.noteActivity(action).then((r) => {
|
|
2225
|
+
api.noteActivity(action, quiet ? { quiet: true } : undefined).then((r) => {
|
|
1569
2226
|
if (r?.welcome?.away_text && !AWAY.current.welcomed) { AWAY.current.welcomed = true; say(`✔ Welcome back, you were away ${r.welcome.away_text}`, 'ok'); }
|
|
1570
2227
|
}, () => {});
|
|
1571
2228
|
}
|
|
1572
2229
|
function submit(text) {
|
|
1573
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
|
|
1574
2232
|
markPresent(/^\//.test(text.trim()) ? `slash ${text.trim().split(/\s+/)[0]}` : 'chat');
|
|
1575
2233
|
dispatch({ type: 'user', text });
|
|
2234
|
+
if (!/^\//.test(text.trim()) && sleepIntent(text)) offerDnd(); // never silently: it asks
|
|
1576
2235
|
const tip = intentHint(text); // a hint only: the turn still goes to the model
|
|
1577
2236
|
if (tip) say(tip);
|
|
1578
2237
|
const panel = intentPanel(text); // the same panel as the command, from local facts, above the answer
|
|
@@ -1616,6 +2275,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1616
2275
|
R.current.fallbackTimer = setTimeout(local, INTERRUPT_FALLBACK_MS);
|
|
1617
2276
|
}
|
|
1618
2277
|
|
|
2278
|
+
// Stop the turn now, locally and for good: tell the server (best effort), abort the stream, and always leave busy cleared.
|
|
2279
|
+
function stopTurnNow() {
|
|
2280
|
+
const s = S.current, turn = s.turn;
|
|
2281
|
+
try {
|
|
2282
|
+
if (R.current.waitingServer) { R.current.abort?.abort(); return; }
|
|
2283
|
+
if (!turn) return;
|
|
2284
|
+
clearTimeout(R.current.fallbackTimer);
|
|
2285
|
+
try { Promise.resolve(api.interrupt?.(s.ctx.conversationId, turn.id)).catch(() => {}); } catch { /* best effort */ }
|
|
2286
|
+
R.current.abort?.abort();
|
|
2287
|
+
} finally {
|
|
2288
|
+
if (turn && S.current.turn?.id === turn.id) dispatch({ type: 'stopped.local' });
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
1619
2292
|
// ---- L1 slash commands (models in slash/*.js) ------------------------------------------------
|
|
1620
2293
|
const help = (model) => dispatch({ type: 'help', model });
|
|
1621
2294
|
const leaveArmed = (kind) => { // a running turn would be disconnected: ask once, a second ask within 5 s goes ahead
|
|
@@ -1662,7 +2335,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1662
2335
|
if (cmd === '/cost') return help(costModel(LG.current));
|
|
1663
2336
|
if (cmd === '/status') {
|
|
1664
2337
|
let hl = R.current.lastHealth || null;
|
|
1665
|
-
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(); }
|
|
1666
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 }));
|
|
1667
2340
|
}
|
|
1668
2341
|
if (cmd === '/context') {
|
|
@@ -1700,12 +2373,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1700
2373
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
1701
2374
|
let turns;
|
|
1702
2375
|
try { turns = await reconnecting(() => api.conversationTurns(row.id), st); } catch (e) { if (R.current.closed) return undefined; if (e?.reconnectGaveUp) return resumeGaveUp(row.id); if (e?.status === 404) return say('That conversation is not on the server any more (it keeps them 7 days).', 'warn'); return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
|
|
2376
|
+
// RESUME: the attachment and the conversation must name the same agent. After a /restart the replayed attach is what the
|
|
2377
|
+
// window held when it was saved; if the conversation was with someone else, warn once and start fresh rather than mix.
|
|
2378
|
+
const wasAgent = st.fromRestart && /^agent:/.test(restartAttach || '') ? restartAttach.slice(6) : null;
|
|
2379
|
+
if (st.fromRestart && wasAgent && row.agent && row.agent !== wasAgent) return say(`That conversation was with ${row.agent}, but this window was attached to ${wasAgent}. Starting a fresh conversation with ${wasAgent}; /resume brings the ${row.agent} one back.`, 'warn');
|
|
1703
2380
|
const t = turnsToHistory(turns);
|
|
2381
|
+
const shownTitle = cleanTitle(row.title) && row.title !== '(untitled)' ? cleanTitle(row.title) : (titleFromTurns(turns) || '(untitled)'); // never rendered UI
|
|
1704
2382
|
dispatch({ type: 'ctx', ctx: { conversationId: row.id } });
|
|
1705
2383
|
SN.current.name = loadNames(namesPath)[row.id]?.name || null; SN.current.saved = null;
|
|
1706
2384
|
if (!t.total && SN.current.name) SN.current.saved = `${row.id}|${SN.current.name}`; // nothing found: do not re-stamp the saved name
|
|
1707
|
-
say(`Resumed "${clip(
|
|
2385
|
+
say(`Resumed "${clip(shownTitle, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
|
|
1708
2386
|
dispatch({ type: 'history.add', items: t.items, answers: t.answers });
|
|
2387
|
+
if (!st.fromRestart && row.agent && row.agent !== SN.current.agent) { R.current.noGreeting = true; await command(`/attach agent:${row.agent}`); } // next turns go to the agent this conversation was with
|
|
1709
2388
|
if (!t.total && agentLogin && row.byName) {
|
|
1710
2389
|
const h = humanProfiles.find(x => x !== profile);
|
|
1711
2390
|
return say(`${row.byName} was saved by another profile. Start with quox -p ${h || '<human profile>'} to resume it.`, 'warn');
|
|
@@ -1713,9 +2392,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1713
2392
|
say(t.total ? `Showing the last ${t.shown} of ${t.total} stored messages. Your next message continues this conversation.` : 'The server has no stored messages for it (it keeps them 7 days). Your next message continues it anyway.', 'dim');
|
|
1714
2393
|
return reattachIfRunning(row.id);
|
|
1715
2394
|
}
|
|
1716
|
-
async function resumeCmd(q) {
|
|
2395
|
+
async function resumeCmd(q, { fromRestart = false } = {}) {
|
|
1717
2396
|
if (!api.conversations || !api.conversationTurns) return say('Resume is not available in this mode.', 'warn');
|
|
1718
|
-
let rows; const st = {};
|
|
2397
|
+
let rows; const st = { fromRestart };
|
|
1719
2398
|
try {
|
|
1720
2399
|
const [list, index] = await reconnecting(() => Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]), st);
|
|
1721
2400
|
rows = resumeRows(list, index, loadNames(namesPath));
|
|
@@ -1770,6 +2449,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1770
2449
|
bump(n => n + 1); return true;
|
|
1771
2450
|
}
|
|
1772
2451
|
function dragonFly() { // one pass of the dragon in flight in the live region; Esc stops it, a new call restarts it
|
|
2452
|
+
const room = flyRoom(termRows(stdout), chromeH.current ?? 10); if (!room.ok) return say(room.note, 'warn'); // too small: say so, never silently nothing
|
|
1773
2453
|
R.current.fly = { startedAt: Date.now(), still: !motionOn || mono() };
|
|
1774
2454
|
bump(n => n + 1); return true;
|
|
1775
2455
|
}
|
|
@@ -1882,6 +2562,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1882
2562
|
}
|
|
1883
2563
|
|
|
1884
2564
|
function cliSlashRun(hit) {
|
|
2565
|
+
if (hit.error) return say(`${hit.error}`, 'warn');
|
|
1885
2566
|
if (hit.cls === 'never') return say(`${hit.display} will not run from here (credentials, install or other never-from-chat commands). Run it in your own terminal if you mean it.`, 'warn');
|
|
1886
2567
|
const bang = { kind: 'quox', args: hit.args, cmd: hit.display };
|
|
1887
2568
|
if (hit.cls === 'write') { R.current.cliAsk = { bang, at: Date.now() }; say(hit.display, 'accent'); return ask('Run it?', { action: hit.display, reason: 'This quox command writes.' }); }
|
|
@@ -1933,6 +2614,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1933
2614
|
const cand = loginCandidates.find(x => x !== me && okProfileName(x)) || 'me';
|
|
1934
2615
|
return offerLogin(cand, `This window is an agent login (${me}) and no human login is signed in on this machine.`);
|
|
1935
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
|
+
}
|
|
1936
2638
|
function offerLogin(name, lead) {
|
|
1937
2639
|
return armRun(loginRunRequest(name, `${lead} Sign ${name} in, then /profile ${name} switches this window to it.`, runNow()), { who: 'Quox', glyph: 'Ⓠ', text: '' });
|
|
1938
2640
|
}
|
|
@@ -1975,17 +2677,37 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1975
2677
|
target = c.onPath || undefined;
|
|
1976
2678
|
}
|
|
1977
2679
|
say(`Restarting${S.current.ctx.conversationId ? ' into this conversation' : ''}…`, 'dim');
|
|
1978
|
-
|
|
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 });
|
|
1979
2682
|
return exit(30);
|
|
1980
2683
|
}
|
|
1981
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.
|
|
1982
2687
|
async function command(line) {
|
|
1983
|
-
if (
|
|
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) {
|
|
2705
|
+
if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/devnote');
|
|
1984
2706
|
const [cmd0, arg] = line.trim().split(/\s+/);
|
|
1985
2707
|
const cmd = cmd0 === '/agent' ? '/agents' : cmd0; // /agent is the same command
|
|
1986
|
-
if (!['/afk', '/sleep', '/back', '/away'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
|
|
1987
|
-
if (cmd !== '/
|
|
1988
|
-
if (cmd !== '/
|
|
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
|
|
2709
|
+
if (cmd !== '/devnote' && cmd !== '/context') SCX.current.command(line.trim());
|
|
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
|
|
1989
2711
|
const restArg = line.trim().replace(/^\S+\s*/, '');
|
|
1990
2712
|
touchSeen();
|
|
1991
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
|
|
@@ -1998,8 +2720,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1998
2720
|
objArg = p.name;
|
|
1999
2721
|
}
|
|
2000
2722
|
if (OBJECT_CMDS.has(cmd) && !(cmd === '/rules' && arg)) { // inline object views (objects/): a card into history, or an honest note
|
|
2001
|
-
const r = await runObjectCommand(cmd, objArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks], conversationId: S.current.ctx?.conversationId });
|
|
2002
|
-
if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : say(
|
|
2723
|
+
const r = await runObjectCommand(cmd, objArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks], conversationId: S.current.ctx?.conversationId, cols });
|
|
2724
|
+
if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : r.lines ? r.lines.forEach(l => say(l, r.tone)) : say(r.note, r.tone); // wrap, never clip: notes carry ids and hints
|
|
2003
2725
|
}
|
|
2004
2726
|
switch (cmd) {
|
|
2005
2727
|
case '/copy': {
|
|
@@ -2009,13 +2731,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2009
2731
|
return say(`copied ${r.text.length.toLocaleString('en-US')} chars to the clipboard (needs a terminal that allows OSC 52)`, 'dim');
|
|
2010
2732
|
}
|
|
2011
2733
|
case '/share': return shareCommand(arg);
|
|
2012
|
-
case '/
|
|
2734
|
+
case '/devnote': {
|
|
2013
2735
|
if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
|
|
2014
|
-
if (!restArg) return say('Usage: /
|
|
2736
|
+
if (!restArg) return say('Usage: /devnote <text> (or // text). Nothing to note.', 'warn');
|
|
2015
2737
|
if (!transcript?.note) return say('notes need dev transcripts on (touch ~/.config/quox/dev-transcripts)', 'warn');
|
|
2016
2738
|
transcript.note(restArg.slice(0, 2000));
|
|
2017
2739
|
return say('noted for the observer', 'dim');
|
|
2018
2740
|
}
|
|
2741
|
+
case '/note': { // TUI-NOTE: a NotesQ note, same backend as `quox notes new`
|
|
2742
|
+
if (!restArg) return say('Usage: /note <text> (saves a NotesQ note; /notes lists them).', 'warn');
|
|
2743
|
+
if (!api.createNote) return say('NotesQ is not available in this window. Run quox notes new from a signed-in terminal, or enable NotesQ for your org in the dashboard.', 'warn');
|
|
2744
|
+
const r = await api.createNote(restArg);
|
|
2745
|
+
if (!r.ok) return say(r.message, 'warn');
|
|
2746
|
+
return say(`Saved note "${r.title}" (${r.id}). /notes lists your notes (or quox notes ls).`, 'ok');
|
|
2747
|
+
}
|
|
2748
|
+
case '/notes': return cliSlashRun(resolveCliSlash('/notes', 'ls'));
|
|
2749
|
+
case '/contact': return cliSlashRun(resolveCliSlash('/contact', restArg)); // registered command; runs as the quox contacts leaf
|
|
2019
2750
|
case '/rename': {
|
|
2020
2751
|
if (!restArg) return say(SN.current.name ? `Session name: ${SN.current.name}` : 'This session has no name. Use /rename <name>.');
|
|
2021
2752
|
const n = cleanName(restArg);
|
|
@@ -2033,17 +2764,38 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2033
2764
|
const m = /^(\S+)\s+([\s\S]*\S[\s\S]*)$/.exec(restArg || '');
|
|
2034
2765
|
if (!m) return say('Usage: /say <slot>[,<slot>...] <text>', 'warn');
|
|
2035
2766
|
const to = [...new Set(m[1].split(',').map(x => x.trim().toLowerCase()).filter(Boolean))];
|
|
2036
|
-
if (to.length < 2)
|
|
2767
|
+
if (to.length < 2) { // SAY-LAST-TARGET: a first word that is not a slot is part of the message when a recent target exists
|
|
2768
|
+
const first = to[0] || m[1].toLowerCase();
|
|
2769
|
+
let rows = [];
|
|
2770
|
+
if (api.slots) { try { rows = await api.slots(); } catch { rows = []; } }
|
|
2771
|
+
let known = !looksLikeAddress(m[1]) && !!resolveSayTarget(first, Array.isArray(rows) ? rows : [], SC.current.senders);
|
|
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
|
+
}
|
|
2778
|
+
if (!known && (looksLikeAddress(m[1]) || last)) {
|
|
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');
|
|
2781
|
+
R.current.sayLastAsk = { slot: last.slot, text: restArg.trim(), at: Date.now() };
|
|
2782
|
+
return say(askText(`Send to ${last.slot}?`, { keys: 'y send · n cancel', action: `send the whole message to ${last.slot}` }), 'ask');
|
|
2783
|
+
}
|
|
2784
|
+
return say(sayWhoText(rows), 'warn');
|
|
2785
|
+
}
|
|
2786
|
+
return sendToSlot(first, m[2].trim());
|
|
2787
|
+
}
|
|
2037
2788
|
// QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
|
|
2038
2789
|
const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
|
|
2039
2790
|
const head = `(to ${slots.join(', ')}) `;
|
|
2040
|
-
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 });
|
|
2041
2792
|
return undefined;
|
|
2042
2793
|
}
|
|
2043
2794
|
case '/join': { const nm = roomName(restArg); if (!nm) return say('Usage: /join <slot> (or #<slot>)', 'warn'); return joinRoom(nm); }
|
|
2044
2795
|
case '/leave': { const nm = roomName(restArg); if (!nm) return say('Usage: /leave <slot>', 'warn'); return leaveRoom(nm); }
|
|
2045
2796
|
case '/rooms': case '/groups': return showRooms();
|
|
2046
2797
|
case '/broadcast': case '/page':
|
|
2798
|
+
if (cmd === '/broadcast' && sleepIntent(restArg)) queueMicrotask(offerDnd); // never silently: it asks, after the card
|
|
2047
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 });
|
|
2048
2800
|
case '/ack': return runAck({ api, arg: restArg, slot: where.current.slot || null, say });
|
|
2049
2801
|
case '/send': { // QUOXON-MESH: a local text file into a slot (inline, 64 KB cap, secrets scrubbed on a yes)
|
|
@@ -2078,6 +2830,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2078
2830
|
return takeSlot(nm, take);
|
|
2079
2831
|
}
|
|
2080
2832
|
case '/attach': {
|
|
2833
|
+
const greetThis = takeGreeting(R.current); // read and clear first: an early return below must not leave a quiet flag armed for the next typed /attach
|
|
2081
2834
|
if (!restArg) return say(SN.current.agent ? `Attached to ${SN.current.agent}. /detach returns to CommanderQ.` : 'Talking to CommanderQ. Use /attach <agent> to pick one.');
|
|
2082
2835
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2083
2836
|
if (!api.agents) return say('Agents are not available in this mode.', 'warn');
|
|
@@ -2094,7 +2847,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2094
2847
|
const m = pt.kind === 'slot' ? { matches: [] } : matchAgent(pt.name, list);
|
|
2095
2848
|
const slotLive = !!(slotRow && slotRow.holder_session);
|
|
2096
2849
|
let choice = null; // 'slot' | 'agent'
|
|
2097
|
-
if (slotRow && m.agent) choice = pt.kind === 'slot'
|
|
2850
|
+
if (slotRow && m.agent) choice = pt.kind === 'slot' ? 'slot' : 'agent'; // a bare name that is both defaults to the AGENT; slot: is the explicit override
|
|
2098
2851
|
else if (slotRow) choice = 'slot';
|
|
2099
2852
|
else if (m.agent) choice = 'agent';
|
|
2100
2853
|
if (!choice) {
|
|
@@ -2107,7 +2860,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2107
2860
|
const both = !!(slotRow && m.agent);
|
|
2108
2861
|
if (choice === 'slot') {
|
|
2109
2862
|
if (!api.slotSay) return say('Talking to a slot is not available in this mode.', 'warn');
|
|
2110
|
-
SN.current.agent = null; SN.current.slotTarget = slotRow.name;
|
|
2863
|
+
SN.current.agent = null; SN.current.slotTarget = slotRow.name; DC.current.detach();
|
|
2111
2864
|
where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
|
|
2112
2865
|
const host = slotRow.holder_identity ? String(slotRow.holder_identity).replace(/^resident:/, '') : (slotRow.host_name || slotRow.hostname || null); // RESTORE-TUI: a named host from an older server row still shows
|
|
2113
2866
|
const lbl = `slot ${slotRow.name}${host ? ` (${host})` : ''}`;
|
|
@@ -2119,11 +2872,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2119
2872
|
}
|
|
2120
2873
|
SN.current.slotTarget = null;
|
|
2121
2874
|
SN.current.agent = m.agent.id; where.current = { ...where.current, agent: m.agent.id }; bump(x => x + 1);
|
|
2122
|
-
|
|
2123
|
-
say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ
|
|
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'); });
|
|
2876
|
+
say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.`, 'ok');
|
|
2877
|
+
if (both && pt.kind !== 'agent') say(`${pt.name} is also a slot: /attach slot:${pt.name} for that`, 'warn');
|
|
2124
2878
|
if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
|
|
2125
2879
|
return undefined;
|
|
2126
2880
|
}
|
|
2881
|
+
case '/evidence': case '/ward': case '/volt': {
|
|
2882
|
+
for (const l of await runEvidenceCommand({ cmd, api, conversationId: S.current.ctx?.conversationId, cols })) say(l.text, l.tone);
|
|
2883
|
+
return undefined;
|
|
2884
|
+
}
|
|
2885
|
+
case '/envelope': case '/envelopes': case '/aee': { // pointers to /proof (the envelope ids) and /evidence (the bundle)
|
|
2886
|
+
say('Envelope ids are what /proof lists; /evidence exports them all as a signed bundle.', 'dim');
|
|
2887
|
+
return command(`/proof${restArg ? ` ${restArg}` : ''}`);
|
|
2888
|
+
}
|
|
2127
2889
|
case '/peek': { // PEEK: the same lookup as `quox peek <id>`, in this window's org
|
|
2128
2890
|
const pid = restArg.trim().split(/\s+/)[0] || '';
|
|
2129
2891
|
if (!api.peek) return say('Peek is not available in this mode. In a terminal: quox peek <id>', 'warn');
|
|
@@ -2180,6 +2942,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2180
2942
|
if (!api.connections) return say('Connections are not available in this mode.', 'warn');
|
|
2181
2943
|
const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
|
|
2182
2944
|
for (const [t, tone] of connectionLines(vault, caps)) say(clip(t, cols - 4), tone);
|
|
2945
|
+
// LAZY-AI-CLI: no Claude Code on this server = the Claude sign-in cannot work yet; offer it as a y/N card here
|
|
2946
|
+
if (api.providerCliStatus && missingTools(await api.providerCliStatus()).includes('claude-code')) { R.current.installQueue = []; return offerInstall('claude-code'); }
|
|
2183
2947
|
return undefined;
|
|
2184
2948
|
}
|
|
2185
2949
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
@@ -2229,6 +2993,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2229
2993
|
return undefined;
|
|
2230
2994
|
}
|
|
2231
2995
|
let r = selectModel(res, restArg, caps);
|
|
2996
|
+
// LAZY-AI-CLI: the Claude sign-in runs through the claude binary; no binary on this server = offer to install it now
|
|
2997
|
+
if ((r.choice?.via === 'subscription' || /^(claude-cli|claude-subscription|subscription|claude-code)\b/i.test(String(restArg).trim())) && api.providerCliStatus) {
|
|
2998
|
+
const st = await api.providerCliStatus();
|
|
2999
|
+
if (missingTools(st).includes('claude-code')) { R.current.installQueue = []; return offerInstall('claude-code'); }
|
|
3000
|
+
}
|
|
2232
3001
|
let installed = null;
|
|
2233
3002
|
if (r.ok && r.choice?.provider === 'ollama' && api.ollamaModels) { // B3: check against what that Ollama has installed
|
|
2234
3003
|
r = selectOllamaModel(await api.ollamaModels(), r.choice.model || null);
|
|
@@ -2256,6 +3025,49 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2256
3025
|
return undefined;
|
|
2257
3026
|
}
|
|
2258
3027
|
case '/row': return RW.current.handle(restArg);
|
|
3028
|
+
case '/qpe': { // TUI-QPE: how many proof engine rows show (a CLI setting)
|
|
3029
|
+
const r = qpeCommand(restArg, api.settings?.() || {});
|
|
3030
|
+
if (r.save != null) { api.saveSetting?.('qpeRows', r.save); bump(n => n + 1); }
|
|
3031
|
+
return say(r.text, r.tone);
|
|
3032
|
+
}
|
|
3033
|
+
case '/deck': { // provided by the quoxdeck extension when loaded; otherwise says where it comes from (the registry entry carries ext)
|
|
3034
|
+
if (await extDispatch(cmd, restArg)) return undefined;
|
|
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' });
|
|
3044
|
+
}
|
|
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;
|
|
3048
|
+
const list = readExtList();
|
|
3049
|
+
if (!w || w === 'list') {
|
|
3050
|
+
if (!list.length) return say('No extensions listed. Install one (npm i -g <pkg>) and list it: quox ext add <pkg>.');
|
|
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'));
|
|
3052
|
+
}
|
|
3053
|
+
if ((w === 'on' || w === 'off') && nm) {
|
|
3054
|
+
if (w === 'on') { if (!list.some(x => x.name === nm)) extAdd(nm); else extSetEnabled(nm, true); await extLoad(nm, false); return undefined; }
|
|
3055
|
+
extSetEnabled(nm, false); const r = DK.current.unload(nm); return say(r.text, 'ok');
|
|
3056
|
+
}
|
|
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');
|
|
3070
|
+
}
|
|
2259
3071
|
case '/watch': return KC.current.watch(restArg);
|
|
2260
3072
|
case '/automate': return KC.current.automate(restArg);
|
|
2261
3073
|
case '/crystallize': return KC.current.crystallize(restArg);
|
|
@@ -2277,9 +3089,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2277
3089
|
case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': case '/fleet': case '/prove': case '/replay': return undefined; // answered above (runObjectCommand)
|
|
2278
3090
|
case '/agent': case '/agents': return undefined; // answered above (picker, tools, card)
|
|
2279
3091
|
case '/which': return showWhich(arg);
|
|
2280
|
-
case '/detach':
|
|
3092
|
+
case '/detach': {
|
|
3093
|
+
const hadAgent = Boolean(SN.current.agent);
|
|
2281
3094
|
SN.current.agent = null; SN.current.slotTarget = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
|
|
2282
|
-
|
|
3095
|
+
DC.current.detach();
|
|
3096
|
+
say('Back to CommanderQ.');
|
|
3097
|
+
const g = detachGreeting({ settings: api.settings?.() || {}, env, rows: termRows(stdout), chromeH: chromeH.current ?? 10, hadAgent }); // dragons on: the Q dragon greets the return
|
|
3098
|
+
if (g?.clip) { playDragon(g.clip, undefined); bump(n => n + 1); } else if (g?.note) say(g.note, 'dim');
|
|
3099
|
+
return true;
|
|
3100
|
+
}
|
|
2283
3101
|
case '/sessions': {
|
|
2284
3102
|
// SESSIONS (owner 2026-10-15): name and where first; ids only with `refs`.
|
|
2285
3103
|
const flags = new Set(String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean));
|
|
@@ -2345,18 +3163,63 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2345
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');
|
|
2346
3164
|
if (cmd === '/gripe') { // ask first: filing makes a public-ish issue
|
|
2347
3165
|
R.current.pendingGripe = { text, at: Date.now() };
|
|
2348
|
-
|
|
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)` : ''}`);
|
|
2349
3168
|
return say('/gripe! files it · /gripe <new text> replaces it · anything else leaves it unfiled', 'dim');
|
|
2350
3169
|
}
|
|
2351
3170
|
R.current.pendingGripe = null;
|
|
2352
3171
|
say('Filing via GRIPE…', 'dim');
|
|
2353
3172
|
const g = await api.gripe(text.slice(0, 4000), { yes: true });
|
|
2354
|
-
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');
|
|
2355
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');
|
|
2356
3176
|
for (const n of g.notes || []) {
|
|
2357
|
-
if (n.kind === 'created') say(
|
|
3177
|
+
if (n.kind === 'created') { say(gripeFiledLine(n.url, text)); say(n.url, 'dim'); }
|
|
2358
3178
|
else say(clip(n.text, cols * 3), n.kind === 'warn' ? 'warn' : 'dim');
|
|
2359
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
|
+
}
|
|
2360
3223
|
return undefined;
|
|
2361
3224
|
}
|
|
2362
3225
|
case '/context':
|
|
@@ -2444,8 +3307,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2444
3307
|
if (!m) return say(DRAGON_USAGE);
|
|
2445
3308
|
if (m.mode === 'explain') return dragonExplain();
|
|
2446
3309
|
if (m.mode === 'ask') return dragonAsk(m.question);
|
|
3310
|
+
if (m.mode === 'companion') {
|
|
3311
|
+
const r = companionCommand(m.arg, { settings: api.settings?.() || {}, plan: DC.current.plan(), artProblem: DC.current.artProblem() });
|
|
3312
|
+
if (r.save != null) { api.saveSetting?.('dragonCompanion', r.save); DC.current.refresh(); }
|
|
3313
|
+
say(r.text, r.tone);
|
|
3314
|
+
if (r.invoke && await DC.current.invoke()) bump(n => n + 1); // asking about the companion is also calling it
|
|
3315
|
+
return true;
|
|
3316
|
+
}
|
|
2447
3317
|
if (m.mode === 'sleep') return dragonSleep();
|
|
2448
3318
|
if (m.mode === 'fly') return dragonFly();
|
|
3319
|
+
if (m.mode === 'pick') { // plain /dragon: the ATTACHED agent's own dragon when it has one, else the classic Quox dragon
|
|
3320
|
+
const shown = await DC.current.invoke();
|
|
3321
|
+
if (shown) { bump(n => n + 1); return true; }
|
|
3322
|
+
const who = SN.current.agent;
|
|
3323
|
+
if (who && !dragonForAgent(who)) say(`${who[0].toUpperCase()}${who.slice(1)} has no dragon of its own; here's Quox.`, 'dim');
|
|
3324
|
+
}
|
|
3325
|
+
const room = clipRoom(m.mode === 'idle' ? 'classic' : m.mode, termRows(stdout), chromeH.current ?? 10); if (!room.ok) return say(room.note, 'warn');
|
|
2449
3326
|
playDragon(m.mode, undefined); bump(n => n + 1); return true;
|
|
2450
3327
|
}
|
|
2451
3328
|
case '/dragon-fly': return arg ? say('Usage: /dragon-fly', 'warn') : dragonFly();
|
|
@@ -2484,7 +3361,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2484
3361
|
case '/about': return dispatch({ type: 'welcome', version: packageVersion() });
|
|
2485
3362
|
case '/help': return dispatch({ type: 'help', model: helpModel(arg, R.current.caps) });
|
|
2486
3363
|
case '/clear': case '/new': case '/reset': {
|
|
2487
|
-
if (S.current.turn)
|
|
3364
|
+
if (S.current.turn || R.current.waitingServer) stopTurnNow(); // cancel it exactly as Esc does, so busy and the divider reset
|
|
2488
3365
|
const prev = S.current.ctx.conversationId;
|
|
2489
3366
|
dispatch({ type: 'ctx', ctx: { conversationId: null } });
|
|
2490
3367
|
SN.current.name = null; SN.current.saved = null;
|
|
@@ -2493,6 +3370,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2493
3370
|
setEpoch(e => e + 1);
|
|
2494
3371
|
return say(prev ? 'Previous conversation saved. /resume to go back.' : 'New conversation.');
|
|
2495
3372
|
}
|
|
3373
|
+
case '/stop': case '/cancel': { // same as Esc
|
|
3374
|
+
if (!S.current.turn && !R.current.waitingServer) return say('Nothing is running.', 'dim');
|
|
3375
|
+
stopTurnNow();
|
|
3376
|
+
return say('Stopped.', 'ok');
|
|
3377
|
+
}
|
|
2496
3378
|
case '/aocl': return dispatch({ type: 'aocl.table' });
|
|
2497
3379
|
case '/verbose':
|
|
2498
3380
|
if (!['off', 'on', 'full'].includes(arg)) return say(`verbose is ${S.current.verbose}. Use /verbose off|on|full`);
|
|
@@ -2556,11 +3438,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2556
3438
|
const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
|
|
2557
3439
|
let list;
|
|
2558
3440
|
try { list = await api.orgs(); } catch (e) { return say(`Couldn't list your orgs: ${listFailure(e, profile)}`, 'warn'); }
|
|
3441
|
+
const openOrgPicker = (rowsIn, title, { confirm = false } = {}) => { // confirm: the explicit `/org switch` chooser keeps its y/N
|
|
3442
|
+
const orows = orgRows(rowsIn);
|
|
3443
|
+
const ow = Math.min(30, Math.max(4, ...orows.map(x => [...x.name].length)));
|
|
3444
|
+
PK.current.open({ title, rows: orows.map(x => ({ key: `${x.id === curId ? '* ' : ' '}${x.name.padEnd(ow)}`, text: `${x.slug || x.id}${x.role ? ` (${x.role})` : ''}` })), sel: Math.max(0, orows.findIndex(x => x.id === curId)),
|
|
3445
|
+
hint: confirm ? '↑↓ select · Enter switch (you confirm first) · Esc cancel' : 'Enter: switch this window only · d: also make it the default · ↑↓ select · Esc cancel',
|
|
3446
|
+
onChar: confirm ? undefined : (ch, i) => { if (ch !== 'd' && ch !== 'D') return false; switchOrgNow(orows[i], { quick: true, makeDefault: true }); PK.current.close(); return true; },
|
|
3447
|
+
onPick: (_, i) => (confirm ? (orows[i].id === curId ? say(`Already in ${orows[i].name}.`) : command(`/org switch ${orows[i].slug || orows[i].id}`)) : switchOrgNow(orows[i], { quick: true })) });
|
|
3448
|
+
};
|
|
2559
3449
|
if (!sub || sub === 'list' || (sub === 'switch' && !query)) { // a bare /org switch offers the chooser, never a Usage line
|
|
2560
3450
|
if (!list.length) return say('No orgs returned for this login.', 'warn');
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
3451
|
+
openOrgPicker(list, 'Move this session to an org', { confirm: sub === 'switch' });
|
|
3452
|
+
return undefined;
|
|
3453
|
+
}
|
|
3454
|
+
if (!['yes', 'back', '-', 'switch'].includes(sub)) { // /org <name or slug fragment>: a unique match switches this window, else the picker opens filtered
|
|
3455
|
+
const q = restArg.trim();
|
|
3456
|
+
const m = matchOrg(q, list);
|
|
3457
|
+
if (m.org) return switchOrgNow(m.org, { quick: true });
|
|
3458
|
+
const part = orgCandidates(q, list); // verb stripped, best-matching token: never a dead end on a typo
|
|
3459
|
+
if (!part.length) return say(`No org of yours matches "${q}". /org lists them.`, 'warn');
|
|
3460
|
+
openOrgPicker(part, `Orgs matching "${q}"`);
|
|
2564
3461
|
return undefined;
|
|
2565
3462
|
}
|
|
2566
3463
|
if (sub === 'yes') {
|
|
@@ -2568,16 +3465,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2568
3465
|
if (!t) return say('Nothing to confirm. /org switch <name> first.', 'warn');
|
|
2569
3466
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2570
3467
|
if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
|
|
2571
|
-
|
|
2572
|
-
try { done = await api.switchOrg(t.id); } catch (e) { return say(`Not switched. ${e?.message || 'The server refused.'} You are still in ${R0.orgName ?? org ?? 'the same org'}.`, 'warn'); }
|
|
2573
|
-
R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
|
|
2574
|
-
R0.orgId = done.id; R0.orgName = done.name;
|
|
2575
|
-
dispatch({ type: 'ctx', ctx: { conversationId: null } });
|
|
2576
|
-
SN.current.name = null; SN.current.saved = null;
|
|
2577
|
-
dispatch({ type: 'history.reset' });
|
|
2578
|
-
if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
2579
|
-
setEpoch(e => e + 1); bump(n => n + 1);
|
|
2580
|
-
return say(`Switched to ${done.name}. New conversation in that org.`, 'ok');
|
|
3468
|
+
return switchOrgNow(t);
|
|
2581
3469
|
}
|
|
2582
3470
|
if (sub === 'back' || sub === '-') { // owner 2026-10-15 "switch back": no "type /org switch <the org you were in>"
|
|
2583
3471
|
const p = R0.prevOrg;
|
|
@@ -2599,6 +3487,71 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2599
3487
|
if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
|
|
2600
3488
|
return ask(`Switch to ${m.org.name}?`, { action: `/org switch ${m.org.id} (${m.org.name})`, reason: 'This starts a new conversation in that org.' });
|
|
2601
3489
|
}
|
|
3490
|
+
case '/todo': case '/todos': {
|
|
3491
|
+
if (!api.todoAdd || !api.todoList) return say('This server has no Action Tracker.', 'warn');
|
|
3492
|
+
const t = parseTodo(cmd, restArg);
|
|
3493
|
+
try {
|
|
3494
|
+
if (t.kind === 'usage') return say(t.text, 'warn');
|
|
3495
|
+
if (t.kind === 'add') return say(todoAddedLine(await api.todoAdd(t.text)), 'ok');
|
|
3496
|
+
if (t.kind === 'move') {
|
|
3497
|
+
if (!api.todoMove) return say('This server cannot move Action Tracker items.', 'warn');
|
|
3498
|
+
const board = await api.todoList();
|
|
3499
|
+
const many = (t.refs || [t.ref]).length > 1;
|
|
3500
|
+
for (const ref of (t.refs || [t.ref])) {
|
|
3501
|
+
const r = resolveRef(ref, TODO_MAP.current, board);
|
|
3502
|
+
if (r.error) { say(r.error, 'warn'); continue; }
|
|
3503
|
+
if (r.note) say(r.note);
|
|
3504
|
+
const moved = await api.todoMove(r.id, t.to);
|
|
3505
|
+
say(`${todoMovedLine({ ...moved, title: moved.title || r.title })}${many ? '' : ' /todos shows the new numbers.'}`, 'ok');
|
|
3506
|
+
}
|
|
3507
|
+
return undefined;
|
|
3508
|
+
}
|
|
3509
|
+
const board = await api.todoList();
|
|
3510
|
+
const numbered = numberBoard(board);
|
|
3511
|
+
TODO_MAP.current = numbered.map;
|
|
3512
|
+
return say(todoBoardText(board, numbered));
|
|
3513
|
+
} catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
|
|
3514
|
+
}
|
|
3515
|
+
case '/done': {
|
|
3516
|
+
if (!api.todoList || !api.todoMove) return say('This server cannot move Action Tracker items.', 'warn');
|
|
3517
|
+
const d = parseDone(restArg);
|
|
3518
|
+
if (d.kind === 'usage') return say(d.text, 'warn');
|
|
3519
|
+
try {
|
|
3520
|
+
const board = await api.todoList();
|
|
3521
|
+
for (const ref of d.refs) {
|
|
3522
|
+
const r = resolveRef(ref, TODO_MAP.current, board);
|
|
3523
|
+
if (r.error) { say(r.error, 'warn'); continue; }
|
|
3524
|
+
if (r.note) say(r.note);
|
|
3525
|
+
const moved = await api.todoMove(r.id, 'done');
|
|
3526
|
+
say(todoDoneLine(moved.title || r.title), 'ok');
|
|
3527
|
+
}
|
|
3528
|
+
return undefined;
|
|
3529
|
+
} catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
|
|
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
|
+
}
|
|
3539
|
+
case '/plugin': case '/extensions': case '/extension': case '/addons': case '/apps': case '/plugins': {
|
|
3540
|
+
if (!api.pluginList) return say('This server has no plugin list.', 'warn');
|
|
3541
|
+
const want = /^(list|ls|all)?$/i.test(restArg.trim()) ? '' : restArg.trim().split(/\s+/)[0]; // "/extension list" is just the list
|
|
3542
|
+
try {
|
|
3543
|
+
if (want) {
|
|
3544
|
+
const one = (api.pluginGet ? await api.pluginGet(want) : null) || (await api.pluginList()).find(r => r.plugin_id === want || r.id === want);
|
|
3545
|
+
if (!one) return say(`No installed plugin named "${want}". /plugins lists them.`, 'warn');
|
|
3546
|
+
const on = one.is_enabled !== false && one.is_enabled !== 0;
|
|
3547
|
+
return say([`${one.plugin_id || want} ${on ? 'enabled' : 'disabled'} v${one.version || '?'}`, ...(one.installed_by ? [`installed by ${one.installed_by}${one.installed_at ? ` on ${String(one.installed_at).slice(0, 10)}` : ''}`] : []), ...(one.upgraded_at ? [`upgraded ${String(one.upgraded_at).slice(0, 10)}`] : [])].join('\n'));
|
|
3548
|
+
}
|
|
3549
|
+
const list = await api.pluginList();
|
|
3550
|
+
if (!list.length) return say('No plugins installed for this org.');
|
|
3551
|
+
const w = Math.max(...list.map(r => String(r.plugin_id || '').length));
|
|
3552
|
+
return say([`${list.length} plugin${list.length === 1 ? '' : 's'} (extensions). /plugins <name> for detail:`, ...list.map(r => ` ${String(r.plugin_id || '?').padEnd(w)} ${r.is_enabled === false || r.is_enabled === 0 ? 'disabled' : 'enabled '} v${r.version || '?'}`)].join('\n'));
|
|
3553
|
+
} catch (e) { return say(`Couldn't list plugins: ${e?.message || 'failed'}`, 'warn'); }
|
|
3554
|
+
}
|
|
2602
3555
|
case '/skills': {
|
|
2603
3556
|
if (!api.skills) return say('This server has no skills list.', 'warn');
|
|
2604
3557
|
try {
|
|
@@ -2631,6 +3584,36 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2631
3584
|
}
|
|
2632
3585
|
return submit(skillPrompt(arg, argRest));
|
|
2633
3586
|
}
|
|
3587
|
+
case '/loop': { // QLOOP-LIVE: list | watch <id> | peek <id> | focus
|
|
3588
|
+
const [sub0, ...restW] = String(restArg || '').trim().split(/\s+/).filter(Boolean);
|
|
3589
|
+
const lid = restW[0];
|
|
3590
|
+
if (!sub0 || sub0 === 'list') {
|
|
3591
|
+
const rows = (R.current.roster.allRows || []).filter(r => r.kind === 'loop');
|
|
3592
|
+
if (!rows.length) return say('No loops running or recent. Ask Quox to run something until it is done, and say yes to the loop offer.');
|
|
3593
|
+
for (const r of rows) say(`∞ ${r.id} │ ${r.name} │ ${r.state}${r.activity ? `, ${r.activity}` : ''}${LC.current.get(r.id) ? ' (card on screen)' : ''}`, r.state === 'failed' ? 'error' : undefined);
|
|
3594
|
+
return say('/loop watch <id> puts a live card in this chat. /loop peek <id> prints one snapshot.');
|
|
3595
|
+
}
|
|
3596
|
+
if (sub0 === 'focus') { const c = LC.current.focus(lid || null); return c ? say(`Keys on the loop card: Enter expands the output, s halts (y confirms), Esc leaves.`) : say('No loop card on screen. /loop watch <id> starts one.', 'warn'); }
|
|
3597
|
+
if (sub0 === 'watch') {
|
|
3598
|
+
if (!lid) return say('Usage: /loop watch <id>', 'warn');
|
|
3599
|
+
if (!api.loopEvents) return say('/loop watch is not available in this mode.', 'warn');
|
|
3600
|
+
const known = (R.current.roster.allRows || []).find(r => r.id === lid);
|
|
3601
|
+
const had = LC.current.get(lid);
|
|
3602
|
+
LC.current.watch(lid, { title: known?.name || '' });
|
|
3603
|
+
LC.current.focus(lid);
|
|
3604
|
+
return had ? say('That loop already has a card above; it has focus. Enter expands, s halts, Esc leaves.') : undefined; // the card's own last line lists the keys
|
|
3605
|
+
}
|
|
3606
|
+
if (sub0 === 'peek') {
|
|
3607
|
+
if (!lid) return say('Usage: /loop peek <id>', 'warn');
|
|
3608
|
+
try {
|
|
3609
|
+
const snap = await api.loopSnapshot(lid);
|
|
3610
|
+
const st = applyLoopSnapshot(initLoop({ id: lid, title: snap?.title || '' }), snap);
|
|
3611
|
+
for (const rs of cardLines(st, { cols: Math.max(40, cols - 4) })) say(rs.map(x => x.t).join(''));
|
|
3612
|
+
} catch (e) { say(`loop ${lid}: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}`, 'warn'); }
|
|
3613
|
+
return undefined;
|
|
3614
|
+
}
|
|
3615
|
+
return say('Usage: /loop list | watch <id> | peek <id> | focus', 'warn');
|
|
3616
|
+
}
|
|
2634
3617
|
case '/roster': {
|
|
2635
3618
|
const ro = R.current.roster;
|
|
2636
3619
|
if (arg === 'all') {
|
|
@@ -2643,6 +3626,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2643
3626
|
for (const src of ro.sources || []) {
|
|
2644
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);
|
|
2645
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
|
+
}
|
|
2646
3633
|
if (ro.sources) say('Down on an empty composer focuses the roster. Enter peeks, s stops (y confirms), Esc returns.');
|
|
2647
3634
|
return undefined;
|
|
2648
3635
|
}
|
|
@@ -2669,17 +3656,65 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2669
3656
|
return dispatch({ type: 'object', obj: { type: 'sitrep', data: sitrepData(r.situation, r.approvals) } });
|
|
2670
3657
|
}
|
|
2671
3658
|
case '/afk': { // owner 2026-10-15: "/afk need a break, keep my requests organised for my return"
|
|
2672
|
-
|
|
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();
|
|
2673
3661
|
try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
|
|
2674
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');
|
|
2675
|
-
const pr = await setPresence('away', restArg);
|
|
3663
|
+
const pr = await setPresence('away', restArg, { dnd: true });
|
|
2676
3664
|
if (!pr.ok) presenceFail(pr);
|
|
2677
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
|
+
}
|
|
2678
3697
|
return undefined;
|
|
2679
3698
|
}
|
|
2680
|
-
case '/back': { // PRESENCE: present on the server too, then the digest
|
|
2681
|
-
|
|
2682
|
-
|
|
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 });
|
|
2683
3718
|
if (!pr.ok) presenceFail(pr);
|
|
2684
3719
|
return command('/away');
|
|
2685
3720
|
}
|
|
@@ -2689,7 +3724,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2689
3724
|
if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
|
|
2690
3725
|
const r = await api.slotSay('quoxon', restArg.trim(), { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
|
|
2691
3726
|
if (!r.ok) return say(`observe: not delivered (${r.message})`, 'warn');
|
|
2692
|
-
return say(
|
|
3727
|
+
return say(`✔ noted for the observer (quoxon${r.viaOrgName ? ` in ${r.viaOrgName}` : ''})`, 'ok');
|
|
2693
3728
|
}
|
|
2694
3729
|
case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
|
|
2695
3730
|
const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
|
|
@@ -2720,16 +3755,94 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2720
3755
|
catch (e) { dispatch({ type: 'error', text: `observers: ${e.message}` }); }
|
|
2721
3756
|
return undefined;
|
|
2722
3757
|
}
|
|
3758
|
+
case '/inbox': { // HITL-INBOX-BAR: the owner's Quox inbox, in the chat
|
|
3759
|
+
if (!api.inboxItems) return say('The inbox is not available in this mode.', 'warn');
|
|
3760
|
+
const q = parseInbox(restArg);
|
|
3761
|
+
if (q.kind === 'usage') return say(INBOX_USAGE, 'warn');
|
|
3762
|
+
let items;
|
|
3763
|
+
try { items = await api.inboxItems({ limit: 30 }); }
|
|
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` }); }
|
|
3765
|
+
const top = newestFirst(items).slice(0, 10);
|
|
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
|
+
}
|
|
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; };
|
|
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 });
|
|
3791
|
+
return undefined;
|
|
3792
|
+
}
|
|
3793
|
+
const it = pick(q.n); if (!it) return undefined;
|
|
3794
|
+
if (q.kind === 'open' && isApprovalItem(it)) {
|
|
3795
|
+
let flat = []; try { flat = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [] }).flatMap((g) => g.items.map((x) => x.item.id)); } catch { /* falls to /approvals below */ }
|
|
3796
|
+
const at = flat.indexOf(it.id);
|
|
3797
|
+
return command(at >= 0 ? `/approvals ${at + 1}` : '/approvals');
|
|
3798
|
+
}
|
|
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'); }
|
|
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);
|
|
3819
|
+
return undefined;
|
|
3820
|
+
}
|
|
2723
3821
|
case '/approvals': {
|
|
2724
3822
|
if (/^help$/i.test(restArg.trim())) { // the syntax lives here, not above every list
|
|
2725
|
-
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')})`);
|
|
2726
3824
|
return undefined;
|
|
2727
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
|
+
}
|
|
2728
3835
|
let items;
|
|
2729
|
-
try { items = await api.approvals(); }
|
|
2730
|
-
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` }); }
|
|
2731
3836
|
let stats = null; let statsErr = null;
|
|
2732
|
-
|
|
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'); };
|
|
2733
3846
|
const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
|
|
2734
3847
|
const groups = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds });
|
|
2735
3848
|
const flat = groups.flatMap(g => g.items.map(x => x.item));
|
|
@@ -2737,9 +3850,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2737
3850
|
R.current.shownIds = flat.map(x => x.id);
|
|
2738
3851
|
dispatch({ type: 'notify', patch: { approvals: flat.length, error: null } });
|
|
2739
3852
|
const hidden = stats ? Math.max(0, stats.awaiting - flat.length) : 0;
|
|
2740
|
-
if (!flat.length && !hidden && !statsErr) return say('Nothing waiting for you.', 'ok');
|
|
2741
3853
|
const fArg = restArg.trim();
|
|
2742
|
-
if (
|
|
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; }
|
|
3856
|
+
if (/^walkthrough$/i.test(fArg)) { // plain approvals walk as cards too, after any session questions
|
|
3857
|
+
const plain = flat.filter(x => !isDecision(x));
|
|
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();
|
|
3860
|
+
return walkStart(flat.map(approvalAsWalkItem), { fresh: true });
|
|
3861
|
+
}
|
|
2743
3862
|
if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
|
|
2744
3863
|
const it = flat[Number(fArg) - 1];
|
|
2745
3864
|
if (!it) return say(`No such item: [${fArg}] (${flat.length} listed).`, 'warn');
|
|
@@ -2766,6 +3885,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2766
3885
|
}
|
|
2767
3886
|
if (flat.length && !shown.size) say(`Nothing matches "${fArg}".`, 'warn');
|
|
2768
3887
|
if (flat.length) say(' ');
|
|
3888
|
+
if (shown.size && !(nDec && !fArg)) R.current.listShownAt = Date.now(); // a list, not a card: a bare y/n right after it has no target
|
|
2769
3889
|
if (nDec && !fArg) walkStart(flat, { fresh: true, quiet: true });
|
|
2770
3890
|
if (hidden) {
|
|
2771
3891
|
const others = humanProfiles.filter(h => h !== profile);
|
|
@@ -2773,6 +3893,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2773
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');
|
|
2774
3894
|
if (agentLogin) offerSwitch(); // one keypress instead of quit and relaunch
|
|
2775
3895
|
}
|
|
3896
|
+
sayMoreOrgs();
|
|
2776
3897
|
if (statsErr) say(`Could not check the org-wide approval count (${statsErr}); the list above is what the inbox returned.`, 'warn');
|
|
2777
3898
|
return undefined;
|
|
2778
3899
|
}
|
|
@@ -2786,7 +3907,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2786
3907
|
}
|
|
2787
3908
|
case '/mouse': {
|
|
2788
3909
|
const v = arg.trim().toLowerCase();
|
|
2789
|
-
if (!v) return say(`Mouse is ${MO.current.on ? 'on' : 'off'}. /mouse ${MO.current.on ? 'off' : 'on'} to change it
|
|
3910
|
+
if (!v) return say(`Mouse is ${MO.current.on ? 'on' : 'off'}. /mouse ${MO.current.on ? 'off' : 'on'} to change it.${MO.current.on ? ` ${MOUSE_HINT}` : ''}`);
|
|
2790
3911
|
if (v !== 'on' && v !== 'off') return say('Usage: /mouse on|off', 'warn');
|
|
2791
3912
|
MO.current.on = v === 'on'; MO.current.drag = null;
|
|
2792
3913
|
api.saveSetting?.('mouse', v);
|
|
@@ -2810,16 +3931,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2810
3931
|
return say(m.text, m.tone || undefined);
|
|
2811
3932
|
}
|
|
2812
3933
|
case '/sleep': { // owner typed /sleep first (2026-10-16); it now MEANS sleeping: sessions stop waiting on approvals
|
|
2813
|
-
|
|
2814
|
-
try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
|
|
2815
|
-
const pr = await setPresence('sleeping');
|
|
2816
|
-
if (!pr.ok) presenceFail(pr);
|
|
3934
|
+
await sleepNow();
|
|
2817
3935
|
return command('/dragon sleep');
|
|
2818
3936
|
}
|
|
3937
|
+
case '/broadcast-active': return command(`/broadcast --active${restArg ? ` ${restArg}` : ''}`);
|
|
2819
3938
|
case '/roar': return command(`/broadcast${restArg ? ` ${restArg}` : ''}`);
|
|
2820
3939
|
case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
|
|
2821
3940
|
case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
|
|
2822
3941
|
case '/huh': return command(`/dragon explain${restArg ? ` ${restArg}` : ''}`); // owner 2026-10-15: shortcut for /dragon explain
|
|
3942
|
+
case '/devservers': { // DEVFLEET: list the dev servers. Handing the terminal to a remote session from inside the TUI is not done here: print the exact command.
|
|
3943
|
+
if (!api.devServers) return say('/devservers is not available in this mode.', 'warn');
|
|
3944
|
+
let rows; try { rows = await api.devServers(); } catch (e) { return say(`Couldn't list servers: ${e?.message || 'failed'}`, 'warn'); }
|
|
3945
|
+
if (!rows.length) return say('No dev servers registered. In a terminal: quox server mark <host> --purpose "main dev"');
|
|
3946
|
+
const pick = restArg.trim();
|
|
3947
|
+
if (pick) {
|
|
3948
|
+
const r = rows.find(x => x.name === pick);
|
|
3949
|
+
if (!r) return say(`No dev server "${pick}". /devservers lists them.`, 'warn');
|
|
3950
|
+
return say(`To open a Quox window on ${r.name}, run this in a terminal (the TUI does not hand over the terminal yet):\nquox server go ${r.name}`);
|
|
3951
|
+
}
|
|
3952
|
+
const lines = rows.map(r => `${r.current ? '*' : ' '} ${r.name} ${r.purpose || '-'} ${r.project || '-'} ${r.reachable ? 'reachable' : 'unreachable'}`);
|
|
3953
|
+
return say(`${lines.join('\n')}\n* this server. /devservers <name> shows the command to open a Quox window there (quox server go <name>).`);
|
|
3954
|
+
}
|
|
2823
3955
|
case '/slots': return listSlots(/^all$/i.test(restArg.trim()));
|
|
2824
3956
|
case '/mirror': {
|
|
2825
3957
|
// The Mirror itself: this org's server-built Mirror, what CommanderQ is told every turn (not /mind).
|
|
@@ -2836,6 +3968,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2836
3968
|
if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
|
|
2837
3969
|
await SUC.current.authenticate(); bump(n => n + 1); return undefined;
|
|
2838
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
|
|
2839
3972
|
case '/override': {
|
|
2840
3973
|
const res = await SUC.current.override(restArg);
|
|
2841
3974
|
if (res?.override_id && res.enforced) R.current.override = { id: res.override_id, until: Date.parse(res.expires_at) || Date.now() + 300000 };
|
|
@@ -2849,7 +3982,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2849
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` }); } }
|
|
2850
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);
|
|
2851
3984
|
if (needs.length === 1) authArg = String(needs[0]);
|
|
2852
|
-
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');
|
|
2853
3989
|
}
|
|
2854
3990
|
R.current.authorising = true; // /approve's answer() asks for the PIN first when there is no live step-up
|
|
2855
3991
|
try { await command(`/approve ${authArg}`); } finally { R.current.authorising = false; }
|
|
@@ -2898,12 +4034,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2898
4034
|
const okIds = []; const failedK = [];
|
|
2899
4035
|
for (const k of pa.ns) {
|
|
2900
4036
|
const x = list[k - 1];
|
|
2901
|
-
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 }); };
|
|
2902
4038
|
try {
|
|
2903
4039
|
try { await go(); }
|
|
2904
4040
|
catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
|
|
2905
4041
|
okIds.push(x.id); walkAnsweredIds().add(x.id);
|
|
2906
|
-
say(`✔ answered [${k}] ${x.payload.session || 'session'}: ${clip(x.title || x.id, Math.max(20, cols - 40))} -> (${keyOf(k)})`, 'ok');
|
|
4042
|
+
say(`✔ answered [${k}] ${x.payload.session || 'session'}: ${clip(decodeEntities(x.title || x.id), Math.max(20, cols - 40))} -> (${keyOf(k)})`, 'ok');
|
|
2907
4043
|
if (x.payload.slot && api.slotSay) await api.slotSay(String(x.payload.slot).toLowerCase(), `Your question "${clip(x.title, 80)}" was answered: ${keyOf(k)}. Read it with: quox ask status ${x.id}`, { from: myLabel() });
|
|
2908
4044
|
} catch (e) { failedK.push(`[${k}] ${e?.status === 404 || e?.status === 400 ? 'already handled or expired' : e.message}`); if (e?.stepUpDeclined) break; }
|
|
2909
4045
|
}
|
|
@@ -2921,7 +4057,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2921
4057
|
}
|
|
2922
4058
|
const it = list[pa.n - 1];
|
|
2923
4059
|
if (!it) return say(`No such item: [${pa.n}] (${list.length} listed).`, 'warn');
|
|
2924
|
-
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
|
+
}
|
|
2925
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');
|
|
2926
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; }
|
|
2927
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
|
|
@@ -2930,7 +4070,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2930
4070
|
const subject = { line1: `answer ${String(it.title || "")}`, raisedBy: it.payload.session || null, risk: 'high' };
|
|
2931
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; } };
|
|
2932
4072
|
if (gate && !SU.current.token('approve')) await askPin();
|
|
2933
|
-
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') }); };
|
|
2934
4074
|
try { await go(); }
|
|
2935
4075
|
catch (e) {
|
|
2936
4076
|
if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
|
|
@@ -2943,7 +4083,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2943
4083
|
return dispatch({ type: 'error', text: `answer: ${e.message}` });
|
|
2944
4084
|
}
|
|
2945
4085
|
say(' ');
|
|
2946
|
-
say(`✔ answered [${pa.n}] ${it.payload.session || 'session'}: ${clip(it.title || it.id, Math.max(20, cols - 40))} -> (${pa.key}) ${it.payload.options.find(o => o.key === pa.key).label}${pa.note ? ` (note: ${clip(pa.note, 40)})` : ''}`, 'ok');
|
|
4086
|
+
say(`✔ answered [${pa.n}] ${it.payload.session || 'session'}: ${clip(decodeEntities(it.title || it.id), Math.max(20, cols - 40))} -> (${pa.key}) ${decodeEntities(it.payload.options.find(o => o.key === pa.key).label)}${pa.note ? ` (note: ${clip(pa.note, 40)})` : ''}`, 'ok');
|
|
2947
4087
|
say(' ');
|
|
2948
4088
|
R.current.lastAnswered = it.id; walkAnsweredIds().add(it.id);
|
|
2949
4089
|
if (it.payload.slot && api.slotSay) { // WALK-FIXES: the answer is already recorded; the slot post never holds up the next card
|
|
@@ -2995,12 +4135,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2995
4135
|
if (!targets.length) return say(`Nothing waiting from "${agent}". ${mark('/approvals agents')} shows who is asking.`, 'warn');
|
|
2996
4136
|
}
|
|
2997
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 });
|
|
2998
|
-
const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = [];
|
|
4138
|
+
const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = []; const heldDel = [];
|
|
2999
4139
|
const heldDecisions = []; const done = []; const heldPriv = []; const failed = []; const missing = []; const gone = [];
|
|
3000
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
|
|
3001
4141
|
for (const k of targets) {
|
|
3002
4142
|
const it = list[k - 1];
|
|
3003
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
|
+
}
|
|
3004
4151
|
if (isDecision(it)) {
|
|
3005
4152
|
if (all || agent) { heldDecisions.push(k); continue; } // a bulk approve never answers a human question for you
|
|
3006
4153
|
if (verb === 'approve' && bulkish && isHeldFromBulk(it)) { heldRisky.push(k); continue; } // risky asks are never part of a bulk yes
|
|
@@ -3011,8 +4158,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3011
4158
|
try {
|
|
3012
4159
|
const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
|
|
3013
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) });
|
|
3014
|
-
if (r?.gone) gone.push(k); else if (r?.ok) done.push(k);
|
|
3015
|
-
} catch (e) {
|
|
4161
|
+
if (r?.gone) gone.push(k); else if (r?.ok) { done.push(k); R.current.lastAnswered = it.id; }
|
|
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
|
+
}
|
|
3016
4166
|
}
|
|
3017
4167
|
if (done.length || gone.length || failed.length || missing.length) say(' ');
|
|
3018
4168
|
for (const k of done) say(`${verb === 'approve' ? '✔ approved' : '✔ denied'} ${label(k)}${reason ? ` (reason: ${clip(reason, 40)})` : ''}`, 'ok');
|
|
@@ -3033,13 +4183,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3033
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');
|
|
3034
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
|
|
3035
4185
|
for (const k of ynHeld) say(` [${k}] ${list[k - 1].title || list[k - 1].id}`, 'dim');
|
|
3036
|
-
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 };
|
|
3037
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' });
|
|
3038
4188
|
}
|
|
3039
4189
|
if (walkKs.length) {
|
|
3040
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');
|
|
3041
|
-
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
|
|
3042
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');
|
|
3043
4194
|
if (heldPriv.length && (all || agent)) say(`${heldPriv.length} privileged left: ${heldPriv.map(k => `${cmd} ${k} confirm`).join(', ')}`, 'warn');
|
|
3044
4195
|
else if (heldPriv.length) { // ask inline instead of sending the user to a second command
|
|
3045
4196
|
R.current.pendingGrant = { cmd, verb, items: heldPriv.map(k => list[k - 1]), at: Date.now() };
|
|
@@ -3053,7 +4204,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3053
4204
|
return undefined;
|
|
3054
4205
|
}
|
|
3055
4206
|
default: {
|
|
4207
|
+
if (await extDispatch(cmd, restArg)) return undefined;
|
|
3056
4208
|
const e = findEntry(cmd);
|
|
4209
|
+
if (e?.ext) return say(`${e.usage.split(/\s/)[0]} comes from the ${e.ext} extension, which is not loaded. npm i -g ${e.ext}, then quox ext add ${e.ext}, or /ext on ${e.ext}.`, 'warn');
|
|
3057
4210
|
if (e && e.status === 'planned') return say(`${e.usage} is planned, not built yet. /help shows what works today.`, 'warn');
|
|
3058
4211
|
if (!e && await slotNameCommand(cmd, restArg)) return undefined;
|
|
3059
4212
|
const cliHit = resolveCliSlash(cmd, restArg); // QUOXON-FW: slash words that name a real quox CLI leaf
|
|
@@ -3066,7 +4219,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3066
4219
|
if (!near.length) return say(`Unknown command ${cmd}. Try /help`, 'warn');
|
|
3067
4220
|
// spelling-only hints keep the plain "/a or /b" line; keyword hints say what each command does
|
|
3068
4221
|
const byWords = near.some(x => x.kw);
|
|
3069
|
-
|
|
4222
|
+
const viaAlias = (x) => { const en = findEntry(x.name); return en && en.id !== x.name.slice(1) ? `${x.name} (${en.id})` : x.name; }; // an alias names the command it runs
|
|
4223
|
+
say(`Unknown command ${cmd}. Did you mean ${byWords ? near.map(x => (x.desc ? `${x.name} (${x.desc})` : x.name)).join(', ') : near.map(viaAlias).join(' or ')}? (/help lists them)`, 'warn');
|
|
3070
4224
|
if (near.length === 1) { // one strong match: offer it as a y/N card (vetted, never a human-typed command)
|
|
3071
4225
|
const v = vetSuggestion(near[0].name);
|
|
3072
4226
|
if (v.ok) { R.current.suggestAsk = { line: v.line, at: Date.now() }; ask(`Run ${v.line}?`, { action: v.line, reason: 'Nearest command to what you typed.' }); }
|
|
@@ -3098,10 +4252,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3098
4252
|
function openAskStates() {
|
|
3099
4253
|
const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
|
|
3100
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
|
|
3101
|
-
return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.profileAsk, R.current.cliAsk, R.current.updateAsk, R.current.orgPending, R.current.hatPending, HW.current.card(), RW.current.walk.card(), R.current.suggestAsk && !R.current.suggestLine ? R.current.suggestAsk : null, R.current.handoffAsk, R.current.askQuox, R.current.strayAsk, R.current.planAsk, SC.current.pendingFile, SC.current.reattach, CH.current.pending, SPN.current.pending].map(fresh)].filter(Boolean);
|
|
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);
|
|
3102
4256
|
}
|
|
3103
|
-
const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || UW.current.pending?.() || runAskOpen();
|
|
3104
|
-
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?.(); }
|
|
3105
4259
|
// ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): each live ask card is
|
|
3106
4260
|
// bound to the open state it asks about; once that state is no longer open the card goes and ONE line stays:
|
|
3107
4261
|
// "✔ Wake it now? yes (00:44)", "⌛ ... expired, not answered", "↷ ... no longer open". Run requests likewise.
|
|
@@ -3131,6 +4285,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3131
4285
|
if (askStateOpen(st)) continue;
|
|
3132
4286
|
bind.delete(cd.id);
|
|
3133
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
|
|
3134
4289
|
if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
|
|
3135
4290
|
takeAnswer();
|
|
3136
4291
|
if (st.walkWhy === 'stop') dispatch({ type: 'ask.settle', id: cd.id, text: 'The question closed before you answered.', tone: 'plain' });
|
|
@@ -3168,6 +4323,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3168
4323
|
if (now - K.current.lastPasteAt < PASTE_GUARD_MS) return; // post-paste guard
|
|
3169
4324
|
if (now - K.current.cardGoneAt < CARD_GONE_MS) return; // keys meant for the card must not send a turn
|
|
3170
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")
|
|
3171
4332
|
// y/N/c: Enter alone answers an open ask "no" (the default), as typing n does.
|
|
3172
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"
|
|
3173
4334
|
if (!buf.text.trim() && !R.current.denyReason && !S.current.turn && askOpen()) { B.current = { ...initialBuffer(), text: 'n', cursor: 1 }; buf = B.current; }
|
|
@@ -3183,7 +4344,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3183
4344
|
if (!chk.ok) { B.current = { ...chk.buffer, undo: buf.undo, redo: buf.redo }; say(chk.note, 'warn'); bump(n => n + 1); return; }
|
|
3184
4345
|
let text = chk.text.trim();
|
|
3185
4346
|
const wrapped = joinWrappedSlash(text); // a wrapped paste of "/say slot long text": one command, not a refusal
|
|
3186
|
-
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
|
+
}
|
|
3187
4354
|
const keyHit = looksLikeKey(text);
|
|
3188
4355
|
if (keyHit) { B.current = initialBuffer(); R.current.confirmMulti = null; say(keyWarning(keyHit), 'warn'); return; } // never sent, never in history
|
|
3189
4356
|
if (text.startsWith('!') && text.includes('\n')) { // owner 2026-10-15: a wrapped `!git ... push origin\nHEAD:main` ran as two commands
|
|
@@ -3192,6 +4359,29 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3192
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');
|
|
3193
4360
|
bump(n => n + 1); return;
|
|
3194
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
|
+
}
|
|
3195
4385
|
const bang = parseBang(text);
|
|
3196
4386
|
if (bang) { // QUOXON-FW8: the user's own command on their own machine; never a turn, never the server
|
|
3197
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; }
|
|
@@ -3199,16 +4389,61 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3199
4389
|
echoCmd(text); bangCommand(bang, { echoed: true }); bump(n => n + 1); return;
|
|
3200
4390
|
}
|
|
3201
4391
|
const s = S.current;
|
|
3202
|
-
const isCommand =
|
|
4392
|
+
const isCommand = isOneLineCommand(text);
|
|
3203
4393
|
const ws = R.current.wokeSwallowed; R.current.wokeSwallowed = null;
|
|
3204
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
|
|
3205
4395
|
B.current = { ...initialBuffer(), text, cursor: text.length };
|
|
3206
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;
|
|
3207
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
|
+
}
|
|
3208
4443
|
if (blocked && !isCommand) { // Z2H-R4: clear the box (else the next /login is appended and refused); Up recalls the message
|
|
3209
4444
|
remember(text); R.current.histIdx = -1; B.current = initialBuffer(); say(blocked, 'warn'); bump(n => n + 1); return;
|
|
3210
4445
|
}
|
|
3211
|
-
if (text.startsWith('/') && text.includes('\n') && !R.current.confirmMulti) {
|
|
4446
|
+
if (text.startsWith('/') && text.includes('\n') && !isOneLineCommand(text) && !R.current.confirmMulti) {
|
|
3212
4447
|
// commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter.
|
|
3213
4448
|
// The second Enter sends it even after edits (R3 D3: requiring a byte-identical draft swallowed every later Enter).
|
|
3214
4449
|
R.current.confirmMulti = true;
|
|
@@ -3236,13 +4471,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3236
4471
|
let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
|
|
3237
4472
|
if (askOpen() && !R.current.noteAsk) {
|
|
3238
4473
|
const o = askOutcomeOf(text, { runWord: runAskOpen() }) || (askAnswer(text) === 'chat' && !S.current.turn && !R.current.pendingWalk && !isAskClarification(text) ? { kind: 'dismissed' } : null);
|
|
3239
|
-
const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'profileAsk', 'cliAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
|
|
4474
|
+
const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'sayLastAsk', 'profileAsk', 'loginAsk', 'neverAsk', 'dndAsk', 'secretAsk', 'limitedAsk', 'cliAsk', 'deckAsk', 'installAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
|
|
3240
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();
|
|
3241
4476
|
if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
|
|
3242
4477
|
else if (o) {
|
|
3243
4478
|
askDismissed = o.kind === 'dismissed';
|
|
3244
4479
|
R.current.askAnswer = { ...o, at: Date.now() }; queueMicrotask(() => reconcileAsks());
|
|
3245
|
-
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
|
|
3246
4481
|
}
|
|
3247
4482
|
}
|
|
3248
4483
|
// y/N/c: c or any other free text keeps the open ask and asks Quox about it first (never a silent cancel).
|
|
@@ -3254,8 +4489,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3254
4489
|
say('Not sent. Your answer and note are back in the composer; press Enter to send it, or edit it first.', 'warn');
|
|
3255
4490
|
return;
|
|
3256
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
|
|
3257
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
|
|
3258
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
|
|
3259
4496
|
if (KC.current.answer(text)) return; // y/n after a /watch, /automate or /crystallize preview card
|
|
3260
4497
|
if (UW.current.answer(text)) return; // y/n after a /undo or /wish card
|
|
3261
4498
|
if (HW.current.active() && HW.current.answer(text)) return; // CUSTOM-HATS: the next line of a /hat new|edit|delete walk
|
|
@@ -3269,7 +4506,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3269
4506
|
if (Date.now() - g.at < 120000 && !text.startsWith('/')) {
|
|
3270
4507
|
if (/^y(es)?$/i.test(text.trim())) {
|
|
3271
4508
|
(async () => {
|
|
3272
|
-
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
|
|
3273
4510
|
if (g.decisions && g.hi) { // one PIN for the whole batch; the token then rides on every item
|
|
3274
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' } });
|
|
3275
4512
|
if (!r.ok) return say(`Not answered: ${declinedText(r.reason)}. Nothing was sent.`, 'warn');
|
|
@@ -3316,6 +4553,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3316
4553
|
if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
|
|
3317
4554
|
if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
|
|
3318
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
|
+
}
|
|
4562
|
+
if (R.current.sayLastAsk) { // SAY-LAST-TARGET "Send to <slot>? y send · n cancel": y sends the whole text
|
|
4563
|
+
const a = R.current.sayLastAsk; R.current.sayLastAsk = null;
|
|
4564
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { sendToSlot(a.slot, a.text); return; }
|
|
4565
|
+
if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
|
|
4566
|
+
}
|
|
3319
4567
|
if (R.current.slotCreateAsk) { // "No slot X. Create it? y/N/c" from a /slot near miss: y runs /slot X --create
|
|
3320
4568
|
const a = R.current.slotCreateAsk; R.current.slotCreateAsk = null;
|
|
3321
4569
|
if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { const l = `/slot ${a.nm} --create`; echoCmd(l); command(l); return; }
|
|
@@ -3336,6 +4584,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3336
4584
|
}
|
|
3337
4585
|
if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
|
|
3338
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
|
+
}
|
|
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)
|
|
4604
|
+
const a = R.current.installAsk; R.current.installAsk = null;
|
|
4605
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { installNow(a); return; }
|
|
4606
|
+
if (!text.startsWith('/')) { say(`Not installed. Nothing was downloaded.`, 'dim'); nextInstallOffer(); return; }
|
|
4607
|
+
}
|
|
3339
4608
|
if (R.current.cliAsk) { // QUOXON-FW: a slash word named a WRITE quox command: "Run it? y/N" (typing y is the authorization)
|
|
3340
4609
|
const a = R.current.cliAsk; R.current.cliAsk = null;
|
|
3341
4610
|
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { if (R.current.bang) say('A command is still running. Ctrl+C stops it.', 'warn'); else bangCommand(a.bang); bump(n => n + 1); return; }
|
|
@@ -3356,6 +4625,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3356
4625
|
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { const v = vetSuggestion(a.line); if (v.ok) { R.current.preConfirmed = v.id === 'org' ? 'org' : null; echoCmd(v.line); command(v.line); bump(n => n + 1); } return; }
|
|
3357
4626
|
if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
|
|
3358
4627
|
}
|
|
4628
|
+
if (R.current.objectiveAsk) { // "Make this an objective that runs until done?" (yes or no): yes creates + starts it, no does nothing
|
|
4629
|
+
const a = R.current.objectiveAsk; R.current.objectiveAsk = null;
|
|
4630
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runObjective(a); return; }
|
|
4631
|
+
if (!text.startsWith('/')) { say('No objective made.', 'dim'); return; }
|
|
4632
|
+
}
|
|
3359
4633
|
if (R.current.handoffAsk) { // "Hand this question to CommanderQ?" (yes or no): yes asks it once, the attach stays
|
|
3360
4634
|
const a = R.current.handoffAsk; R.current.handoffAsk = null;
|
|
3361
4635
|
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runHandoff(a); return; }
|
|
@@ -3378,13 +4652,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3378
4652
|
}
|
|
3379
4653
|
if (SC.current.pendingFile) { // /send found secret-shaped values: "y/N" (send redacted)
|
|
3380
4654
|
const pf = SC.current.pendingFile; SC.current.pendingFile = null;
|
|
3381
|
-
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; }
|
|
3382
4656
|
if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
|
|
3383
4657
|
}
|
|
3384
4658
|
if (SC.current.reattach) { // startup "Reattach to slot X? y/N"
|
|
3385
4659
|
const ra = SC.current.reattach; SC.current.reattach = null;
|
|
3386
4660
|
if (/^y(es)?$/i.test(text.trim())) { takeSlot(ra.slot, ra.take); return; }
|
|
3387
|
-
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; }
|
|
3388
4662
|
}
|
|
3389
4663
|
if (SC.current.runAsk) { // SLOT-RUN: "Run it? y/N/c" (or "run" for a dangerous one) on a slot sender's command
|
|
3390
4664
|
const ra = SC.current.runAsk;
|
|
@@ -3396,7 +4670,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3396
4670
|
else if (v === 'needs-run') { say(`Not run yet. ${ra.danger}, so type run to run it, or n to skip.`, 'warn'); return; }
|
|
3397
4671
|
else if (v === 'no') { SC.current.runAsk = null; transcript?.bang?.(ra.cmd, 'declined'); say('Not run.', 'dim'); return; }
|
|
3398
4672
|
}
|
|
3399
|
-
|
|
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
|
|
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; } }
|
|
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
|
|
3400
4685
|
const roomSend = api.slotSay ? parseRoomSend(text) : null;
|
|
3401
4686
|
if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
|
|
3402
4687
|
remember(text); R.current.histIdx = -1;
|
|
@@ -3485,9 +4770,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3485
4770
|
const now = Date.now();
|
|
3486
4771
|
const idx = hit.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
|
|
3487
4772
|
if (idx != null) {
|
|
3488
|
-
const
|
|
3489
|
-
|
|
3490
|
-
|
|
4773
|
+
const same = M.last && now - M.last.at < DOUBLE_MS && M.last.x === e.x && M.last.y === e.y;
|
|
4774
|
+
const n = same ? M.last.n + 1 : 1;
|
|
4775
|
+
M.last = n >= 3 ? null : { at: now, x: e.x, y: e.y, n };
|
|
4776
|
+
if (n === 2) { const w = wordAt(B.current.text, idx); edit({ type: 'select', anchor: w.from, cursor: w.to }); copySelection(); return; }
|
|
4777
|
+
if (n >= 3) { const l = lineAt(B.current.text, idx); edit({ type: 'select', anchor: l.from, cursor: l.to }); copySelection(); return; }
|
|
3491
4778
|
edit({ type: 'place', index: idx }); K.current.composerClicked = true;
|
|
3492
4779
|
M.drag = { anchor: B.current.cursor, cursorRow: hit.cursorRow ?? null };
|
|
3493
4780
|
return;
|
|
@@ -3527,7 +4814,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3527
4814
|
k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50); k.askEchoTimer.unref?.();
|
|
3528
4815
|
bump(n => n + 1); return true;
|
|
3529
4816
|
}
|
|
3530
|
-
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;
|
|
3531
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; }
|
|
3532
4819
|
B.current = { ...initialBuffer(), text: ch, cursor: 1 };
|
|
3533
4820
|
onEnter(now);
|
|
@@ -3554,6 +4841,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3554
4841
|
const k = K.current;
|
|
3555
4842
|
const g = k.guard;
|
|
3556
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
|
|
3557
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)
|
|
3558
4846
|
const armedAt = Math.max(card.displayedAt ?? card.shownAt ?? 0, g?.since ?? 0);
|
|
3559
4847
|
const ok = () => approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: k.lastKeyAt, heldKey: g?.heldKey, ch });
|
|
@@ -3564,6 +4852,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3564
4852
|
return true; // any other key cancels
|
|
3565
4853
|
}
|
|
3566
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
|
+
}
|
|
3567
4862
|
if (key === 'c' && !S.current.turn && !noAiNow()) { // y/N/c: chat about the approval first; the card stays
|
|
3568
4863
|
if (!ok()) return true;
|
|
3569
4864
|
const what = card.what || [card.tool, card.target].filter(Boolean).join(' ') || 'this approval';
|
|
@@ -3573,9 +4868,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3573
4868
|
if (key !== 'y' && key !== 'n') return false; // ordinary typing: the composer keeps it
|
|
3574
4869
|
if (!ok()) return true; // too early / key repeat: swallowed, never answered
|
|
3575
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
|
|
3576
|
-
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.
|
|
3577
4872
|
R.current.denyReason = { card, at: Date.now() }; say('Reason? (Enter to skip)', 'warn'); bump(n => n + 1); return true;
|
|
3578
4873
|
}
|
|
4874
|
+
if (card.destructive) { startDelConfirm(card.destructive, () => answer(card, 'approve')); return true; } // a delete is never one key
|
|
3579
4875
|
if (card.privileged || card.undescribable) { k.confirm = true; bump(n => n + 1); return true; }
|
|
3580
4876
|
answer(card, 'approve');
|
|
3581
4877
|
return true;
|
|
@@ -3613,6 +4909,25 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3613
4909
|
}
|
|
3614
4910
|
|
|
3615
4911
|
/** COMPOSER-EDIT: cells the draft has per row (the composer box is `cols` wide, less the "› " prompt). */
|
|
4912
|
+
/** QUOXON-HISTORY-UP: still on the entry Up recalled (unedited): Up/Down jump whole entries, not rows of a long recalled message. */
|
|
4913
|
+
function browsingHistory() { return R.current.histIdx >= 0 && B.current.text === R.current.histText; }
|
|
4914
|
+
/** QUOXON-HISTORY-UP: Ctrl+R reverse search. The composer shows "(reverse-i-search) q: match"; Enter accepts, Esc cancels, Ctrl+R again = next older. */
|
|
4915
|
+
function rsShow() {
|
|
4916
|
+
const rs = R.current.rs; const hist = R.current.history; const q = rs.q.toLowerCase(); let n = rs.skip; rs.hit = null;
|
|
4917
|
+
for (let i = hist.length - 1; i >= 0; i--) if (hist[i].toLowerCase().includes(q) && n-- === 0) { rs.hit = hist[i]; break; }
|
|
4918
|
+
B.current = bufferReducer(initialBuffer(), { type: 'set', text: `(reverse-i-search) ${rs.q}: ${rs.hit == null ? '' : rs.hit.replace(/\s*\n\s*/g, ' ')}` }); bump(n2 => n2 + 1);
|
|
4919
|
+
}
|
|
4920
|
+
function rsEvent(e) {
|
|
4921
|
+
const rs = R.current.rs;
|
|
4922
|
+
if (e.type === 'key') {
|
|
4923
|
+
if (e.name === 'escape' || e.name === 'ctrl-c') { B.current = rs.saved; R.current.rs = null; bump(n => n + 1); return; }
|
|
4924
|
+
if (e.name === 'enter') { const hit = rs.hit; B.current = rs.saved; R.current.rs = null; if (hit != null) { R.current.histIdx = -1; edit({ type: 'set', text: hit }); } else bump(n => n + 1); return; }
|
|
4925
|
+
if (e.name === 'ctrl-r') { rs.skip++; return rsShow(); }
|
|
4926
|
+
if (e.name === 'backspace') { rs.q = rs.q.slice(0, -1); rs.skip = 0; return rsShow(); }
|
|
4927
|
+
return;
|
|
4928
|
+
}
|
|
4929
|
+
if (e.type === 'text' || e.type === 'paste') { rs.q += e.text.replace(/\s+/g, ' '); rs.skip = 0; rsShow(); }
|
|
4930
|
+
}
|
|
3616
4931
|
function composerWidth() { return Math.max(1, cols - 2); }
|
|
3617
4932
|
/** COMPOSER-EDIT: copy the composer selection to the clipboard with OSC 52 (works over SSH; needs a terminal that allows it). */
|
|
3618
4933
|
function copySelection() {
|
|
@@ -3635,6 +4950,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3635
4950
|
return;
|
|
3636
4951
|
}
|
|
3637
4952
|
if (name === 'ctrl-d') { if (!B.current.text) exit(); return; }
|
|
4953
|
+
if (LC.current.focused() && !s.approvals.length && !B.current.text && LC.current.key(name, e.text)) return; // QLOOP-LIVE: keys of a focused loop card
|
|
3638
4954
|
if (R.current.roster.focus && !s.approvals.length && rosterKey(name)) return;
|
|
3639
4955
|
// quick reply (owner 2026-10-15): Tab in an EMPTY composer pre-fills "/say <last sender> " (before the palette, which owns Tab otherwise)
|
|
3640
4956
|
if (name === 'tab' && !B.current.text && R.current.lastSender && !headCard(s)) { edit({ type: 'set', text: `/say ${R.current.lastSender.toLowerCase()} ` }); return; }
|
|
@@ -3646,6 +4962,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3646
4962
|
if (R.current.askArmed) { R.current.askArmed = false; R.current.dragonTalk = null; say('Dragon ask cancelled.', 'dim'); return; }
|
|
3647
4963
|
if (HW.current.cancel()) return; // CUSTOM-HATS: Esc ends the hat walk, nothing sent
|
|
3648
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
|
|
3649
4966
|
if (R.current.pendingWalk) { walkEnd('Esc'); K.current.lastEscAt = now; return; } // WALK-FIXES: Esc leaves the walkthrough with a summary; the draft stays
|
|
3650
4967
|
if (now - K.current.lastEscAt < ESC_ESC_MS && B.current.text) {
|
|
3651
4968
|
if (looksLikeKey(B.current.text)) { edit({ type: 'clear' }); say('Draft cleared. It looked like a key, so it was not kept.'); }
|
|
@@ -3663,7 +4980,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3663
4980
|
case 'delete': return edit({ type: 'delete' });
|
|
3664
4981
|
case 'delete-word': return edit({ type: 'deleteWordBack' });
|
|
3665
4982
|
case 'kill-line': return edit({ type: 'killLineBack' });
|
|
3666
|
-
case '
|
|
4983
|
+
case 'home': { // Ctrl+A stays line-start; a second Ctrl+A within 600 ms selects the whole draft (and copies it)
|
|
4984
|
+
if (e.via === 'ctrl-a') {
|
|
4985
|
+
const k = K.current;
|
|
4986
|
+
if (k.ctrlA && now - k.ctrlA.at < 600 && k.ctrlA.text === B.current.text && B.current.text) { k.ctrlA = 0; edit({ type: 'selectAll' }); copySelection(); return; }
|
|
4987
|
+
k.ctrlA = { at: now, text: B.current.text };
|
|
4988
|
+
}
|
|
4989
|
+
return edit({ type: 'home' });
|
|
4990
|
+
}
|
|
4991
|
+
case 'left': case 'right': case 'end': return edit({ type: name });
|
|
3667
4992
|
case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'wordLeft' : 'wordRight' });
|
|
3668
4993
|
case 'kill-line-forward': return edit({ type: 'killLineForward' }); // Ctrl+K
|
|
3669
4994
|
case 'delete-word-forward': return edit({ type: 'deleteWordForward' }); // Alt+D, Ctrl+Delete
|
|
@@ -3678,21 +5003,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3678
5003
|
case 'redo': return edit({ type: 'redo' });
|
|
3679
5004
|
case 'up': {
|
|
3680
5005
|
if (B.current.text === '' && s.pending.length) { const last = s.pending[s.pending.length - 1]; dispatch({ type: 'queue.pop' }); edit({ type: 'set', text: last.text }); return; }
|
|
3681
|
-
if (atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
|
|
5006
|
+
if (browsingHistory() || atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
|
|
3682
5007
|
const hist = R.current.history;
|
|
3683
5008
|
if (B.current.text === '' || R.current.histIdx >= 0) {
|
|
3684
5009
|
const i = R.current.histIdx < 0 ? hist.length - 1 : Math.max(0, R.current.histIdx - 1);
|
|
3685
|
-
if (hist[i] !== undefined) { R.current.histIdx = i; edit({ type: 'set', text: hist[i] }); }
|
|
5010
|
+
if (hist[i] !== undefined) { R.current.histIdx = i; R.current.histText = hist[i]; edit({ type: 'set', text: hist[i] }); }
|
|
3686
5011
|
}
|
|
3687
5012
|
return;
|
|
3688
5013
|
}
|
|
3689
5014
|
return edit({ type: 'up', width: composerWidth() });
|
|
3690
5015
|
}
|
|
3691
5016
|
case 'down': {
|
|
5017
|
+
if (B.current.text === '' && R.current.histIdx < 0 && !s.approvals.length && LC.current.anyRunning() && !LC.current.focused()) { LC.current.focus(); return; } // QLOOP-LIVE: Down on an empty composer reaches the newest running card (its hint line says so)
|
|
3692
5018
|
if (B.current.text === '' && R.current.histIdx < 0 && rosterOn()) { focusRoster(); return; }
|
|
3693
|
-
if (atVisualEdge(B.current, 'down', composerWidth()) && R.current.histIdx >= 0) {
|
|
5019
|
+
if ((browsingHistory() || atVisualEdge(B.current, 'down', composerWidth())) && R.current.histIdx >= 0) {
|
|
3694
5020
|
const i = R.current.histIdx + 1;
|
|
3695
|
-
if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; edit({ type: 'set', text: R.current.history[i] }); }
|
|
5021
|
+
if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; R.current.histText = R.current.history[i]; edit({ type: 'set', text: R.current.history[i] }); }
|
|
3696
5022
|
return;
|
|
3697
5023
|
}
|
|
3698
5024
|
return edit({ type: 'down', width: composerWidth() });
|
|
@@ -3732,6 +5058,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3732
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; }
|
|
3733
5059
|
if (e.name === 'enter' && autoIdle()) { stopCountdown(); cdLog('restart-now'); doRestart(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
|
|
3734
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
|
|
3735
5062
|
if (R.current.sleep) { // the sleep screen eats the waking key (nothing is lost), except "/": slash commands always work asleep
|
|
3736
5063
|
if (e.type === 'key' && e.name === 'ctrl-c') { exit(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
|
|
3737
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 */ }
|
|
@@ -3746,6 +5073,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3746
5073
|
if (LI.current.isOpen()) { LI.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the sign-in card takes every key
|
|
3747
5074
|
if (CN.current.isOpen()) { CN.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the secure card takes every key
|
|
3748
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)
|
|
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; }
|
|
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
|
|
3749
5088
|
const card = headCard(S.current);
|
|
3750
5089
|
const g = K.current.guard;
|
|
3751
5090
|
// any different key ends the "this key was already down" suppression
|
|
@@ -3753,6 +5092,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3753
5092
|
// Tab (composer empty) puts the card away into the status line until a NEW approval arrives
|
|
3754
5093
|
// RESTORE-TUI: Tab with a draft focuses the card (the draft is kept, never queued or sent); Esc returns to the draft
|
|
3755
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
|
|
3756
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; }
|
|
3757
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; }
|
|
3758
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
|
|
@@ -3761,6 +5101,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3761
5101
|
edit({ type: 'paste', text: e.text }); // a card never discards a paste
|
|
3762
5102
|
} else if (e.type === 'text') {
|
|
3763
5103
|
if (card && approvalKey('text', e.text, now)) { /* consumed by the approval dialog */ }
|
|
5104
|
+
else if (!card && !B.current.text && LC.current.focused() && LC.current.key(null, e.text)) { /* consumed by the focused loop card (s halts, y confirms) */ }
|
|
3764
5105
|
else if (!card && R.current.roster.focus && rosterKey(null, e.text)) { /* consumed by the roster */ }
|
|
3765
5106
|
else if (!card && askKey(e.text, now)) { /* consumed by the open ask */ }
|
|
3766
5107
|
else edit({ type: 'insert', text: e.text });
|
|
@@ -3774,6 +5115,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3774
5115
|
};
|
|
3775
5116
|
onData.current = (chunk) => {
|
|
3776
5117
|
const now = Date.now();
|
|
5118
|
+
DC.current.keypress(); // a keypress sends the companion dragon away, but not in its first moments
|
|
3777
5119
|
finishIntroRef.current();
|
|
3778
5120
|
// Enter is a newline only inside a real paste (bracketed, or several lines in one chunk: keys.js).
|
|
3779
5121
|
// Its timing relative to the previous chunk says nothing: tmux, expect and SSH batch segments.
|
|
@@ -3812,6 +5154,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3812
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'}` }); });
|
|
3813
5155
|
return () => { live = false; };
|
|
3814
5156
|
}, []);
|
|
5157
|
+
const startupRecheck = React.useRef(null);
|
|
3815
5158
|
React.useEffect(() => {
|
|
3816
5159
|
if (!startup) return undefined;
|
|
3817
5160
|
let live = true;
|
|
@@ -3824,18 +5167,90 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3824
5167
|
timer = null;
|
|
3825
5168
|
let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
|
|
3826
5169
|
if (!live) return;
|
|
3827
|
-
if (hl?.ok) {
|
|
5170
|
+
if (hl?.ok) { noteAnswering(); flushOutbox(); return; }
|
|
3828
5171
|
recheck(Math.min(i + 1, delays.length - 1));
|
|
3829
5172
|
}, delays[Math.min(i, delays.length - 1)]);
|
|
3830
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;
|
|
3831
5193
|
Promise.resolve().then(startup).then((note) => {
|
|
3832
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; }
|
|
3833
5202
|
if (slow(note)) { if (startup.said) return; startup.said = true; } // once per session, even if the probe is mounted twice
|
|
3834
5203
|
say(note, 'warn');
|
|
3835
5204
|
if (slow(note)) recheck(0);
|
|
3836
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);
|
|
3837
5248
|
return () => { live = false; clearTimeout(timer); };
|
|
3838
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
|
+
}, []);
|
|
3839
5254
|
React.useEffect(() => { // E5: "While you were away" under the intro, after more than 2 h away
|
|
3840
5255
|
if (!away) return undefined;
|
|
3841
5256
|
let live = true;
|
|
@@ -3888,6 +5303,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3888
5303
|
stdin.on('data', fn);
|
|
3889
5304
|
return () => {
|
|
3890
5305
|
R.current.closed = true;
|
|
5306
|
+
LC.current.dispose();
|
|
3891
5307
|
R.current.abort?.abort(); // closing quoxon cancels the active stream (and its HTTP body)
|
|
3892
5308
|
for (const k of ['fallbackTimer', 'ctrlCTimer']) clearTimeout(R.current[k]);
|
|
3893
5309
|
clearTimeout(K.current.danglingTimer);
|
|
@@ -3930,16 +5346,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3930
5346
|
}
|
|
3931
5347
|
pendingHist = s.history.slice(committedRef.current);
|
|
3932
5348
|
}
|
|
5349
|
+
const staticCols = columns != null ? cols : liveCols(stdout, cols); // history prints at the terminal's width NOW
|
|
5350
|
+
const staticItems = (() => { // QLOOP-LIVE: the committed history for <Static>; append-only, a settled loop card joins at the end
|
|
5351
|
+
let st = staticRef.current;
|
|
5352
|
+
const upto = Math.min(committedRef.current, s.history.length);
|
|
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
|
|
5354
|
+
let list = st.list;
|
|
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]; }
|
|
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]; } }
|
|
5357
|
+
st.list = list;
|
|
5358
|
+
return list;
|
|
5359
|
+
})();
|
|
3933
5360
|
lastAppRender.current = nowR;
|
|
3934
5361
|
// Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
|
|
3935
5362
|
const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
|
|
3936
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
|
|
3937
5365
|
const card = queue[0] || null;
|
|
3938
5366
|
const armed = card ? Date.now() - Math.max(card.displayedAt ?? card.shownAt ?? 0, K.current.guard?.since ?? 0) >= 300 : false;
|
|
3939
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)
|
|
3940
5368
|
if (motionStats && motionOn && card && card.displayedAt != null && !(motionStats.cards ||= []).some(x => x.card.callId === card.callId)) motionStats.cards.push({ card, org: orgShown() || null }); // the card took the draw-in path (a test renders it at fixed motionE)
|
|
3941
5369
|
R.current.armedShown = armed;
|
|
3942
|
-
const ctxParts = [demoLabel && profile === 'demo' ? null : profile, orgShown(), (R.current.noAi && !s.ctx?.model ? NO_AI_STATUS : modelLabel(s.ctx, MC.current)), demoLabel ? (demoAuto ? 'demo data, nothing real runs' : null) : MD.current, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules), AR.current.on ? '↻ auto' : null]; // [5]: auto-restart, context row of the status
|
|
5370
|
+
const ctxParts = [demoLabel && profile === 'demo' ? null : profile, orgShown(), (R.current.noAi && !s.ctx?.model ? NO_AI_STATUS : modelLabel(s.ctx, MC.current)), demoLabel ? (demoAuto ? 'demo data, nothing real runs' : null) : MD.current, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules), AR.current.on ? '↻ auto' : null, SV.current.chip ? { name: SV.current.chip.name, role: SV.current.chip.role } : null, SV.current.chip?.remote || null]; // [6],[7]: server chip, remote instance; [5]: auto-restart, context row of the status
|
|
3943
5371
|
const caps = R.current.caps;
|
|
3944
5372
|
const hint = (busy ? `${caps.steer ? 'Enter steer' : 'Enter queue'} · Tab queue · ${caps.interrupt ? 'Esc stop' : 'Esc disconnects'}` : ''); // owner 22:12 "Lose this: Enter send · /help": only the hint a running turn needs is shown
|
|
3945
5373
|
const placeholder = busy ? (caps.steer ? 'Type to steer (Enter) or queue (Tab)' : 'Type to queue (Enter or Tab)') : R.current.askArmed ? ASK_PROMPT : 'Ask Quox anything…';
|
|
@@ -3963,13 +5391,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3963
5391
|
function approvalRowShown() {
|
|
3964
5392
|
const ro = R.current.roster;
|
|
3965
5393
|
if (!ro.on || !ro.rows.some(r => r.kind === 'approval')) return false;
|
|
3966
|
-
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 });
|
|
3967
5395
|
return v.mode === 'full' && v.lines.some(l => l.kind === 'approval');
|
|
3968
5396
|
}
|
|
3969
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
|
+
}
|
|
3970
5411
|
function rosterNode() {
|
|
3971
5412
|
const ro = R.current.roster;
|
|
3972
|
-
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 });
|
|
3973
5414
|
const row = ro.rows[ro.sel];
|
|
3974
5415
|
return h(Roster, {
|
|
3975
5416
|
view, cols,
|
|
@@ -3983,39 +5424,44 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3983
5424
|
if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
|
|
3984
5425
|
const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
|
|
3985
5426
|
return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
|
|
3986
|
-
h(Static, { key: `static-${epoch}`, items:
|
|
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)) }))),
|
|
3987
5428
|
h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
|
|
3988
5429
|
}
|
|
3989
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());
|
|
3990
5431
|
return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
|
|
3991
5432
|
// Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
|
|
3992
5433
|
// already-printed items would drop it (and reprint the rest).
|
|
3993
|
-
h(Static, { key: `static-${epoch}`, items: introDone.current ?
|
|
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)) }))),
|
|
3994
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) })),
|
|
3995
5436
|
liveRegion([
|
|
3996
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 }))),
|
|
3997
5438
|
...s.blocks.map((b, i) => { const gap = historyGap(i ? s.blocks[i - 1] : (pendingHist[pendingHist.length - 1] ?? s.history[committedRef.current - 1]), b, termRows(stdout)); return h(Box, { key: b.id, flexDirection: 'column' }, motionOn && revealTimingFor(b) ? h(FxItem, { item: b, cols, live: true, fxFor, ignition: feats().ignition, gap }) : h(HistoryItem, { item: b, cols, live: true, gap })); })]),
|
|
3998
5439
|
h(DragonLive, { key: 'dragon', cols }),
|
|
3999
|
-
flyActive(nowR) ? h(FlyLive, { key: 'dragon-fly', cols, rows: termRows(stdout), startedAt: R.current.fly.startedAt, still: R.current.fly.still }) : null,
|
|
5440
|
+
flyActive(nowR) ? h(FlyLive, { key: 'dragon-fly', cols, rows: termRows(stdout), startedAt: R.current.fly.startedAt, still: R.current.fly.still, chromeH: chromeH.current }) : null,
|
|
4000
5441
|
dragonTalkVisible(nowR) ? h(DragonTalk, { key: 'dragon-talk', cols, label: R.current.dragonTalk.label, talking: !R.current.dragonTalk.armed && !R.current.dragonTalk.endedAt, since: R.current.dragonTalk.since, still: !motionOn || mono() }) : null,
|
|
5442
|
+
h(DragonCompanionLive, { key: 'dragon-companion', ctl: DC.current, cols, rows: termRows(stdout) ?? 24 }),
|
|
4001
5443
|
h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
|
|
4002
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,
|
|
4003
|
-
|
|
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
|
|
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,
|
|
4004
5449
|
...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
|
|
4005
5450
|
// TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
|
|
4006
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,
|
|
4007
5452
|
h(Pending, { pending: s.pending, steering: s.steering, cols }),
|
|
4008
|
-
AR.current.timer && AR.current.endsAt ? h(Box, { key: 'restart-countdown', paddingLeft: 2 }, h(Text, { color: c('accent'), bold: true }, countdownLine(Math.max(1, Math.ceil((AR.current.endsAt - Date.now()) / 1000)), cols))) : null,
|
|
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(),
|
|
4009
5455
|
ruleAbove ? null : h(Box, { marginTop: 1 }),
|
|
4010
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 })
|
|
4011
|
-
: ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
|
|
4012
|
-
: h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove }),
|
|
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 } }),
|
|
4013
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,
|
|
4014
|
-
h(RowsBar, { rows: RW.current.view(), cols }),
|
|
5460
|
+
h(RowsBar, { rows: RW.current.view(), cols, limit: { grid: QPE_GRID, n: qpeRowsSetting(api.settings?.()) } }),
|
|
4015
5461
|
h(PresentBar, { stepUp: SU.current, cols }),
|
|
4016
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 }),
|
|
4017
5463
|
h(WaitingNotice, { items: s.waiting.filter(x => K.current.dismissed.has(x.callId)), cols }),
|
|
4018
|
-
h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
|
|
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)] }),
|
|
4019
5465
|
rosterNode())));
|
|
4020
5466
|
}
|
|
4021
5467
|
|