quoxcli 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/admin.js +26 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +2 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +30 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +6 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +86 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +163 -27
- package/lib/commands/agentAttach.js +94 -6
- package/lib/commands/approvals.js +73 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +57 -2
- package/lib/commands/claude.js +34 -8
- package/lib/commands/contacts.js +49 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +17 -6
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +311 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +178 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/licence.js +14 -155
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +11 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +11 -1
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +38 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +64 -9
- package/lib/commands/server.js +292 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/skills.js +48 -5
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/core/actingAs.js +2 -2
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +144 -35
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/config.js +142 -24
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/global-flags.js +105 -5
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +4 -4
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +504 -25
- package/lib/quoxon/app.js +3000 -230
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +363 -38
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +44 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +244 -25
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +49 -6
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +92 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +24 -3
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +9 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +58 -37
- package/lib/quoxon/render.js +638 -119
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +95 -9
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +172 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +21 -7
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/restart.js +71 -13
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +4 -1
- package/lib/core/websiteSession.js +0 -148
package/lib/quoxon/app.js
CHANGED
|
@@ -6,65 +6,132 @@
|
|
|
6
6
|
import React from 'react';
|
|
7
7
|
import { Box, Static, Text, measureElement, useApp, useStdin, useStdout } from 'ink';
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
|
-
import {
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { mkdtempSync, writeFileSync, writeSync, readFileSync, unlinkSync } from 'node:fs';
|
|
10
11
|
import { tmpdir, hostname as osHostname } from 'node:os';
|
|
11
12
|
import { join } from 'node:path';
|
|
12
|
-
import { bufferReducer, initialBuffer, sanitizeForSend } from './textBuffer.js';
|
|
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';
|
|
13
15
|
import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
|
|
14
|
-
import {
|
|
16
|
+
import { isHeldFromBulk, heldReason } from './bulkRisk.js';
|
|
17
|
+
import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, parseAnswer, parsePairs } from './approvalsView.js';
|
|
18
|
+
import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
|
|
19
|
+
import { reduce, initialState, approvalKeyAllowed, groupApprovals, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
|
|
15
20
|
import { turnTokens } from './indicator.js';
|
|
16
21
|
import {
|
|
17
|
-
Composer, RingComposer, Pending, ApprovalCard, StatusLine, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
|
|
18
|
-
} from './render.js';
|
|
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';
|
|
19
24
|
import { c, mono } from './theme.js';
|
|
20
|
-
import {
|
|
25
|
+
import { strayCardAnswer, strayQuestion, alreadyQueued } from './stray.js';
|
|
26
|
+
import { findEntry, helpModel, nearestCommands, joinWrappedSlash, commandNames, REGISTRY, visibleRegistry } from './commands.js';
|
|
21
27
|
import { osc52, pickCopy } from './copy.js';
|
|
22
28
|
import { detectSlotAgent } from './context.js';
|
|
23
29
|
import { rulesContext, rulesLabel, rulesLines } from './rules.js';
|
|
30
|
+
import { resolveCliSlash } from './slash/cliCommand.js';
|
|
31
|
+
import { parseBang, quoxArgv, needsTerminal, ttyFailure, runBang, footer as bangFooter, truncNote as bangTruncNote, shareText, withShared, cleanOutput, DEFAULT_TIMEOUT_MS } from './bang.js';
|
|
32
|
+
import { startupWhatsNew, listWhatsNew, unseenEntries, seenRecord, tryText, LIST_DEFAULT, unseenCommands, seenAfter, startupCommandLine, startupEnabled, toggleArg } from './whatsnew.js';
|
|
33
|
+
import { nextTip, normaliseMode, tipSettingsReply, tipsReply, DEFAULT_MODE, TIPS, tryCommand, tipText } from './tips.js';
|
|
24
34
|
import { findSkill, skillRows, skillDetail, skillPrompt } from './skills.js';
|
|
25
|
-
import {
|
|
35
|
+
import { createScreenContext } from './screenContext.js';
|
|
36
|
+
import { askText, askQuestion, askAnswer, askChatMessage, settledAskLine, settledAskTone, askOutcomeOf, isAskClarification, askClock } from './ask.js';
|
|
37
|
+
import { createHandoffStreak, handoffBiggerAskText, handoffFromEvent, handoffAskText, handoffAnsweredNote, handoffSupersededNote, handoffSkippedNote } from './handoffOffer.js';
|
|
38
|
+
import * as selfUpdate from '../core/selfUpdate.js';
|
|
39
|
+
import { runRequest, runAnswer, handOverRun, runFooter } from './slotRun.js';
|
|
40
|
+
import { profileChooserRows, liveApi, switchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
|
|
41
|
+
import { rosterView, peekLines, selectRows, backgroundAlerts, isExpired, applyProgress, SPIN_MS } from './roster.js';
|
|
26
42
|
import { runObjectCommand } from './objects/index.js';
|
|
27
|
-
|
|
43
|
+
import { peekLines as peekIdLines } from '../core/peek.js';
|
|
44
|
+
import { turnsFromItems, asideNoteText } from './asideView.js';
|
|
45
|
+
import { createMentionCache, loadMentions, receiptMentions } from './mentions.js';
|
|
46
|
+
const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile', '/fleet', '/prove', '/replay', '/health', '/core']);
|
|
28
47
|
import { intentHint } from './intent.js';
|
|
29
48
|
import { observerLines } from './observers.js';
|
|
49
|
+
import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
|
|
50
|
+
import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
|
|
30
51
|
import { packageVersion } from './welcome.js';
|
|
31
|
-
import {
|
|
52
|
+
import { feedbackPayload, feedbackPreview } from './feedback.js';
|
|
53
|
+
import { vetSuggestion, namedCommand } from './suggestCommand.js';
|
|
54
|
+
import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // PLAN MODE (owner 2026-10-16)
|
|
55
|
+
import { echoLine } from './echo.js';
|
|
56
|
+
import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
|
|
57
|
+
import { pushHistory } from './promptHistory.js';
|
|
32
58
|
import { termRows } from './term.js';
|
|
33
|
-
import {
|
|
59
|
+
import { cardTallerThanScreen } from './objects/view.js';
|
|
60
|
+
import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows, confirmLanAllow } from './connect.js';
|
|
34
61
|
import { ConnectCard } from './connectCard.js';
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
62
|
+
import { cmd as mark } from './palette.js';
|
|
63
|
+
import { createLoginController, LoginCard, SIGNED_OUT_LINE, signedOutCard, isSessionDead } from './signin.js';
|
|
64
|
+
import { createStepUp } from './slash/stepUp.js';
|
|
65
|
+
import { StepUpCard, PresentBar } from './slash/stepUpCard.js';
|
|
66
|
+
import { createStepUpCmds, declinedText } from './slash/stepUpCmds.js';
|
|
67
|
+
import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
|
|
68
|
+
import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
|
|
69
|
+
import { humanSessionLines } from '../core/sessionsOverview.js';
|
|
70
|
+
import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
|
|
37
71
|
import { capabilityLines, offerFor, findCapability } from './capabilities.js';
|
|
38
|
-
import {
|
|
72
|
+
import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
|
|
73
|
+
import { DragonLive, playDragon, dragonActive, resetDragon } from './qmotion.js';
|
|
74
|
+
import { AUTOPLAY, AUTOPLAY_REQUEST, AUTOPLAY_PROOF, typingPlan } from './demo.autoplay.js';
|
|
75
|
+
import { motionLevel, levelFeatures, moodForEvent, ignitionGlow, normaliseLevel } from './motion/level.js';
|
|
76
|
+
import { CLIPS as DRAGON_CLIPS } from './qdragonScenes.js';
|
|
77
|
+
import { DRAGON_USAGE, DRAGON_EXPLAINS, DRAGON_ANSWERS, NOTHING_TO_EXPLAIN, ASK_PROMPT, WAKE_MS, explainPrompt, askPrompt, lastOutput, parseDragonArg, sourceLines } from './dragonModes.js';
|
|
78
|
+
import { SleepScreen, DragonTalk, FlyLive } from './dragonView.js';
|
|
79
|
+
import { FLY_PASS_MS, FLY_STILL_MS } from './qdragonFly.js';
|
|
39
80
|
import { createSound } from './sound/index.js';
|
|
40
81
|
import { createPalette } from './slash/paletteController.js';
|
|
41
82
|
import { capabilityMap } from './slash/palette.js';
|
|
83
|
+
import { createArgSources } from './slash/argSources.js';
|
|
84
|
+
import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
|
|
85
|
+
import { parseBroadcastText } from './slash/broadcast.js';
|
|
86
|
+
import { isOwnBroadcast } from './ownBroadcast.js';
|
|
87
|
+
import { orgLabel, isOrgId } from './orgLabel.js';
|
|
88
|
+
import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine } from './slotChat.js';
|
|
89
|
+
import { homedir as osHome } from 'node:os';
|
|
90
|
+
import { createRowCmd } from './slash/rowCmd.js';
|
|
91
|
+
import { createKillerCmds } from './slash/killer.js';
|
|
92
|
+
import { createUndoWish } from './slash/undoWish.js';
|
|
93
|
+
import { createHatWalk } from './slash/hatWalk.js';
|
|
94
|
+
import { createRowEngine, runShell } from './rows.js';
|
|
95
|
+
import { createModelCmd } from './slash/modelCmd.js';
|
|
96
|
+
import { createChallengeCmd } from './slash/challenge.js';
|
|
97
|
+
import { createSpawnCmd } from './slash/spawn.js';
|
|
98
|
+
import { normPrefs, parseModelSub, label as favLabel } from './modelPrefs.js';
|
|
42
99
|
import { ListView } from './slash/listView.js';
|
|
43
100
|
import { createLedger, costModel } from './slash/ledger.js';
|
|
44
|
-
import { statusModel, contextModel, doctorModel, chatChecks } from './slash/panels.js';
|
|
101
|
+
import { statusModel, contextModel, doctorModel, chatChecks, modelText } from './slash/panels.js';
|
|
102
|
+
import { parseAgentsArgs, filterAgents, agentPickerRow, toolLines, whichReply, looseCommand } from './slash/agents.js';
|
|
45
103
|
import { createPicker } from './slash/picker.js';
|
|
46
104
|
import { resumeRows, pickerRow, matchConversation, turnsToHistory } from './slash/resume.js';
|
|
47
105
|
import { settingsList, applySettings, configRows, cycle } from './slash/settings.js';
|
|
48
|
-
import {
|
|
106
|
+
import { countdownLine } from './countdownLine.js';
|
|
107
|
+
import { startBuildWatch, DIFFERENT_NOTICE } from './slash/restart.js';
|
|
108
|
+
import { dashboardLines } from './slash/dashboard.js';
|
|
109
|
+
import { supportsHyperlinks } from './markdown.js';
|
|
49
110
|
import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
|
|
50
111
|
import { intentPanel } from './intent.js';
|
|
112
|
+
import { awayDue, parseSince } from './away.js';
|
|
113
|
+
import { sitrepData } from './objects/sitrep.js';
|
|
114
|
+
import { mirrorErrorNote } from './objects/mirror.js';
|
|
51
115
|
import { launchEgg } from './egg.js';
|
|
52
|
-
import { INTRO_MS
|
|
116
|
+
import { INTRO_MS } from './qart.js';
|
|
53
117
|
import { Clock, FRAME_MS, SECOND_MS, motionAllowed, createFrames } from './motion/clock.js';
|
|
54
118
|
import { FrameContext, useFrame } from './motion/frame.js';
|
|
55
119
|
import { ringMode, ringAnimating } from './motion/ring.js';
|
|
56
120
|
import { TIMING, revealSpan } from './motion/reveal.js';
|
|
121
|
+
import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
|
|
122
|
+
import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
|
|
123
|
+
import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
|
|
57
124
|
|
|
58
125
|
const h = React.createElement;
|
|
59
126
|
const PASTE_GUARD_MS = 40;
|
|
127
|
+
const ASK_ECHO_MS = 1500; // TUI-INPUT: how long an Enter after a single-key ask answer is treated as the confirm habit
|
|
60
128
|
const MAX_ROSTER = 4;
|
|
61
129
|
const ESC_ESC_MS = 500;
|
|
62
130
|
const INTERRUPT_FALLBACK_MS = 8000;
|
|
63
|
-
const INTRO_FRAME_MS = 50; // intro frame period on the shared clock (the plan's length is INTRO_MS
|
|
131
|
+
const INTRO_FRAME_MS = 50; // intro frame period on the shared clock (the plan's length is INTRO_MS)
|
|
64
132
|
const HELD_WINDOW_MS = 1000; // a key seen this close before a card appears is treated as still held
|
|
65
133
|
const APPROVE_WORD = 'approve';
|
|
66
134
|
const CARD_GONE_MS = 400;
|
|
67
|
-
const CARD_EDIT_KEYS = ['backspace', 'delete', 'delete-word', 'kill-line', 'left', 'right', 'home', 'end', 'word-left', 'word-right', 'undo', 'redo'];
|
|
68
135
|
|
|
69
136
|
|
|
70
137
|
const STEER_OFF_NOTE = "This model can't take steering mid-turn; queued for next turn.";
|
|
@@ -87,31 +154,64 @@ export const friendlyError = (e, profile) =>
|
|
|
87
154
|
? `Not signed in for chat on profile ${profile}. Run: quox login -p ${profile}` : (e?.message || String(e));
|
|
88
155
|
|
|
89
156
|
const byId = (id) => CHOICES.find(x => x.id === id) || null;
|
|
157
|
+
// the status line shows the new ask until the server answers
|
|
158
|
+
const NO_RAN = { provider: null, model: null, endpoint: null };
|
|
90
159
|
|
|
91
|
-
export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api, profile = '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 = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, onRestart = null, restartCheck = null, startSha = null, buildWatch = startBuildWatch, env = process.env, motion, motionStats = null }) {
|
|
92
|
-
|
|
160
|
+
export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, autoTickMs = 1000, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined }) {
|
|
161
|
+
// Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
|
|
162
|
+
// one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
|
|
163
|
+
// PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
|
|
164
|
+
// profile this window switched to with a y or /profile (null = as launched). Same conversation, attach, slot, groups.
|
|
165
|
+
const APX = React.useRef(null);
|
|
166
|
+
if (!APX.current || APX.current.from !== apiProp) APX.current = { live: apiProp, from: apiProp };
|
|
167
|
+
const APIP = React.useRef(null); APIP.current ??= liveApi(APX); const api = APIP.current;
|
|
168
|
+
const [who, setWho] = React.useState(null);
|
|
169
|
+
const profile = who?.profile ?? profileProp;
|
|
170
|
+
const agentLogin = who ? who.agentLogin : agentLoginProp;
|
|
171
|
+
const WHO = React.useRef(null); WHO.current = { profile, agentLogin };
|
|
172
|
+
const [signedOut, setSignedOut] = React.useState(signedOutProp);
|
|
173
|
+
const blocked = blockedProp || (signedOut ? SIGNED_OUT_LINE : null);
|
|
174
|
+
const signedOutRef = React.useRef(null); signedOutRef.current = signedOut;
|
|
175
|
+
const BL = React.useRef(null); BL.current = blocked; // effects/timers created at mount read the live value
|
|
176
|
+
const { exit: inkExit } = useApp();
|
|
93
177
|
const { stdin, setRawMode, isRawModeSupported } = useStdin();
|
|
94
178
|
const { stdout } = useStdout();
|
|
95
179
|
const [cols, setCols] = React.useState(clampCols(columns ?? ((stdout?.columns || 80) - 1)));
|
|
96
180
|
const [, bump] = React.useState(0);
|
|
181
|
+
// Every exit path goes through here: settle all in-flight katakana (Working word, drop-in reveals, sleep screen)
|
|
182
|
+
// to plain text, let that frame paint, then unmount. Ink leaves its last frame in scrollback, so it must be plain.
|
|
183
|
+
const exit = (afterMs = 0) => {
|
|
184
|
+
const R0 = R.current;
|
|
185
|
+
if (R0.exiting) return undefined;
|
|
186
|
+
R0.exiting = true; R0.sleep = null; R0.fly = null;
|
|
187
|
+
R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
|
|
188
|
+
resetDragon();
|
|
189
|
+
bump(n => n + 1);
|
|
190
|
+
return setTimeout(() => inkExit(), 60 + afterMs);
|
|
191
|
+
};
|
|
97
192
|
const [epoch, setEpoch] = React.useState(0);
|
|
98
193
|
|
|
99
194
|
const introItem = { id: 'intro', kind: 'intro', text: `${version} · ${profile} · ${cwd}`, cols: clampCols(columns ?? ((process.stdout.columns || 80) - 1)) };
|
|
100
195
|
const introHistory = () => [introItem,
|
|
101
196
|
...(welcome ? [{ id: 'welcome', kind: 'welcome', version: packageVersion() }] : []),
|
|
102
197
|
...(demoLabel ? [{ id: 'demo', kind: 'note', text: demoLabel, tone: 'warn' }] : []),
|
|
198
|
+
...(signedOutProp ? [{ id: 'signedOut', kind: 'note', text: signedOutCard(profile, signedOutProp.reason), tone: 'error' }] : []),
|
|
103
199
|
...(introNote ? [{ id: 'introNote', kind: 'note', text: introNote, tone: 'warn' }] : []),
|
|
104
|
-
...(invite ? [{ id: 'invite', kind: 'note', text:
|
|
200
|
+
...(invite ? [{ id: 'invite', kind: 'note', text: inviteText({ slots: slotsAvailable }) }] : [])];
|
|
105
201
|
// The animated intro lives in the live region (no flicker, nothing printed twice) and moves into
|
|
106
202
|
// <Static> history once settled. Any keypress skips it; the composer is usable throughout.
|
|
107
203
|
const introAnimating = animateIntro ?? introAnimates(stdout?.isTTY);
|
|
108
204
|
// Motion (decorative animation) is decided once: a real terminal and no QUOX_REDUCED_MOTION / LITE. Colour
|
|
109
205
|
// is a separate setting (NO_COLOR); one never implies the other.
|
|
110
206
|
const motionOn = motion ?? motionAllowed(stdout?.isTTY);
|
|
207
|
+
const isInteractive = interactive ?? Boolean(stdout?.isTTY); // /dragon sleep takes over the screen: a real terminal only, never piped or headless
|
|
208
|
+
// Motion level (motion/level.js): read live, so a /config change applies at once. calm = everything above.
|
|
209
|
+
const feats = () => levelFeatures(motionLevel(env));
|
|
111
210
|
const introStart = React.useRef(Date.now());
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
const
|
|
211
|
+
// Cinematic: Orbit & Lock on every launch, then the dragon greets you (open, look, wink). Launch egg: rarely the same
|
|
212
|
+
// dragon greeting on other levels (egg.js decides once per launch; never reduced motion).
|
|
213
|
+
const introDragon = React.useMemo(() => introAnimating && !mono() && (feats().introDragon || (animateIntro === undefined && feats().launchEgg && launchEgg({ tty: Boolean(stdout?.isTTY) }))), []);
|
|
214
|
+
const introMs = INTRO_MS + (introDragon ? DRAGON_CLIPS.greeting.dur + 300 : 0);
|
|
115
215
|
const introDone = React.useRef(!introAnimating);
|
|
116
216
|
const finishIntro = () => {
|
|
117
217
|
if (introDone.current) return;
|
|
@@ -134,53 +234,589 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
134
234
|
const committedRef = React.useRef(introAnimating ? 0 : S.current.history.length);
|
|
135
235
|
const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
|
|
136
236
|
const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
|
|
137
|
-
const K = React.useRef({ keys: initialKeyState(), lastKeyAt: null, lastKeyCh: null, lastDataAt: 0, lastPasteAt: 0, lastEscAt: 0, ctrlC: 0, guard: null, cardFocus: false, cardGoneAt: 0, typed: '', danglingTimer: null });
|
|
237
|
+
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 });
|
|
138
238
|
const chromeRef = React.useRef(null);
|
|
139
239
|
const chromeH = React.useRef(null);
|
|
140
240
|
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
|
+
// QUOXON-HISTORY: Up recalls what was typed in earlier runs too (most recent first); main.js passes the file store.
|
|
242
|
+
if (!R.current.histLoaded) { R.current.histLoaded = true; try { R.current.history = historyStore?.load?.() || []; } catch { R.current.history = []; } }
|
|
243
|
+
const remember = (t) => { if (pushHistory(R.current.history, t)) { try { historyStore?.add?.(t); } catch { /* history is a convenience */ } } };
|
|
244
|
+
// E5: when this profile was last seen (read once at launch, before it is refreshed), and a throttled touch.
|
|
245
|
+
const AWAY = React.useRef({ last: null, touched: 0 });
|
|
246
|
+
const touchSeen = (force = false) => {
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
if (!away || (!force && now - AWAY.current.touched < 60000)) return;
|
|
249
|
+
AWAY.current.touched = now;
|
|
250
|
+
try { away.write(profile, now); } catch { /* best effort */ }
|
|
251
|
+
};
|
|
141
252
|
|
|
142
253
|
const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
|
|
254
|
+
/** 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)); };
|
|
256
|
+
const headCard = (st) => queueOf(st)[0] || null;
|
|
257
|
+
const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
|
|
143
258
|
const dispatch = (a) => {
|
|
144
|
-
|
|
259
|
+
if (a.type === 'note') SCX.current.notice(a.text, a.tone);
|
|
260
|
+
else if (a.type === 'error') SCX.current.error(a.text);
|
|
261
|
+
else if (a.type === 'event' && a.ev) {
|
|
262
|
+
if (a.ev.type === 'error') SCX.current.error(a.ev.error || a.ev.message || 'error');
|
|
263
|
+
else if (a.ev.type === 'turn.finished' && a.ev.status && !['ok', 'success', 'completed'].includes(a.ev.status)) SCX.current.failedTurn(a.ev.status);
|
|
264
|
+
}
|
|
265
|
+
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
|
|
145
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 = moodForEvent(prevS, a, S.current); if (m) playDragon(m); } // cinematic: the dragon reacts
|
|
146
268
|
// Per shown card: remember the key that was already down when it appeared, and restart the typed word.
|
|
147
|
-
const c0 = S.current
|
|
269
|
+
const c0 = headCard(S.current);
|
|
148
270
|
if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
|
|
149
271
|
const gid = c0 ? `${c0.callId}:${c0.displayedAt}` : null;
|
|
150
272
|
if (gid !== K.current.guard?.id) {
|
|
151
273
|
const k = K.current;
|
|
152
274
|
const held = c0 && k.lastKeyAt != null && Date.now() - k.lastKeyAt < HELD_WINDOW_MS ? k.lastKeyCh : null;
|
|
153
|
-
k.guard = gid ? { id: gid, heldKey: held } : null;
|
|
154
|
-
k.
|
|
155
|
-
k.cardFocus = false;
|
|
275
|
+
k.guard = gid ? { id: gid, heldKey: held, since: Date.now() } : null;
|
|
276
|
+
k.confirm = false; k.cardFocus = false;
|
|
156
277
|
}
|
|
157
278
|
if (!R.current.closed) bump(n => n + 1);
|
|
158
279
|
};
|
|
159
|
-
const edit = (a) => { B.current = bufferReducer(B.current, a); if (!B.current.text) R.current.multiWarned = false; bump(n => n + 1); };
|
|
160
|
-
|
|
280
|
+
const edit = (a) => { B.current = bufferReducer(B.current, a); if (!B.current.text) { R.current.multiWarned = false; R.current.confirmMulti = null; } bump(n => n + 1); };
|
|
281
|
+
// ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): an ask is a LIVE card
|
|
282
|
+
// (never printed to scrollback while open); reconcileAsks settles it to one line once it is no longer open.
|
|
283
|
+
const askSeq = React.useRef(0);
|
|
284
|
+
const say = (text, tone) => {
|
|
285
|
+
if (tone !== 'ask') return dispatch({ type: 'note', text, tone });
|
|
286
|
+
const id = `ask-${++askSeq.current}`;
|
|
287
|
+
R.current.lastAsk = { text, question: askQuestion(text), id };
|
|
288
|
+
K.current.composerClicked = false; // TUI-INPUT: a new card is the active prompt until the composer is clicked again
|
|
289
|
+
dispatch({ type: 'ask.open', id, text, at: Date.now() });
|
|
290
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(() => reconcileAsks()); // bind it to its open state
|
|
291
|
+
return undefined;
|
|
292
|
+
};
|
|
293
|
+
// QUOXON-ASK: every question for the human is ONE shape (▶ reason. question? keys), highlighted. Never say a raw y/N.
|
|
294
|
+
const ask = (question, opts) => say(askText(question, opts), 'ask');
|
|
295
|
+
// PLAN MODE: this window's mode ('ask' | 'plan'). Window-scoped: rides /restart, never the shared profile.
|
|
296
|
+
const MD = React.useRef(null); MD.current ??= normMode(mode);
|
|
297
|
+
const askerName = () => String(SN.current?.agent || where.current?.agent || 'Quox').toUpperCase(); // a model-suggested command: who asks
|
|
298
|
+
/** Z2H-3c: add-a-host lines, after probing that the server can actually serve its installer (null = unknown). */
|
|
299
|
+
const hostLines = async () => {
|
|
300
|
+
const base = await (api.hostsBaseUrl?.() ?? api.serverUrl?.());
|
|
301
|
+
let check = null; try { check = base && api.hostInstallCheck ? await api.hostInstallCheck(base) : null; } catch { check = null; }
|
|
302
|
+
return addHostLines(base, check);
|
|
303
|
+
};
|
|
161
304
|
// /model: the --provider flag wins for this run ('auto' = ignore the saved choice), else the profile's saved choice.
|
|
162
305
|
const MC = React.useRef(modelChoice === 'auto' ? null : normChoice(modelChoice) || (api.modelChoice?.() ?? null));
|
|
163
306
|
// /connect card: owns its own secret box; the key never enters dispatch, the buffer or history.
|
|
164
307
|
const CN = React.useRef(null);
|
|
165
308
|
CN.current ??= createConnectController({ api, note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), refresh: () => bump(n => n + 1) });
|
|
309
|
+
// /login card: email (prefilled), masked password, MFA code if asked. Same login path as `quox login`.
|
|
310
|
+
const markSignedOut = (reason = null) => { if (BL.current) return; setSignedOut({ reason }); say(signedOutCard(profile, reason), 'error'); };
|
|
311
|
+
const LI = React.useRef(null);
|
|
312
|
+
LI.current ??= createLoginController({ api, note: (t, tone) => say(t, tone), refresh: () => bump(n => n + 1), onSignedIn: () => {
|
|
313
|
+
setSignedOut(null); BL.current = blockedProp || null;
|
|
314
|
+
R.current.notify = { fails: 0, authLost: false };
|
|
315
|
+
dispatch({ type: 'notify', patch: { error: null } });
|
|
316
|
+
refreshNotify(); refreshRoster();
|
|
317
|
+
if (away && api.away) { const last = AWAY.current.last; if (awayDue(last)) api.away(new Date(last).toISOString()).then((d) => dispatch({ type: 'object', obj: { type: 'away', data: { ...d, compact: true } } }), () => {}); }
|
|
318
|
+
} });
|
|
319
|
+
// QUOXON-STEPUP: the PIN card. The digits live in its closure box only: never dispatched, buffered, in history,
|
|
320
|
+
// undo, the transcript or any log. Agent logins never get a PIN field (slash/stepUp.js).
|
|
321
|
+
const SU = React.useRef(null);
|
|
322
|
+
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
|
+
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 });
|
|
166
325
|
// L0 palette: opens under the composer while the draft is a bare /word (slash/paletteController.js).
|
|
167
326
|
const PL = React.useRef(null);
|
|
168
|
-
|
|
327
|
+
// QUOXON-FW6: model favourites + default (server-side user preferences; profile = cache).
|
|
328
|
+
const MP = React.useRef(null);
|
|
329
|
+
MP.current ??= { prefs: normPrefs(api.localModelPrefs?.()), touched: false, warned: false };
|
|
330
|
+
const PK = React.useRef(null); // /resume, /config, /model
|
|
331
|
+
PK.current ??= createPicker({ refresh: () => bump(n => n + 1) });
|
|
332
|
+
const MM = React.useRef(null);
|
|
333
|
+
MM.current ??= createModelCmd({
|
|
334
|
+
api, say: (t, tone) => say(t, tone), command: (l) => command(l), mc: MC, mp: MP,
|
|
335
|
+
picker: PK.current, state: S, bump: () => bump(n => n + 1), cols: () => cols
|
|
336
|
+
});
|
|
337
|
+
const KC = React.useRef(null); KC.current ??= createKillerCmds({ api, say: (t, tone) => say(t, tone), getState: () => S.current, getFields: () => streamFields(MC.current) }); // /watch, /automate, /crystallize
|
|
338
|
+
// /undo last and /wish (slash/undoWish.js): one card, y/n on the next composer line.
|
|
339
|
+
const HW = React.useRef(null); // CUSTOM-HATS: /hat new|edit|delete walk
|
|
340
|
+
HW.current ??= createHatWalk({ api, say: (t, tone) => say(t, tone), bump: () => bump(n => n + 1) });
|
|
341
|
+
const UW = React.useRef(null);
|
|
342
|
+
UW.current ??= createUndoWish({
|
|
343
|
+
api, say: (t, tone) => say(t, tone), getItems: () => [...S.current.history, ...S.current.blocks],
|
|
344
|
+
runTurn: (t) => runTurn(t),
|
|
345
|
+
draft: async (prompt) => { // the spec is drafted by a normal governed turn; its answer is read back
|
|
346
|
+
if (S.current.turn) throw new Error('wait for the current turn or press Esc first');
|
|
347
|
+
const before = (S.current.answers || []).slice(-1)[0]?.id ?? null;
|
|
348
|
+
await runTurn(prompt);
|
|
349
|
+
const last = (S.current.answers || []).slice(-1)[0];
|
|
350
|
+
return last && last.id !== before ? last.text : null;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
const CH = React.useRef(null);
|
|
354
|
+
CH.current ??= createChallengeCmd({ api, say: (t, tone) => say(t, tone), getState: () => S.current, sendTurn: (t) => submit(t) });
|
|
355
|
+
const SPN = React.useRef(null);
|
|
356
|
+
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
|
+
React.useEffect(() => { if (!blocked && !demoLabel && api.slotSay) SPN.current.sweep().catch(() => {}); }, []); // release this session's overdue VMs
|
|
358
|
+
// QUOXON-FW17: custom rows above the status line (rows.js engine, slash/rowCmd.js).
|
|
359
|
+
const RW = React.useRef(null);
|
|
360
|
+
RW.current ??= createRowCmd({
|
|
361
|
+
api, say: (t, tone) => say(t, tone), bump: () => bump(n => n + 1), cols: () => cols,
|
|
362
|
+
engine: createRowEngine({ fetchUrl: (u) => (api.rowFetch ? api.rowFetch(u) : Promise.reject(new Error('not available'))), fetchQuox: (src) => (api.rowFetchQuox ? api.rowFetchQuox(src) : Promise.reject(new Error('not available'))), runCmd: runShell, onChange: () => bump(n => n + 1) })
|
|
363
|
+
});
|
|
364
|
+
// Rows load once the window is usable: a window started signed out loads them after /login (owner lost the QPE bar 2026-10-15).
|
|
365
|
+
const rowsLoaded = React.useRef(false);
|
|
366
|
+
React.useEffect(() => { if (!blocked && !demoLabel && !rowsLoaded.current) { rowsLoaded.current = true; RW.current.load(); } }, [blocked]);
|
|
367
|
+
React.useEffect(() => () => RW.current.stopEngine(), []);
|
|
368
|
+
PL.current ??= createPalette({
|
|
369
|
+
getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
|
|
370
|
+
run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
|
|
371
|
+
ctx: () => ({ api, caps: R.current.contract, blocked: BL.current }),
|
|
372
|
+
source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all() }),
|
|
373
|
+
refresh: () => bump(n => n + 1),
|
|
374
|
+
// MENTIONS: agents, hosts and tasks fetched once per session on the first @; receipts are this session's own tool envelopes
|
|
375
|
+
mentions: blocked ? null : { cache: createMentionCache({ load: () => loadMentions(api), refresh: () => bump(n => n + 1) }), local: () => receiptMentions([...S.current.history, ...S.current.blocks]) }
|
|
376
|
+
});
|
|
377
|
+
React.useEffect(() => MM.current.startOnDefault({
|
|
378
|
+
flagged: modelChoice != null, off: Boolean(blocked || demoLabel),
|
|
379
|
+
dispatchCtx: () => dispatch({ type: 'ctx', ctx: NO_RAN })
|
|
380
|
+
}), []);
|
|
169
381
|
const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
|
|
170
|
-
|
|
171
|
-
React.
|
|
382
|
+
// /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 };
|
|
384
|
+
// Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
|
|
385
|
+
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
|
+
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; };
|
|
388
|
+
React.useEffect(() => (startSha ? buildWatch({ startSha, repeat: true, onNotice: (t, id) => {
|
|
389
|
+
AR.current.newId = id || t;
|
|
390
|
+
if (!AR.current.on) say(t === DIFFERENT_NOTICE ? t : 'a newer quox is installed: /restart (or /restart auto on)', 'dim');
|
|
391
|
+
} }) : undefined), []);
|
|
392
|
+
const autoIdle = () => { // nothing for a restart to interrupt
|
|
393
|
+
const s = S.current;
|
|
394
|
+
return !s.turn && !s.approvals.length && !s.pending.length && B.current.text === '' && !R.current.pendingWalk && !R.current.bang && !R.current.sleep
|
|
395
|
+
&& !R.current.restarting && !R.current.exiting && !LI.current.isOpen() && !SU.current.isOpen() && !CN.current.isOpen() && !PK.current.isOpen() && !blocked;
|
|
396
|
+
};
|
|
397
|
+
React.useEffect(() => {
|
|
398
|
+
const id = setInterval(() => {
|
|
399
|
+
const a = AR.current;
|
|
400
|
+
if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting) return;
|
|
401
|
+
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;
|
|
403
|
+
const ms = countdownMs();
|
|
404
|
+
if (ms <= 0) { cdLog('timed-out', 0); doRestart(); return; }
|
|
405
|
+
a.endsAt = Date.now() + ms;
|
|
406
|
+
cdLog('started', Math.round(ms / 1000));
|
|
407
|
+
a.timer = setTimeout(() => { stopCountdown(); if (AR.current.on && autoIdle()) { cdLog('timed-out'); doRestart(); } else { cdLog('held'); bump(n => n + 1); } }, ms);
|
|
408
|
+
bump(n => n + 1);
|
|
409
|
+
}, autoTickMs);
|
|
410
|
+
return () => { clearInterval(id); stopCountdown(); };
|
|
411
|
+
}, []);
|
|
412
|
+
// /restart carried a slot: re-check it out under this window's label before the old one is gone. /restart auto tends to
|
|
413
|
+
// fire just as a new build lands, which is often while the collector redeploys, so a timeout or 5xx is retried; if it
|
|
414
|
+
// 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); }, []);
|
|
416
|
+
// /restart carried an /attach (agent or slot): re-attach once the window can act (owner 2026-10-15 "why dont u reattach").
|
|
417
|
+
const attachCarried = React.useRef(false);
|
|
418
|
+
React.useEffect(() => {
|
|
419
|
+
if (!restartAttach || blocked || attachCarried.current) return;
|
|
420
|
+
attachCarried.current = true;
|
|
421
|
+
if (S.current.turn) { R.current.pendingAttach = restartAttach; say(`Re-attaching to ${restartAttach.replace(/^(agent|slot):/, '')} when this turn ends.`, 'dim'); return; } // a resumed turn would refuse it
|
|
422
|
+
command(`/attach ${restartAttach}`);
|
|
423
|
+
}, [blocked]);
|
|
424
|
+
React.useEffect(() => { // any other way the resumed turn ends (reattach path, Esc): carry the attach then
|
|
425
|
+
if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
|
|
426
|
+
});
|
|
427
|
+
async function carrySlot(nm) {
|
|
428
|
+
for (let i = 0; ; i++) {
|
|
429
|
+
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
|
|
430
|
+
const r = await takeSlot(nm, true, { quietTransient: true });
|
|
431
|
+
if (!r?.transient) return;
|
|
432
|
+
if (i >= slotRetryDelays.length) {
|
|
433
|
+
if (R.current.closed || SC.current.slot) return;
|
|
434
|
+
SC.current.reattach = { slot: nm, take: true, at: Date.now() };
|
|
435
|
+
say(`slot ${nm}: could not reattach after the restart (${r.message}).`, 'warn');
|
|
436
|
+
ask(`Reattach to slot ${nm}?`, { action: `/slot ${nm} --take` });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (i === 0) say(`slot ${nm}: reattaching after the restart is slow (${r.message}); retrying.`, 'dim');
|
|
440
|
+
await new Promise((res) => { const t = setTimeout(res, slotRetryDelays[i]); t.unref?.(); });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
React.useEffect(() => { // a TUI-held slot lapses to stale after 15m without a heartbeat
|
|
444
|
+
if (!api.slotHeartbeat) return undefined;
|
|
445
|
+
const id = setInterval(() => { const sl = where.current.slot; if (sl) api.slotHeartbeat(sl, slotSession()); }, 5 * 60 * 1000);
|
|
446
|
+
id.unref?.();
|
|
447
|
+
return () => clearInterval(id);
|
|
448
|
+
}, []);
|
|
449
|
+
// ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
|
|
450
|
+
const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
|
|
451
|
+
const realCwd = () => (cwd ? String(cwd).replace(/^~(?=\/|$)/, osHome()) : process.cwd());
|
|
452
|
+
const myDisplay = () => { try { return api.displayName?.() || null; } catch { return null; } };
|
|
453
|
+
const myLabel = () => senderLabel({ slot: where.current.slot || null, display: myDisplay() });
|
|
454
|
+
/** Listen on the held slot's live stream; every message lands as a chat line above the composer. */
|
|
455
|
+
function listenSlot(nm) {
|
|
456
|
+
SC.current.listener?.stop(); SC.current.listener = null; SC.current.slot = nm || null;
|
|
457
|
+
if (!nm || !api.slotStream || R.current.closed) return;
|
|
458
|
+
const label = myLabel();
|
|
459
|
+
let paused = false;
|
|
460
|
+
const l = createSlotListener({
|
|
461
|
+
open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: label }, signal),
|
|
462
|
+
wait: api.slotWait ? ({ since }) => api.slotWait(nm, { since, excludeFrom: label }) : null,
|
|
463
|
+
onMessage: (m0) => {
|
|
464
|
+
// a broadcast carries the sender's session, not its slot: look the slot up so the header shows the reply address
|
|
465
|
+
if (m0.from_slot || !api.slots || !parseBroadcastText(m0.text)) return handle(m0);
|
|
466
|
+
Promise.resolve().then(() => api.slots()).then(rows => handle({ ...m0, from_slot: slotOfHolder(rows, m0.from) || undefined }), () => handle(m0));
|
|
467
|
+
},
|
|
468
|
+
onState: (st) => {
|
|
469
|
+
if (st.error && st.failures === 1 && !R.current.closed) { paused = true; streamPausedNote(`slot ${nm}`, st.error); }
|
|
470
|
+
else if (st.connected && paused && !R.current.closed) { paused = false; say(`✔ slot ${nm}: reconnected`, 'dim'); }
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
function handle(m) {
|
|
474
|
+
{
|
|
475
|
+
if (typeof m.text === 'string') m = { ...m, text: decodeEntities(m.text) };
|
|
476
|
+
noteReply(m); noteSender(m);
|
|
477
|
+
if (R.current.closed || SC.current.slot !== nm || (label && m.from === label)) return;
|
|
478
|
+
if (isOwnBroadcast(m, { label, display: myDisplay(), userId: api.userId?.() || null })) return; // your own broadcast is not news: the "BROADCAST sent" card told you
|
|
479
|
+
{ 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 }); }
|
|
480
|
+
rememberSender(m);
|
|
481
|
+
if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
SC.current.listener = l; l.start({});
|
|
485
|
+
}
|
|
486
|
+
React.useEffect(() => () => { SC.current.listener?.stop(); SC.current.listener = null; for (const t of SC.current.waits?.values() || []) clearTimeout(t); }, []);
|
|
487
|
+
// ---- SLOT-RUN B: a slot sender asks the human to run ONE command here (slotRun.js) ------------------
|
|
488
|
+
/** Show the request (the full command, always) and, when it may run, arm the y/N/c ask for RUN_TTL_MS. */
|
|
489
|
+
function armRun(rq, ln) {
|
|
490
|
+
if (rq.runnable) {
|
|
491
|
+
const prev = SC.current.runAsk;
|
|
492
|
+
if (prev && Date.now() < prev.expiresAt) say(`${prev.who}'s earlier run request was replaced by this one. It was not run.`, 'warn');
|
|
493
|
+
if (prev) settleRun(prev, { kind: 'closed' });
|
|
494
|
+
rq = { ...rq, askId: `run-${++askSeq.current}` };
|
|
495
|
+
SC.current.runAsk = { ...rq, at: Date.now() };
|
|
496
|
+
(SC.current.runCards ??= new Map()).set(rq.askId, rq); // held live until settled (see commitLimit)
|
|
497
|
+
}
|
|
498
|
+
const show = () => dispatch({ type: 'slotmsg', line: { ...ln, run: rq } });
|
|
499
|
+
show();
|
|
500
|
+
if (rq.runnable) R.current.lastAsk = { text: '', question: `Run \`${rq.cmd}\` (${rq.who} asked: ${rq.why || 'no reason given'})?`, redo: show };
|
|
501
|
+
}
|
|
502
|
+
const runAskOpen = () => Boolean(SC.current.runAsk && runNow() < SC.current.runAsk.expiresAt); // one clock: the run-ask clock decides open and expired alike
|
|
503
|
+
/** ASK-SETTLE: history may go to <Static> (scrollback, immutable) only up to the first armed run card. */
|
|
504
|
+
function commitLimit(s0) {
|
|
505
|
+
const held = SC.current.runCards;
|
|
506
|
+
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
|
+
return s0.history.length;
|
|
508
|
+
}
|
|
509
|
+
/** y: hand the terminal to the user's own shell for the command (like /restart's hand-over), then resume. Never the model. */
|
|
510
|
+
function runSlotCommand(ra) {
|
|
511
|
+
if (R.current.bang) { SC.current.runAsk = ra; return say('A command is still running. Ctrl+C stops it, then answer again.', 'warn'); }
|
|
512
|
+
say(`$ ${ra.cmd}`, 'accent');
|
|
513
|
+
say(`Running ${ra.who}'s command in your shell, with this terminal handed over. Quox comes back when it ends.`, 'dim');
|
|
514
|
+
setTimeout(() => {
|
|
515
|
+
if (R.current.closed || R.current.exiting) return;
|
|
516
|
+
const r = handOverRun({ cmd: ra.cmd, env, cwd: realCwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
|
|
517
|
+
transcript?.bang?.(ra.cmd, r.error ? 'error' : (r.signal || r.code));
|
|
518
|
+
say(`${ra.who}'s command: ${runFooter(r)}`, !r.error && !r.signal && r.code === 0 ? 'ok' : 'warn');
|
|
519
|
+
bump(n => n + 1);
|
|
520
|
+
}, runHandOverDelayMs);
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
/** Say whether the recipient is live; if it is, one gold line when no reply comes. Older servers send no presence. */
|
|
524
|
+
function announceDelivery(slot, r) {
|
|
525
|
+
const p = presenceLine(slot, r.presence, agoText);
|
|
526
|
+
if (!p) return say(`✔ delivered to ${slot}`, 'ok');
|
|
527
|
+
say(p.text, p.tone);
|
|
528
|
+
const wait = SC.current.waits ??= new Map();
|
|
529
|
+
clearTimeout(wait.get(slot)); wait.delete(slot);
|
|
530
|
+
if (p.listening) {
|
|
531
|
+
const t = setTimeout(() => { wait.delete(slot); if (!R.current.closed) say(`no reply from ${slot} yet`, 'warn'); }, noReplyMs);
|
|
532
|
+
t.unref?.(); wait.set(slot, t);
|
|
533
|
+
} else if (r.presence?.state === 'not_listening') {
|
|
534
|
+
// Transcript fp 6a482d60fe83 (2026-10-16): an attached window got nothing for 4 minutes after "not listening".
|
|
535
|
+
// No reply within the wait: ONE wake ask for this message; y runs /poke <slot> (the keeper wake).
|
|
536
|
+
const seenAt = r.presence.last_checked_in_at || null;
|
|
537
|
+
const t = setTimeout(() => {
|
|
538
|
+
wait.delete(slot);
|
|
539
|
+
if (R.current.closed) return;
|
|
540
|
+
R.current.wakeAsk = { slot, at: Date.now() };
|
|
541
|
+
ask('Wake it now?', { action: `/poke ${slot}`, reason: `${slot} hasn't replied (not listening, ${seenAt ? `last checked in ${agoText(seenAt)}` : 'no check-in on record'})` });
|
|
542
|
+
}, noReplyMs);
|
|
543
|
+
t.unref?.(); wait.set(slot, t);
|
|
544
|
+
}
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
/** SLOTS-REPLY2: after a send into slot X, also show X's holder's lines arriving in X (30 min). Own slot is already listened. */
|
|
548
|
+
function watchReplies(slot, sent) {
|
|
549
|
+
if (!api.slotStream || R.current.closed) return;
|
|
550
|
+
SC.current.replyWatch ??= createReplyWatches({
|
|
551
|
+
open: (nm, q, signal) => api.slotStream(nm, { since: q.since }, signal),
|
|
552
|
+
wait: api.slotWait ? (nm, { since }) => api.slotWait(nm, { since }) : null,
|
|
553
|
+
ttlMs: replyWatchMs,
|
|
554
|
+
skipSlot: (nm) => nm === where.current.slot || SC.current.rooms?.has(nm),
|
|
555
|
+
isMine: (m) => { const l = myLabel(); return Boolean(l && m?.from === l); },
|
|
556
|
+
onReply: (nm, m) => {
|
|
557
|
+
if (R.current.closed) return;
|
|
558
|
+
noteReply(m); noteSender(m);
|
|
559
|
+
const ln = slotLine(m); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() });
|
|
560
|
+
if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: ln });
|
|
561
|
+
rememberSender(m);
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
SC.current.replyWatch.arm(slot, sent);
|
|
565
|
+
}
|
|
566
|
+
React.useEffect(() => () => { SC.current.replyWatch?.stop(); }, []);
|
|
567
|
+
// SLOTS-REPLY2: every slotSay names the session this window checked its slot out as, so the server can stamp from_slot from its hold record.
|
|
568
|
+
api.bindHolderSession?.(() => slotSession());
|
|
569
|
+
/** Any inbound line from a slot we are waiting on cancels its "no reply" nudge. */
|
|
570
|
+
function noteReply(m) {
|
|
571
|
+
const waits = SC.current.waits; if (!waits?.size) return;
|
|
572
|
+
for (const [slot, t] of waits) if (isHolderLine(m, slot)) { clearTimeout(t); waits.delete(slot); } // case-insensitive: LUCID is the holder of lucid
|
|
573
|
+
}
|
|
574
|
+
/** @slot, /say, /send: straight to the slot, no model turn, signed as this window; prints once delivered. */
|
|
575
|
+
// A typed slot name resolved against the org's slots ignoring case, hyphens, underscores and spaces; else as typed.
|
|
576
|
+
async function canonSlot(nm, { senders = false } = {}) {
|
|
577
|
+
return (await sayTarget(nm, { senders })).slot;
|
|
578
|
+
}
|
|
579
|
+
/** SLOTS-REPLY: names that recently spoke here with a verified slot (from_slot); /say <that name> reaches the slot. */
|
|
580
|
+
function noteSender(m) {
|
|
581
|
+
if (!m?.from || !m?.from_slot) return;
|
|
582
|
+
const list = SC.current.senders ??= [];
|
|
583
|
+
list.push({ from: m.from, from_slot: m.from_slot });
|
|
584
|
+
if (list.length > 200) list.splice(0, list.length - 200);
|
|
585
|
+
}
|
|
586
|
+
// -> { slot, label }: the slot to post to and how to show it (slot first, the name it was reached by in brackets).
|
|
587
|
+
// A slot name, or the session holding one ("/say SITE-CLI"), or (senders) a name that recently spoke with a slot.
|
|
588
|
+
async function sayTarget(nm, { senders = false } = {}) {
|
|
589
|
+
if (!nm) return { slot: nm, label: nm };
|
|
590
|
+
let rows = [];
|
|
591
|
+
if (api.slots) { try { rows = await api.slots(); } catch { rows = []; } }
|
|
592
|
+
const t = resolveSayTarget(nm, Array.isArray(rows) ? rows : [], senders ? SC.current.senders : null);
|
|
593
|
+
if (!t) return { slot: nm, label: nm };
|
|
594
|
+
return { slot: t.slot, label: t.via === 'slot' ? t.slot : addressLabel(t.slot, t.name) };
|
|
595
|
+
}
|
|
596
|
+
/** A dropped slot stream: a server restart (connection cut) is routine and says so calmly; anything else keeps the error. */
|
|
597
|
+
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');
|
|
599
|
+
return say(`${where}: live messages paused (${err}); retrying.`, 'warn');
|
|
600
|
+
}
|
|
601
|
+
/** QUOXON-REPLY (owner 2026-10-15: "click the sender to reply"; no mouse tracking, so /r): who last messaged me. */
|
|
602
|
+
function rememberSender(m) {
|
|
603
|
+
const who = m?.from_slot || String(m?.from || '').replace(/\s*\(.*\)\s*$/, '').trim();
|
|
604
|
+
if (!who) return;
|
|
605
|
+
R.current.lastSender = who;
|
|
606
|
+
if (m?.from_slot && !R.current.replyHinted) { // no hint for a sender with no reply address: it would contradict "(no reply address)"
|
|
607
|
+
R.current.replyHinted = true;
|
|
608
|
+
say(`Tab (empty prompt) or /r <text> replies to ${who}`, 'dim');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/** /slots, /slot list: every slot, who holds it and how recently, one line each (held first). */
|
|
612
|
+
async function listSlots(all = false) {
|
|
613
|
+
if (!api.slots) return say('Slots are not available in this mode.', 'warn');
|
|
614
|
+
let rows; try { rows = await api.slots(); } catch (e) { return say(`Couldn't list slots: ${e?.message || 'failed'}`, 'warn'); }
|
|
615
|
+
const list = (Array.isArray(rows) ? rows : []).filter(r => r?.name);
|
|
616
|
+
if (!list.length) return say('No slots yet. /slot <name> gives this session one.');
|
|
617
|
+
const held = list.filter(r => r.holder_session && r.state !== 'free'), free = list.filter(r => !(r.holder_session && r.state !== 'free'));
|
|
618
|
+
const lines = [...slotsTable(list, { mine: where.current.slot, cols: Math.max(20, cols - 2), all }), `${held.length} held, ${free.length} free${where.current.slot ? ' · * this window' : ''}. /say <slot or session> <text> reaches a held one.`];
|
|
619
|
+
for (const n of new Set(list.map(r => r.human_presence?.away && r.human_presence.notice).filter(Boolean))) lines.push(`⏸ ${n}`); // PRESENCE
|
|
620
|
+
return say(lines.join('\n'));
|
|
621
|
+
}
|
|
622
|
+
async function sendToSlot(slot, text) {
|
|
623
|
+
const target = await sayTarget(slot, { senders: true });
|
|
624
|
+
slot = target.slot;
|
|
625
|
+
if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
|
|
626
|
+
if (SC.current.rooms.has(slot)) return postRoom(slot, text); // a joined room posts as a member
|
|
627
|
+
const r = await api.slotSay(slot, text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
|
|
628
|
+
if (!r.ok && r.status === 404 && api.slots) { let rows = []; try { rows = await api.slots(); } catch { /* the list is a hint only */ } return say(sayMissText(slot, rows), 'warn'); }
|
|
629
|
+
if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
|
|
630
|
+
const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
|
|
631
|
+
dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
|
|
632
|
+
announceDelivery(slot, r);
|
|
633
|
+
watchReplies(slot, r.message);
|
|
634
|
+
if (r.humanPresence?.away && r.humanPresence.notice) say(`⏸ ${r.humanPresence.notice}`, 'warn'); // PRESENCE
|
|
635
|
+
if (r.roomPending) say(`${slot} is new: listening windows have it; its chat room copy is queued, delivering when the room is ready.`, 'dim');
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
// ---- QUOXON-ROOMS: a slot as a group room. Joining subscribes (no hold); `#room text` posts; rooms list with unread. ----
|
|
639
|
+
const roomMe = () => slotSession();
|
|
640
|
+
const roomSigned = () => { const d = myDisplay(); return d ? `${roomMe()} (${d})` : roomMe(); };
|
|
641
|
+
const isMine = (m) => { const f = String(m?.from || ''); return f === roomMe() || f.startsWith(`${roomMe()} (`); };
|
|
642
|
+
async function joinRoom(nm, { resume = false } = {}) {
|
|
643
|
+
if (!api.slotJoin || !api.slotStream) return say('Rooms are not available in this mode.', 'warn');
|
|
644
|
+
const rooms = SC.current.rooms;
|
|
645
|
+
if (rooms.has(nm)) { if (!resume) say(`Already in #${nm}. #${nm} <text> posts; #${nm} alone makes it the default.`, 'dim'); return true; }
|
|
646
|
+
const me = roomMe();
|
|
647
|
+
if (!resume) {
|
|
648
|
+
const r = await api.slotJoin(nm, me, myDisplay());
|
|
649
|
+
if (!r.ok) { say(`#${nm}: ${r.message}`, 'warn'); return false; }
|
|
650
|
+
}
|
|
651
|
+
let since = null;
|
|
652
|
+
try { // a one-line-each backlog of the last ~10, so a join lands in the middle of the conversation
|
|
653
|
+
const rows = await (api.slotMessages ? api.slotMessages(nm, 10) : []);
|
|
654
|
+
for (const m of rows) { dispatch({ type: 'slotmsg', line: roomLine(nm, m) }); since = m.created_at || since; }
|
|
655
|
+
} catch { /* the live stream still starts; the backlog is a courtesy */ }
|
|
656
|
+
if (R.current.closed) return false;
|
|
657
|
+
const entry = { listener: null, unread: 0 };
|
|
658
|
+
entry.listener = createSlotListener({
|
|
659
|
+
open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: me, session: me }, signal),
|
|
660
|
+
wait: api.slotWait ? ({ since: sn }) => api.slotWait(nm, { since: sn, excludeFrom: me }) : null,
|
|
661
|
+
onMessage: (m) => {
|
|
662
|
+
noteReply(m); noteSender(m);
|
|
663
|
+
if (R.current.closed || !SC.current.rooms.has(nm) || isMine(m)) return;
|
|
664
|
+
if (SC.current.active !== nm) entry.unread += 1; // before the dispatch, so the status line's count is current
|
|
665
|
+
dispatch({ type: 'slotmsg', line: roomLine(nm, m) });
|
|
666
|
+
rememberSender(m);
|
|
667
|
+
if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
|
|
668
|
+
},
|
|
669
|
+
onState: (st) => { if (st.error && st.failures === 1 && !R.current.closed) streamPausedNote(`#${nm}`, st.error); },
|
|
670
|
+
});
|
|
671
|
+
rooms.set(nm, entry); bump(n => n + 1); // the status line's groups update now, even on a quiet resume
|
|
672
|
+
entry.listener.start({ since });
|
|
673
|
+
if (!resume) say(`Joined #${nm}. #${nm} <text> posts as ${roomSigned()}; #${nm} alone makes it the default target (a bare # undoes it); /leave ${nm} leaves.`, 'ok');
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
async function leaveRoom(nm) {
|
|
677
|
+
const e = SC.current.rooms.get(nm);
|
|
678
|
+
if (!e) return say(`You are not in #${nm}. /rooms lists the rooms you are in.`, 'dim');
|
|
679
|
+
e.listener?.stop(); SC.current.rooms.delete(nm);
|
|
680
|
+
if (SC.current.active === nm) SC.current.active = null;
|
|
681
|
+
bump(n => n + 1);
|
|
682
|
+
const r = await api.slotLeave?.(nm, roomMe());
|
|
683
|
+
say(r && !r.ok ? `Left #${nm} here; the server did not confirm (${r.message}).` : `Left #${nm}.`, r && !r.ok ? 'warn' : 'ok');
|
|
684
|
+
}
|
|
685
|
+
function showRooms() {
|
|
686
|
+
const rooms = SC.current.rooms;
|
|
687
|
+
if (!rooms.size) return say('No rooms joined. /join <slot> joins one (any slot in this org).', 'dim');
|
|
688
|
+
for (const [nm, e] of rooms) {
|
|
689
|
+
const live = e.listener?.state?.().connected ? '' : ' (reconnecting)';
|
|
690
|
+
say(`${SC.current.active === nm ? '> ' : ' '}#${nm} ${e.unread ? `${e.unread} unread` : 'no unread'}${SC.current.active === nm ? ' (default target)' : ''}${live}`, e.unread ? 'accent' : 'dim');
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
async function setActiveRoom(nm) {
|
|
694
|
+
if (!(await joinRoom(nm))) return;
|
|
695
|
+
SC.current.active = nm; const e = SC.current.rooms.get(nm); if (e) e.unread = 0;
|
|
696
|
+
say(`Default target is #${nm}: plain lines now post there. A bare # puts it back.`, 'ok');
|
|
697
|
+
}
|
|
698
|
+
async function postRoom(nm, text) {
|
|
699
|
+
if (!api.slotSay) return say('Rooms are not available in this mode.', 'warn');
|
|
700
|
+
if (!SC.current.rooms.has(nm) && !(await joinRoom(nm))) return;
|
|
701
|
+
const r = await api.slotSay(nm, text, { roomSession: roomMe(), fast: true });
|
|
702
|
+
if (!r.ok) return say(`#${nm}: ${r.message}`, 'warn');
|
|
703
|
+
const e = SC.current.rooms.get(nm); if (e) e.unread = 0;
|
|
704
|
+
dispatch({ type: 'slotmsg', line: { ...roomLine(nm, { from: roomSigned(), text, created_at: r.message?.created_at || new Date().toISOString() }, { out: true }), delivered: true } });
|
|
705
|
+
}
|
|
706
|
+
/** `/group-chat join|leave|<text>` and /join, /leave: a typed slot name is checked BEFORE "Ask Quox". */
|
|
707
|
+
async function slotNameCommand(cmdWord, rest) {
|
|
708
|
+
let nm = roomName(cmdWord.replace(/^\//, ''));
|
|
709
|
+
if (!nm || !api.slots) return false;
|
|
710
|
+
let rows = null;
|
|
711
|
+
try { rows = await api.slots(); } catch { return false; }
|
|
712
|
+
const hit = uniqueSquash(nm, (Array.isArray(rows) ? rows : []).map(x => x.name));
|
|
713
|
+
if (!hit) return false;
|
|
714
|
+
nm = hit.toLowerCase();
|
|
715
|
+
const t = String(rest || '').trim();
|
|
716
|
+
if (/^join$/i.test(t)) await joinRoom(nm);
|
|
717
|
+
else if (/^leave$/i.test(t)) await leaveRoom(nm);
|
|
718
|
+
else if (!t) await setActiveRoom(nm);
|
|
719
|
+
else await postRoom(nm, t);
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
React.useEffect(() => { // rooms are server truth: a restarted named window rejoins quietly
|
|
723
|
+
// keyed like roomMe(): the name, else the conversation an unnamed window joined under (restart resumes it)
|
|
724
|
+
const me = SN.current.name || resumeConversation;
|
|
725
|
+
if (blocked || demoLabel || !api.slotRooms || !me) return;
|
|
726
|
+
api.slotRooms(me).then((rows) => { for (const r of rows) joinRoom(String(r.slot_name), { resume: true }); }).catch(() => {});
|
|
727
|
+
}, []);
|
|
728
|
+
|
|
729
|
+
async function slotLog(nArg) {
|
|
730
|
+
const nm = where.current.slot;
|
|
731
|
+
if (!nm) return say('No slot held. /slot <name> gives this session one.', 'warn');
|
|
732
|
+
if (!api.slotMessages) return say('Slots are not available in this mode.', 'warn');
|
|
733
|
+
const n = Math.min(Math.max(parseInt(nArg, 10) || 20, 1), 100);
|
|
734
|
+
let rows;
|
|
735
|
+
try { rows = await api.slotMessages(nm, n); } catch (e) { return say(`slot ${nm}: ${e?.message || 'failed'}`, 'warn'); }
|
|
736
|
+
if (!rows.length) return say(`No messages in ${nm} yet.`);
|
|
737
|
+
say(`Last ${rows.length} in ${nm} (seen by Quox):`);
|
|
738
|
+
for (const m of rows) { // SLOT-RUN: an old run request is shown, never armed (only a live one can be answered)
|
|
739
|
+
noteSender(m); const rq = runRequest(m);
|
|
740
|
+
dispatch({ type: 'slotmsg', line: { ...slotLine(m), full: true, ...(rq ? { run: { ...rq, runnable: false, danger: null, refuse: 'it was sent earlier, and only a live request can be answered here' } } : {}) } });
|
|
741
|
+
}
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
async function sendFile(slot, p) {
|
|
745
|
+
const f = prepareFile(p, { cwd: realCwd() });
|
|
746
|
+
if (!f.ok) return say(f.message, 'warn');
|
|
747
|
+
if (f.hits) {
|
|
748
|
+
SC.current.pendingFile = { slot, f, at: Date.now() };
|
|
749
|
+
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
|
+
}
|
|
751
|
+
return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }));
|
|
752
|
+
}
|
|
753
|
+
// Startup: the slot this profile last held in this directory, free or stale (or still under our old label), is offered once.
|
|
754
|
+
React.useEffect(() => {
|
|
755
|
+
if (blocked || demoLabel || restartSlot || !api.checkoutSlot || !api.slots) return;
|
|
756
|
+
const rec = loadLastSlot({ profile, cwd: realCwd(), path: slotRecordPath });
|
|
757
|
+
if (!rec) return;
|
|
758
|
+
api.slots().then((rows) => {
|
|
759
|
+
const row = (Array.isArray(rows) ? rows : []).find(x => String(x.name) === rec.slot);
|
|
760
|
+
if (!row || SC.current.slot || R.current.closed) return; // only this window's own checkout suppresses the ask, not an inherited one
|
|
761
|
+
const mine = row.holder_session && rec.session && row.holder_session === rec.session;
|
|
762
|
+
if (row.state === 'free' || row.state === 'stale' || !row.holder_session || mine) {
|
|
763
|
+
SC.current.reattach = { slot: rec.slot, take: Boolean(mine || row.state === 'stale'), at: Date.now() };
|
|
764
|
+
ask(`Reattach to slot ${rec.slot}?`, { action: `/slot ${rec.slot}` });
|
|
765
|
+
}
|
|
766
|
+
}).catch(() => { /* no ask when the server cannot say */ });
|
|
767
|
+
}, []);
|
|
172
768
|
React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume)); }, []);
|
|
173
|
-
React.useEffect(() => { if (!blocked &&
|
|
769
|
+
React.useEffect(() => { if (!blocked && !demoLabel) aiConnected().then((st) => { R.current.noAi = st === false ? true : st === true ? false : undefined; bump(n => n + 1); }); }, []);
|
|
770
|
+
// 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.
|
|
772
|
+
const MO = React.useRef(null);
|
|
773
|
+
MO.current ??= { on: (api.settings?.() || {}).mouse === 'on', tty: false, chain: Promise.resolve(), drag: null, last: null, onCursor: null, exitFn: null };
|
|
774
|
+
const TP = React.useRef(null);
|
|
775
|
+
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
|
+
const showTip = async ({ startup: atLaunch = false } = {}) => {
|
|
777
|
+
const t = TP.current;
|
|
778
|
+
if (t.mode === 'off') return atLaunch ? undefined : say('Tips are off. /tip-settings all turns them back on.');
|
|
779
|
+
let hosts; try { hosts = await api.hostCount?.(); } catch { hosts = undefined; } // unknown never skips a tip
|
|
780
|
+
if (atLaunch && S.current.approvals.length) return undefined; // never over an approval
|
|
781
|
+
let plugins; try { plugins = await api.installedPlugins?.(); } catch { plugins = undefined; } // unreadable = plugin tips hidden
|
|
782
|
+
const signals = { hosts, modelFavs: MP.current.prefs.favourites.length, rows: RW.current.all().length };
|
|
783
|
+
const contract = R.current.contract || null;
|
|
784
|
+
const r = nextTip({ mode: t.mode, contract, plugins, signals, used: t.used, seen: t.seen });
|
|
785
|
+
if (!r.tip) return atLaunch ? undefined : say('No tips to show right now for this install.');
|
|
786
|
+
t.seen = r.seen; api.saveSetting?.('tipsSeen', t.seen);
|
|
787
|
+
if (R.current.sleep) return undefined; // no tip over the sleep screen
|
|
788
|
+
return dispatch({ type: 'tip', text: tipText(r.tip), cmd: r.tip.try });
|
|
789
|
+
};
|
|
790
|
+
const tipStarted = React.useRef(false);
|
|
791
|
+
// What's new (whatsnew.js): once per newer build; first ever run only records. Wins the startup slot over the tip.
|
|
792
|
+
const showWhatsNew = () => {
|
|
793
|
+
if (!whatsNewStartup || blocked || demoLabel) return false;
|
|
794
|
+
const st = api.settings?.() || {};
|
|
795
|
+
const r = startupWhatsNew({ build: version, last: st.whatsNew, entries: whatsNewEntries });
|
|
796
|
+
if (r.record) api.saveSetting?.('whatsNew', r.record);
|
|
797
|
+
if (welcome || !startupEnabled(st)) return false;
|
|
798
|
+
if (r.show.length) { dispatch({ type: 'whatsnew', items: r.show.map(e => ({ title: e.title, cmd: tryText(e) })), more: r.more }); return true; }
|
|
799
|
+
const line = startupCommandLine(unseenCommands({ registry: visibleRegistry(), seen: st.whatsNewSeen })); // /whatsnew marks them seen
|
|
800
|
+
if (!line) return false;
|
|
801
|
+
dispatch({ type: 'whatsnew', head: "What's new", pad: true, items: [{ title: line, cmd: '' }], more: 0 });
|
|
802
|
+
return true;
|
|
803
|
+
};
|
|
804
|
+
const startupTip = () => {
|
|
805
|
+
if (tipStarted.current || blocked) return;
|
|
806
|
+
tipStarted.current = true;
|
|
807
|
+
const shown = showWhatsNew();
|
|
808
|
+
if (tipStartup && !welcome && !shown) showTip({ startup: true });
|
|
809
|
+
};
|
|
810
|
+
React.useEffect(() => {
|
|
811
|
+
if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); startupTip(); }, () => startupTip());
|
|
812
|
+
else startupTip();
|
|
813
|
+
}, []);
|
|
174
814
|
|
|
175
815
|
React.useEffect(() => {
|
|
176
816
|
if (columns != null) { setCols(clampCols(columns)); return undefined; }
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
const onResize = () =>
|
|
180
|
-
if (stdout?.isTTY !== false) stdout.write('\x1b[2J\x1b[H');
|
|
181
|
-
setEpoch(e => e + 1);
|
|
182
|
-
setCols(clampCols((stdout.columns || 80) - 1));
|
|
183
|
-
};
|
|
817
|
+
// Only the live region repaints at the new width (R3 D2): the stdout proxy (term.js) erases the old frame
|
|
818
|
+
// by its reflowed height, so no ghost composers, and history is never reprinted into scrollback.
|
|
819
|
+
const onResize = () => setCols(clampCols((stdout.columns || 80) - 1));
|
|
184
820
|
stdout?.on?.('resize', onResize);
|
|
185
821
|
return () => stdout?.off?.('resize', onResize);
|
|
186
822
|
}, [stdout, columns]);
|
|
@@ -191,34 +827,102 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
191
827
|
const SN = React.useRef({ name: cleanName(sessionName), saved: null, agent: null, baseAgent: where.current.agent ?? null });
|
|
192
828
|
React.useEffect(() => {
|
|
193
829
|
const id = S.current.ctx.conversationId; const key = `${id}|${SN.current.name}`;
|
|
194
|
-
if (SN.current.name && id && SN.current.saved !== key) { SN.current.saved = key; saveName(id, SN.current.name, namesPath); }
|
|
830
|
+
if (SN.current.name && id && SN.current.saved !== key) { SN.current.saved = key; saveName(id, SN.current.name, namesPath, { profile, org: R.current.orgName ?? org ?? null }); }
|
|
195
831
|
});
|
|
196
832
|
|
|
197
833
|
// Live approvals count: refreshed from the inbox every 30 s. A failed refresh keeps the last
|
|
198
834
|
// known count and records the error; it never reads as "0 waiting".
|
|
835
|
+
// The org's name, or "org …H398" while it is unknown; never a raw ULID. A lookup that failed at startup (server down)
|
|
836
|
+
// is retried on the next successful poll (owner 2026-10-18).
|
|
837
|
+
const orgShown = () => orgLabel(R.current.orgName ?? org, R.current.orgId ?? api.orgId?.() ?? null);
|
|
838
|
+
const retryOrgName = async () => {
|
|
839
|
+
const cur = R.current.orgName ?? org;
|
|
840
|
+
if ((cur && !isOrgId(cur)) || !api.orgs || R.current.orgLookup) return;
|
|
841
|
+
const id = R.current.orgId ?? api.orgId?.() ?? null; if (!id) return;
|
|
842
|
+
R.current.orgLookup = true;
|
|
843
|
+
try {
|
|
844
|
+
const hit = (await api.orgs()).find((o) => o?.id === id);
|
|
845
|
+
const nm = hit?.name || hit?.slug;
|
|
846
|
+
if (nm && !isOrgId(nm)) { R.current.orgName = nm; dispatch({ type: 'notify', patch: {} }); }
|
|
847
|
+
} catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
|
|
848
|
+
};
|
|
199
849
|
const refreshNotify = async () => {
|
|
200
|
-
if (
|
|
850
|
+
if (BL.current) return;
|
|
851
|
+
const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
|
|
201
852
|
try {
|
|
202
853
|
const items = await api.approvals();
|
|
854
|
+
if (N.authLost) say('Signed in again: approvals are back.', 'ok');
|
|
855
|
+
else if (N.slow) say('Quox server is back.', 'ok');
|
|
856
|
+
N.fails = 0; N.authLost = false; N.slow = false;
|
|
203
857
|
dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
|
|
858
|
+
retryOrgName();
|
|
204
859
|
dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
|
|
860
|
+
R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
|
|
205
861
|
// Provider paths that emit no approval.requested still surface here, as the same card.
|
|
206
862
|
for (const item of items) dispatch({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
|
|
207
|
-
} catch (e) {
|
|
863
|
+
} catch (e) {
|
|
864
|
+
N.fails++; // counted apart from an empty inbox
|
|
865
|
+
const msg = e?.message || 'failed';
|
|
866
|
+
if (!e?.transient && (e?.status === 401 || isSessionDead(e) || /No authentication token|service key|unauthori[sz]ed/i.test(msg))) {
|
|
867
|
+
N.authLost = true;
|
|
868
|
+
markSignedOut(e?.reason || null); // one card, then the pollers go quiet until /login
|
|
869
|
+
dispatch({ type: 'notify', patch: { error: null } });
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (e?.transient && !N.slow) { N.slow = true; say('Quox server is slow or unreachable, retrying.', 'warn'); } // QUOXON-FW12: not signed out
|
|
873
|
+
dispatch({ type: 'notify', patch: { error: msg } });
|
|
874
|
+
}
|
|
208
875
|
};
|
|
209
876
|
React.useEffect(() => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
877
|
+
// Back off on repeated failure (x2 per miss, capped at 60 s); the normal cadence returns after one success.
|
|
878
|
+
let timer = null, stopped = false;
|
|
879
|
+
const tick = async () => {
|
|
880
|
+
// Stale sign-in: the credentials file changed (another window refreshed, or `quox login` ran). While this
|
|
881
|
+
// window reads as signed out, re-read them and retry once; a still-dead session just marks itself again.
|
|
882
|
+
try {
|
|
883
|
+
if (api.credentialsChanged?.() && BL.current && signedOutRef.current && api.reloadCredentials?.()) {
|
|
884
|
+
setSignedOut(null); BL.current = blockedProp || null;
|
|
885
|
+
R.current.notify = { fails: 0, authLost: false };
|
|
886
|
+
refreshRoster();
|
|
887
|
+
}
|
|
888
|
+
} catch { /* a failed stat never blocks the poll */ }
|
|
889
|
+
await refreshNotify();
|
|
890
|
+
if (stopped) return;
|
|
891
|
+
const f = R.current.notify?.fails || 0;
|
|
892
|
+
timer = setTimeout(tick, f ? Math.min(60000, notifyMs * 2 ** Math.min(f, 12)) : notifyMs);
|
|
893
|
+
};
|
|
894
|
+
tick();
|
|
895
|
+
return () => { stopped = true; clearTimeout(timer); };
|
|
896
|
+
}, []);
|
|
897
|
+
|
|
898
|
+
// #904 part 3: a provider refusing turns (weekly limit, no credits) shows in the status area before
|
|
899
|
+
// you type into it. Polled once a minute; a failed read keeps the last answer (never "fine").
|
|
900
|
+
React.useEffect(() => {
|
|
901
|
+
if (!api.providerHealth) return undefined;
|
|
902
|
+
let timer = null, stopped = false;
|
|
903
|
+
const tick = async () => {
|
|
904
|
+
if (!BL.current) {
|
|
905
|
+
const list = await api.providerHealth();
|
|
906
|
+
// Only the provider this window uses (owner 2026-10-15: "only need to know if using openai"): the chosen
|
|
907
|
+
// model's provider, else the provider the last turn ran on. Unknown yet: say nothing; a failing turn says why.
|
|
908
|
+
const mine = String(MC.current?.provider || S.current?.ctx?.provider || '').toLowerCase();
|
|
909
|
+
const hits = list ? list.filter(e => mine && String(e.provider || '').toLowerCase() === mine) : null;
|
|
910
|
+
if (hits) dispatch({ type: 'notify', patch: { providerLines: hits.map(e => `⚠ ${e.line || e.provider + ' ' + e.state}: chat on it fails until then`) } });
|
|
911
|
+
}
|
|
912
|
+
if (!stopped) timer = setTimeout(tick, 60000);
|
|
913
|
+
};
|
|
914
|
+
tick();
|
|
915
|
+
return () => { stopped = true; clearTimeout(timer); };
|
|
213
916
|
}, []);
|
|
214
917
|
|
|
215
918
|
// Roster: refreshed from real list endpoints. A failing source adds no rows and is named in /roster.
|
|
216
919
|
const refreshRoster = async () => {
|
|
217
|
-
if (
|
|
920
|
+
if (BL.current || !api.roster) return;
|
|
218
921
|
try {
|
|
219
922
|
let r = await api.roster(Date.now());
|
|
220
923
|
const R0 = R.current.roster;
|
|
221
924
|
const prevKey = R0.rows[R0.sel]?.key;
|
|
925
|
+
applyProgress(r.rows, (R0.progress ||= new Map()), Date.now());
|
|
222
926
|
R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
|
|
223
927
|
r = { ...r, rows: selectRows(r.rows, { all: R0.all }) };
|
|
224
928
|
R0.rows = r.rows;
|
|
@@ -257,6 +961,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
257
961
|
if (!framesRef.current) framesRef.current = createFrames();
|
|
258
962
|
const lastAppRender = React.useRef(0);
|
|
259
963
|
const ringRef = React.useRef(null);
|
|
964
|
+
const dividerSlowRef = React.useRef({}); // TUI-DIVIDER: per-turn slow-redraw marker (dividerRule.noteFrameGap)
|
|
260
965
|
const onClockTick = React.useRef(null);
|
|
261
966
|
onClockTick.current = () => {
|
|
262
967
|
if (R.current.closed) return;
|
|
@@ -275,14 +980,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
275
980
|
if (!introDone.current) return true;
|
|
276
981
|
const sec = (t) => Math.floor(t / 1000);
|
|
277
982
|
if (R.current.roster.rows.length && sec(now) !== sec(lastAppRender.current)) return true; // roster ages
|
|
983
|
+
if (motionOn && !mono() && R.current.roster.rows.some((x) => x.state === 'running')) return true; // pulse spinner frames
|
|
278
984
|
if (motionOn) {
|
|
279
|
-
if (s0
|
|
985
|
+
if (commitLimit(s0) > committedRef.current && !fxActive(s0.history[committedRef.current], now) && !isActiveStage(s0, committedRef.current)) return true; // commit to Static
|
|
280
986
|
for (const r of R.current.roster.rows) if (rosterFx(r.key, now) != null || !rosterSeen.current.has(r.key)) return true;
|
|
281
987
|
}
|
|
282
988
|
const card0 = s0.approvals[0];
|
|
283
989
|
if (card0) {
|
|
284
990
|
const age = now - (card0.displayedAt ?? card0.shownAt);
|
|
285
|
-
if (motionOn && age < 1000) return true; // arrival animation
|
|
991
|
+
if (motionOn && (age < 1000 || R.current.cardMidAnim)) return true; // arrival animation, plus one settled render if the last draw was mid-animation (a late tick on a loaded box left the title scrambled for good)
|
|
286
992
|
if (!R.current.armedShown && age >= 300) return true; // the card arms
|
|
287
993
|
}
|
|
288
994
|
return false;
|
|
@@ -296,10 +1002,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
296
1002
|
if (s0.turn) d.push(motionOn ? { every: FRAME_MS } : { every: SECOND_MS, since: s0.turn.startedAt });
|
|
297
1003
|
const card0 = s0.approvals[0];
|
|
298
1004
|
if (card0) { const at = (card0.displayedAt ?? card0.shownAt) + 320; if (now < at) d.push(motionOn ? { every: FRAME_MS } : { at }); }
|
|
299
|
-
if (R.current.roster.rows.length) d.push({ every: SECOND_MS });
|
|
1005
|
+
if (R.current.roster.rows.length) d.push({ every: motionOn && !mono() && R.current.roster.rows.some((x) => x.state === 'running') ? SPIN_MS : SECOND_MS }); // SPIN_MS: the roster pulse spinner
|
|
300
1006
|
if (ringAnimating(ringRef.current)) d.push({ every: FRAME_MS }); // the ring settling after the turn
|
|
301
|
-
if (
|
|
1007
|
+
if (motionOn && !mono() && dividerLive(s0.divider, now)) { // TUI-DIVIDER: the AOCL strip, then its green/red hold; nothing once it is over
|
|
1008
|
+
d.push({ every: FRAME_MS });
|
|
1009
|
+
if (s0.divider.endedAt != null) d.push({ at: s0.divider.endedAt + (s0.divider.phase === 'done' ? GREEN_MS : RED_MS) + 1 });
|
|
1010
|
+
}
|
|
1011
|
+
if (dragonActive(now, !motionOn || mono())) d.push({ every: motionOn && !mono() ? 40 : 500 }); // /dragon and the cinematic moods (qmotion DragonLive)
|
|
302
1012
|
if (motionOn && fxBusy(now)) d.push({ every: FRAME_MS }); // reveals in flight
|
|
1013
|
+
const sl = R.current.sleep;
|
|
1014
|
+
if (sl) d.push({ every: sl.wakeAt != null ? 50 : sl.still ? 1000 : 120 }); // sleep screen: low frame rate (shimmer, slow sway)
|
|
1015
|
+
if (dragonTalkVisible(now)) d.push({ every: motionOn && !mono() ? 60 : 500 }); // /dragon explain|ask
|
|
1016
|
+
if (flyActive(now)) d.push({ every: R.current.fly.still ? 500 : 60 }); // /dragon-fly
|
|
303
1017
|
return d;
|
|
304
1018
|
};
|
|
305
1019
|
React.useEffect(() => { if (!R.current.closed) clockRef.current.schedule(demands()); });
|
|
@@ -308,23 +1022,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
308
1022
|
const fxActive = (item, now) => { const e = fxRef.current.get(item.id); return Boolean(e) && now < fxEnd(e); };
|
|
309
1023
|
const fxFor = (item, now) => {
|
|
310
1024
|
const e = fxRef.current.get(item.id);
|
|
311
|
-
if (!e || now >= fxEnd(e)) return null;
|
|
1025
|
+
if (!e || now >= fxEnd(e) || R.current.exiting) return null;
|
|
312
1026
|
return { e: (now - e.at) / 1000, timing: e.timing, settle: e.settleAt != null ? (now - e.settleAt) / 1000 : null };
|
|
313
1027
|
};
|
|
314
1028
|
const registerFx = (item, now) => {
|
|
315
1029
|
const t = revealTimingFor(item);
|
|
316
1030
|
if (!t) return;
|
|
1031
|
+
if (item.kind === 'object' && !fxRef.current.has(item.id) && cardTallerThanScreen(item, cols, termRows(stdout) ?? 40, now)) return; // TUI-UX #5: a card taller than the screen never decodes in (it would leave a garbled copy in scrollback)
|
|
317
1032
|
const e = fxRef.current.get(item.id);
|
|
318
|
-
if (!e) fxRef.current.set(item.id, { at: now, timing: t, running: item.kind === 'tool' && item.status === 'running', settleAt: null });
|
|
1033
|
+
if (!e) { fxRef.current.set(item.id, { at: now, timing: t, running: item.kind === 'tool' && item.status === 'running', settleAt: null }); if (motionStats) (motionStats.reveals ||= []).push({ id: item.id, kind: item.kind, text: item.text ?? null, timing: t }); } // motionStats: which items started a decode-in, for a test that cannot rely on sampling a 0.28 s window
|
|
319
1034
|
else if (e.running && item.status !== 'running' && e.settleAt == null) e.settleAt = now;
|
|
320
1035
|
};
|
|
321
1036
|
const rosterFx = (key, now) => { const at = rosterSeen.current.get(key); const e = at == null ? null : (now - at) / 1000; return e != null && e < revealSpan(TIMING.roster) ? e : null; };
|
|
322
1037
|
function fxBusy(now) {
|
|
323
1038
|
const s0 = S.current;
|
|
324
|
-
if (s0
|
|
1039
|
+
if (commitLimit(s0) > committedRef.current) return true;
|
|
325
1040
|
if (s0.blocks.some(b => fxActive(b, now))) return true;
|
|
326
1041
|
const card0 = s0.approvals[0];
|
|
327
|
-
if (card0 && now - (card0.displayedAt ?? card0.shownAt) < 1000) return true;
|
|
1042
|
+
if (card0 && (now - (card0.displayedAt ?? card0.shownAt) < 1000 || R.current.cardMidAnim)) return true; // owed a settled draw
|
|
328
1043
|
for (const r of R.current.roster.rows) if (rosterFx(r.key, now) != null) return true;
|
|
329
1044
|
return false;
|
|
330
1045
|
}
|
|
@@ -348,22 +1063,326 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
348
1063
|
}
|
|
349
1064
|
return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
|
|
350
1065
|
};
|
|
351
|
-
|
|
1066
|
+
/** Where an answer came from, for the approve comment: the session name, else the conversation id, else a marker. */
|
|
1067
|
+
const slotSession = () => SN.current.name || S.current.ctx.conversationId || 'quox';
|
|
1068
|
+
const agoText = (iso) => { const m = iso ? Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000)) : null; return m == null || Number.isNaN(m) ? 'a while ago' : m < 1 ? 'just now' : m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`; };
|
|
1069
|
+
// Check a slot out for this window. A hold by another session of the SAME user is offered as a takeover (y/N, or --take).
|
|
1070
|
+
const takeSlot = async (nm, take, { quietTransient = false } = {}) => {
|
|
1071
|
+
const r = await api.checkoutSlot(nm, slotSession(), { take });
|
|
1072
|
+
if (!r.ok && r.transient && quietTransient) return r; // carrySlot retries and speaks for itself
|
|
1073
|
+
if (r.ok) {
|
|
1074
|
+
where.current = { ...where.current, slot: nm }; bump(x => x + 1);
|
|
1075
|
+
listenSlot(nm);
|
|
1076
|
+
saveLastSlot({ profile, cwd: realCwd(), slot: nm, session: slotSession(), path: slotRecordPath });
|
|
1077
|
+
const from = r.tookOver?.from;
|
|
1078
|
+
return say(from ? `Slot ${nm} checked out (took over your ${r.tookOver.state === 'stale' ? 'stale ' : ''}session '${from}'). Messages to ${nm} show here as they arrive.` : `Slot ${nm} checked out. Messages to ${nm} show here as they arrive; @<slot> <text> sends.`, 'ok');
|
|
1079
|
+
}
|
|
1080
|
+
const h = r.held;
|
|
1081
|
+
if (h?.same_user) {
|
|
1082
|
+
R.current.slotAsk = { nm, at: Date.now() };
|
|
1083
|
+
return ask('Take it over?', { action: `/slot ${nm} --take`, reason: `${nm} is held by your other session '${h.holder_session}' (last seen ${agoText(h.last_seen)}); /slot ${nm} --take also works.` });
|
|
1084
|
+
}
|
|
1085
|
+
if (h) return say(`slot ${nm} is held by ${h.holder_session || 'another user'}${h.since ? ` since ${h.since}` : ''}. It is not yours to take.`, 'warn');
|
|
1086
|
+
if (r.unlicensed) return say(r.message, 'warn');
|
|
1087
|
+
return say(`slot ${nm}: ${r.message}`, 'warn');
|
|
1088
|
+
};
|
|
1089
|
+
// QUOXON-STEPUP: the server decides what needs a person. We try with any live step-up token; on 403 step_up_required
|
|
1090
|
+
// we ask for the PIN (masked card) and retry once. `proactive` (/authorise) asks first. Deny never needs a PIN.
|
|
1091
|
+
const respondWithStepUp = async (card, action, { proactive = false, reason = null, bulk = false } = {}) => {
|
|
1092
|
+
const via = `tty ${sessionLabel()}`;
|
|
1093
|
+
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
|
+
const ask = async () => {
|
|
1095
|
+
const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject });
|
|
1096
|
+
if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; }
|
|
1097
|
+
};
|
|
1098
|
+
if (action !== 'approve') return api.respond(card.inboxItemId, action, { via, reason });
|
|
1099
|
+
if (proactive && !SU.current.token('approve')) await ask();
|
|
1100
|
+
try { return await api.respond(card.inboxItemId, action, { via, stepUp: SU.current.headers('approve'), bulk }); }
|
|
1101
|
+
catch (e) {
|
|
1102
|
+
if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
|
|
1103
|
+
SU.current.drop('approve'); // a stale or mismatched token must not be reused
|
|
1104
|
+
await ask();
|
|
1105
|
+
return api.respond(card.inboxItemId, action, { via, stepUp: SU.current.headers('approve'), bulk });
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
// QUOXON-STEPUP: an override id is single-use; the next turn carries it once, then it is gone.
|
|
1109
|
+
const takeOverride = () => { const o = R.current.override; R.current.override = null; return o && o.until > Date.now() ? o.id : undefined; };
|
|
1110
|
+
const sessionLabel = () => String(SN.current.name || (S.current.ctx.conversationId ? String(S.current.ctx.conversationId).slice(0, 12) : 'new session')).replace(/\s+/g, ' ').slice(0, 40);
|
|
1111
|
+
// APPROVALS-AUDIT: a typed number is a position. If the list moved since the owner last saw it, refuse and show it again.
|
|
1112
|
+
const listChanged = async () => {
|
|
1113
|
+
if (R.current.walkSend || !R.current.shownIds) return false;
|
|
1114
|
+
let items; try { items = await api.approvals(); } catch { return false; }
|
|
1115
|
+
const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
|
|
1116
|
+
const ids = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item.id));
|
|
1117
|
+
const seen = R.current.shownIds;
|
|
1118
|
+
if (ids.length === seen.length && ids.every((x, i) => x === seen[i])) return false;
|
|
1119
|
+
say('The list changed since you last saw it, so nothing was sent. Here it is again:', 'warn');
|
|
1120
|
+
await command('/approvals');
|
|
1121
|
+
return true;
|
|
1122
|
+
};
|
|
1123
|
+
// WALKTHROUGH: decisions one card at a time. Identity is the decision id (items are held by id), never a list position.
|
|
1124
|
+
// WALK-FIXES (owner 2026-10-17: "escape to exit survey. two came at once. didnt submit each answer as i gave it, exited
|
|
1125
|
+
// halfway thru and tried again, answered question returned"): ONE walk and ONE card on screen at a time (a second start
|
|
1126
|
+
// joins the walk already running); each answer is sent the moment it is given and the next card shows as soon as the
|
|
1127
|
+
// server took it; a line typed while an answer is still on its way is never applied to the card just answered; Esc or any
|
|
1128
|
+
// command ends the walk with a summary; an id answered in this window never comes back, and a list held from earlier
|
|
1129
|
+
// (a bulk card, a range) is re-read from the server before its first card.
|
|
1130
|
+
const WALK_TTL = 1800000;
|
|
1131
|
+
const walkAnsweredIds = () => (R.current.walkAnswered ||= new Set());
|
|
1132
|
+
const walkCloseCard = (W, why = 'next') => { if (W?.card) { W.card.at = Date.now(); W.card.walkWhy = why; W.card = null; } }; // why: next/done settle silently; stop = interrupted
|
|
1133
|
+
const walkKeysText = (it) => { const ks = it.payload.options.map(o => o.key); return ks.length > 1 ? `${ks.slice(0, -1).join(', ')} or ${ks[ks.length - 1]}` : ks.join(''); };
|
|
1134
|
+
// EXPIRED-ASK (owner 2026-10-17 21:23): a question whose time ran out is gone: say so plainly and move on, never "the question stays".
|
|
1135
|
+
const askExpiry = (it) => { const t = Date.parse(it?.due_at ?? it?.payload?.expires_at); return Number.isFinite(t) ? t : null; };
|
|
1136
|
+
const expiredText = (it) => { const t = askExpiry(it); return t ? `That question expired at ${askClock(t)}, so there's nothing to answer.` : "That question is no longer open, so there's nothing to answer."; };
|
|
1137
|
+
const walkGoneElsewhere = (W, id) => { const o = R.current.openIds; return Boolean(o && o.at > W.startedAt && !o.ids.has(id)); };
|
|
1138
|
+
const walkSummaryLines = (W, head) => {
|
|
1139
|
+
const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null;
|
|
1140
|
+
const left = W.ids.filter(id => !W.done.has(id) && id !== sending).map(id => W.items[id].title || id);
|
|
1141
|
+
const lines = walkSummary({ answered: W.answered, skipped: W.skipped.map(id => W.items[id].title || id), left });
|
|
1142
|
+
if (head) lines[0] = lines[0].replace(/^Walkthrough done/, head);
|
|
1143
|
+
if (sending) lines.push(` … still sending: ${W.items[sending].title || sending}`);
|
|
1144
|
+
return { lines, open: left.length + W.skipped.length };
|
|
1145
|
+
};
|
|
1146
|
+
const walkEnd = (why) => { // Esc, a command, or a card left too long: the walk stops here and says where it stands
|
|
1147
|
+
const W = R.current.pendingWalk; if (!W) return false;
|
|
1148
|
+
R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); walkCloseCard(W, 'stop');
|
|
1149
|
+
const { lines, open } = walkSummaryLines(W, `Walkthrough stopped (${why})`);
|
|
1150
|
+
say(' ');
|
|
1151
|
+
lines.forEach((l, k) => say(l, k === 0 ? 'ok' : undefined));
|
|
1152
|
+
if (open) say('Nothing else was sent. /walkthrough picks up what is still waiting.', 'dim');
|
|
1153
|
+
queueMicrotask(() => reconcileAsks());
|
|
1154
|
+
return true;
|
|
1155
|
+
};
|
|
1156
|
+
const walkShow = () => {
|
|
1157
|
+
endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
|
|
1158
|
+
const W = R.current.pendingWalk; if (!W) return;
|
|
1159
|
+
walkCloseCard(W); // the card just answered or skipped settles; only the next one is open
|
|
1160
|
+
const gone = walkAnsweredIds();
|
|
1161
|
+
let nextId = null;
|
|
1162
|
+
for (const id of W.ids) {
|
|
1163
|
+
if (W.done.has(id)) continue;
|
|
1164
|
+
if (gone.has(id)) { W.done.add(id); continue; } // answered in this window already: never shown again
|
|
1165
|
+
{ const ex = askExpiry(W.items[id]); if (ex && ex <= Date.now()) { W.done.add(id); (W.dropped ||= new Set()).add(id); refreshNotify(); say(`"${clip(W.items[id].title || id, 60)}": ${expiredText(W.items[id])}`, 'accent'); continue; } }
|
|
1166
|
+
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
|
+
nextId = id; break;
|
|
1168
|
+
}
|
|
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; }
|
|
1170
|
+
const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
|
|
1171
|
+
const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
|
|
1172
|
+
W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
|
|
1173
|
+
say(' ');
|
|
1174
|
+
c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
|
|
1175
|
+
ask('Your choice, with an optional note', { who: it.payload.session || '', action: '', reason: '', keys: '· Esc stops', plain: true });
|
|
1176
|
+
};
|
|
1177
|
+
const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
|
|
1178
|
+
let decs = (list || []).filter(isDecision);
|
|
1179
|
+
if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
|
|
1180
|
+
try { const open = new Set((await api.approvals()).map(x => x.id)); decs = decs.filter(x => open.has(x.id)); } catch { /* slow server: the answered-in-this-window set below still applies */ }
|
|
1181
|
+
}
|
|
1182
|
+
const gone = walkAnsweredIds();
|
|
1183
|
+
decs = decs.filter(x => !gone.has(x.id));
|
|
1184
|
+
const W0 = R.current.pendingWalk;
|
|
1185
|
+
if (W0) { // one walk at a time: a second start joins the walk on screen, never a second card
|
|
1186
|
+
const add = decs.filter(x => !W0.ids.includes(x.id));
|
|
1187
|
+
for (const x of add) { W0.ids.push(x.id); W0.items[x.id] = x; }
|
|
1188
|
+
if (add.length) say(`${add.length} more question${add.length === 1 ? '' : 's'} added to this walkthrough (now ${W0.ids.length}).`, 'dim');
|
|
1189
|
+
return undefined;
|
|
1190
|
+
}
|
|
1191
|
+
if (!decs.length) return say('No question is waiting for you.');
|
|
1192
|
+
R.current.pendingWalk = { ids: decs.map(x => x.id), items: Object.fromEntries(decs.map(x => [x.id, x])), done: new Set(), answered: [], skipped: [], current: null, card: null, at: Date.now(), startedAt: Date.now() };
|
|
1193
|
+
if (!quiet) say(`${decs.length} question${decs.length === 1 ? '' : 's'} waiting for you. One at a time; answer with a choice, then an optional note; Esc stops. Risky ones (high risk, test changes, restarts) always come here, one at a time.`, 'dim'); // /approvals already said it once in its own header
|
|
1194
|
+
walkShow();
|
|
1195
|
+
return undefined;
|
|
1196
|
+
};
|
|
1197
|
+
// 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
|
+
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
|
+
const noteCard = (st) => {
|
|
1200
|
+
say(' ');
|
|
1201
|
+
say(st.batch ? `You are answering ${st.count} questions (${st.label}), and your note says: "${clip(st.note, Math.max(30, cols - 50))}".` : `You chose (${st.key}) ${st.label}, and your note says: "${clip(st.note, Math.max(30, cols - 40))}".`, 'warn');
|
|
1202
|
+
say(`A note can change what you mean${st.batch ? ', and this one would go with every answer' : ''}. Send it as:`);
|
|
1203
|
+
say(` [1] the note wins (recommended) [2] the option${st.batch ? 's' : ''} as written${st.batch ? '' : `, (${st.key}) ${clip(st.label, 40)}`} [3] edit`);
|
|
1204
|
+
say(' y or Enter sends 1 · n sends 2 · e edits');
|
|
1205
|
+
R.current.noteAsk = { ...st, at: Date.now() };
|
|
1206
|
+
bump(n => n + 1);
|
|
1207
|
+
};
|
|
1208
|
+
const noteResolve = async (choice, st) => {
|
|
1209
|
+
if (choice === '3') { edit({ type: 'set', text: st.walk ? `${st.key} ${st.note}` : `/answer ${st.cmd} "${st.note}"` }); return; }
|
|
1210
|
+
const note2 = choice === '1' ? `[note overrides the option as written (${st.label})] ${st.note}` : `[sent as the option as written; the note did not change it] ${st.note}`;
|
|
1211
|
+
R.current.noteBypass = true;
|
|
1212
|
+
try { if (st.walk) await walkHandle(`${st.key} ${note2}`); else await command(`/answer ${st.cmd} "${note2}"`); }
|
|
1213
|
+
finally { R.current.noteBypass = false; }
|
|
1214
|
+
};
|
|
1215
|
+
const walkHandle = async (text) => {
|
|
1216
|
+
const W = R.current.pendingWalk; const it = W?.items[W.current];
|
|
1217
|
+
if (!it) { R.current.pendingWalk = null; return false; }
|
|
1218
|
+
dispatch({ type: 'walk.reply', text, id: it.id }); // the transcript keeps what was typed at each card
|
|
1219
|
+
if (R.current.walkSend) { // the last answer is still on its way: this line must not land on the card just answered
|
|
1220
|
+
edit({ type: 'set', text });
|
|
1221
|
+
say(`Still sending your answer to "${clip(it.title || it.id, 60)}". Your line is kept in the box; press Enter when the next card shows.`, 'warn');
|
|
1222
|
+
return true;
|
|
1223
|
+
}
|
|
1224
|
+
if (Date.now() - W.at >= WALK_TTL) { say('That card sat open for 30 minutes, so nothing was sent. Here it is again:', 'warn'); walkShow(); return true; }
|
|
1225
|
+
const rep = walkReply(it, text);
|
|
1226
|
+
if (!rep) { say(walkNoCatch(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
|
|
1227
|
+
if (rep.notYesNo) { say(walkNotYesNo(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
|
|
1228
|
+
if (rep.defer) { W.done.add(it.id); W.skipped.push(it.id); walkShow(); return true; }
|
|
1229
|
+
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
|
+
const 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
|
|
1231
|
+
if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
|
|
1232
|
+
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
|
+
if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
|
|
1234
|
+
const o = it.payload.options.find(x => x.key === rep.key);
|
|
1235
|
+
noteCard({ walk: true, key: rep.key, label: o?.label || rep.key, note: rep.note, id: it.id });
|
|
1236
|
+
return true;
|
|
1237
|
+
}
|
|
1238
|
+
R.current.lastAnswered = null;
|
|
1239
|
+
R.current.walkSend = true;
|
|
1240
|
+
try { await command(`/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
|
|
1241
|
+
if (R.current.lastAnswered === it.id) {
|
|
1242
|
+
walkAnsweredIds().add(it.id);
|
|
1243
|
+
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)` : ''}` });
|
|
1244
|
+
if (R.current.pendingWalk === W) walkShow(); // stopped while it was sending: the answer stands, no further card
|
|
1245
|
+
} else if (R.current.goneAsk === it.id) { // the server says it is gone: nothing to answer, on to the next card
|
|
1246
|
+
R.current.goneAsk = null; W.done.add(it.id); walkAnsweredIds().add(it.id); refreshNotify();
|
|
1247
|
+
if (R.current.pendingWalk === W) walkShow();
|
|
1248
|
+
} else if (R.current.pendingWalk === W) { say('Not answered; the question stays. Say a choice again, s to skip, or Esc to stop.', 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; }
|
|
1249
|
+
return true;
|
|
1250
|
+
};
|
|
1251
|
+
const answer = async (card, action, { proactive = false, batch = false, reason = null, bulk = false } = {}) => {
|
|
352
1252
|
if (R.current.answering?.has(card.callId)) return;
|
|
353
1253
|
(R.current.answering ||= new Set()).add(card.callId);
|
|
354
1254
|
try {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1255
|
+
if (!card.inboxItemId && card.approvalId && !batch) { // an event card that never learned its inbox id: find it, never answer null
|
|
1256
|
+
try { card = { ...card, inboxItemId: (await api.approvals()).find(i => (i.payload?.approval_id || i.approval_id || i.aee_correlation_id) === card.approvalId)?.id || null }; } catch { /* falls through to the 404 path */ }
|
|
1257
|
+
}
|
|
1258
|
+
await respondWithStepUp(card, action, { proactive, reason, bulk });
|
|
1259
|
+
// 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 });
|
|
1261
|
+
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' && !card.foreign && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
|
|
1263
|
+
return { ok: true };
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
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)
|
|
1266
|
+
if (!batch) dispatch({ type: 'approval.gone', callId: card.callId, inboxItemId: card.inboxItemId || null });
|
|
1267
|
+
return { gone: true };
|
|
1268
|
+
}
|
|
1269
|
+
if (batch) throw e;
|
|
1270
|
+
dispatch({ type: 'error', text: `approval: ${e.message}` });
|
|
1271
|
+
}
|
|
359
1272
|
finally { R.current.answering.delete(card.callId); }
|
|
360
1273
|
};
|
|
361
1274
|
|
|
362
1275
|
// ---- turn runner ---------------------------------------------------------------------------
|
|
363
|
-
|
|
364
|
-
|
|
1276
|
+
// Z2H-R2B: with no AI connected a turn is answered here, whole and wrapped, without a server call.
|
|
1277
|
+
async function aiConnected() {
|
|
1278
|
+
if (!api.connections) return null;
|
|
1279
|
+
try {
|
|
1280
|
+
const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
|
|
1281
|
+
return anyProviderConnected(vault, caps);
|
|
1282
|
+
} catch { return null; }
|
|
1283
|
+
}
|
|
1284
|
+
// Z2H-3B: a successful /connect makes the new provider the active choice when the current one is not a working
|
|
1285
|
+
// connected provider (session + the saved default). Another working provider stays active.
|
|
1286
|
+
async function adoptConnected(id, { host = null, model = null } = {}) {
|
|
1287
|
+
try {
|
|
1288
|
+
if (!api.connections) return;
|
|
1289
|
+
const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
|
|
1290
|
+
const a = adoptAfterConnect({ vault, caps, current: MC.current, id, host, model });
|
|
1291
|
+
if (!a.adopt) return;
|
|
1292
|
+
MC.current = a.choice; api.setModelChoice?.(a.choice);
|
|
1293
|
+
R.current.noAi = false;
|
|
1294
|
+
dispatch({ type: 'ctx', ctx: { provider: null, model: null, endpoint: null, auth: null } });
|
|
1295
|
+
say(a.line, 'ok');
|
|
1296
|
+
bump(n => n + 1);
|
|
1297
|
+
} catch { /* the connect itself succeeded; the hint line already says /model */ }
|
|
1298
|
+
}
|
|
1299
|
+
async function noAiAnswer(text) {
|
|
1300
|
+
if (R.current.noAi === false) return false;
|
|
1301
|
+
const st = await aiConnected();
|
|
1302
|
+
R.current.noAi = st === false ? true : st === true ? false : undefined;
|
|
1303
|
+
if (!R.current.noAi) { bump(n => n + 1); return false; }
|
|
1304
|
+
if (asksAddHost(text)) for (const l of await hostLines()) say(l);
|
|
1305
|
+
else if (asksWatchHosts(text)) {
|
|
1306
|
+
// Z2H-R4: watching starts with a host on the list; that part needs no AI, the answers about it do.
|
|
1307
|
+
for (const l of await hostLines()) say(l);
|
|
1308
|
+
for (const l of NO_AI_CARD) say(l, 'warn');
|
|
1309
|
+
} else for (const l of NO_AI_CARD) say(l, 'warn');
|
|
1310
|
+
bump(n => n + 1);
|
|
1311
|
+
return true;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// QUOXON-FW4: a queued follow-up reaches the model as a bare turn, and a bare "Also end with X" was
|
|
1315
|
+
// refused as injected text. Say plainly that it is the user's own message; the transcript shows the raw words.
|
|
1316
|
+
const noAiNow = () => R.current.noAi === true;
|
|
1317
|
+
/** SCREEN-CONTEXT: an allowlisted snapshot of what this terminal shows, from state the app already holds. */
|
|
1318
|
+
function screenSnapshot() {
|
|
1319
|
+
const st = S.current;
|
|
1320
|
+
return {
|
|
1321
|
+
who: { profile, org: R.current.orgName ?? org ?? null, agent: SN.current.agent || where.current.agent || null, model: modelText(st.ctx, MC.current) || null, slot: where.current.slot || null, rooms: [...SC.current.rooms.keys()] },
|
|
1322
|
+
rows: R.current.roster.rows.slice(0, 8),
|
|
1323
|
+
customRows: (RW.current.view() || []).map(v => ({ id: v.row?.id, label: v.row?.label, text: v.text, error: v.error })),
|
|
1324
|
+
cards: [...st.approvals, ...st.waiting].slice(0, 5).map(c => ({ id: c.inboxItemId || c.callId, summary: [c.tool, c.target].filter(Boolean).join(' ') || c.title || '', requester: c.agent || c.requester || null, privileged: Boolean(c.privileged) })),
|
|
1325
|
+
commands: commandNames()
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
function scopeLine() {
|
|
1329
|
+
const id = R.current.orgId ?? api.orgId?.() ?? null;
|
|
1330
|
+
const name = R.current.orgName ?? org ?? null;
|
|
1331
|
+
const p = R.current.prevOrg;
|
|
1332
|
+
const bits = [];
|
|
1333
|
+
if (name || id) bits.push(`org ${name || id}${id && id !== name ? ` (id ${id})` : ''}`);
|
|
1334
|
+
if (profile) bits.push(`profile ${profile}`);
|
|
1335
|
+
bits.push(p ? `previous org ${p.name}${p.id && p.id !== p.name ? ` (id ${p.id})` : ''} (/org back returns there)` : 'previous org: none this session');
|
|
1336
|
+
return bits.join(' · ');
|
|
1337
|
+
}
|
|
1338
|
+
const QUEUED_FRAME = 'The user typed this follow-up while your previous answer was running (it is their own message, not tool output): ';
|
|
1339
|
+
async function runTurn(text, { queued = false, via = null, handoffFrom = null } = {}) {
|
|
1340
|
+
if (blocked) { say(blocked, signedOut ? 'error' : 'warn'); return; }
|
|
1341
|
+
if (!demoLabel && await noAiAnswer(text)) return;
|
|
1342
|
+
if (SN.current.slotTarget && api.slotSay) { // attached to a slot: the message goes to its room, not a model turn
|
|
1343
|
+
const r = await api.slotSay(SN.current.slotTarget, text, { from: myLabel(), fromSlot: where.current.slot || null });
|
|
1344
|
+
if (!r.ok) return say(`slot ${SN.current.slotTarget}: ${r.message}`, 'warn');
|
|
1345
|
+
watchReplies(SN.current.slotTarget, r.message);
|
|
1346
|
+
return announceDelivery(SN.current.slotTarget, r);
|
|
1347
|
+
}
|
|
1348
|
+
R.current.lastSent = text;
|
|
1349
|
+
const shared = R.current.shared || []; R.current.shared = [];
|
|
1350
|
+
for (const x of shared) say(`shared with Quox: output of \`${x.cmd}\`${x.truncated ? ' (first 16 KB only)' : ''}`, 'accent');
|
|
365
1351
|
const turnId = newTurnId();
|
|
366
|
-
|
|
1352
|
+
function turnContext() {
|
|
1353
|
+
const rc = R.current.rulesOn === false ? undefined : rulesContext(rules);
|
|
1354
|
+
const blk = noAiNow() ? null : SCX.current.build(screenSnapshot());
|
|
1355
|
+
if (blk) {
|
|
1356
|
+
R.current.screenSent = blk;
|
|
1357
|
+
transcript?.action({ type: 'screen.sent', text: `terminal context ${blk.bytes} bytes: ${blk.sections.join(', ') || 'nothing new'}` });
|
|
1358
|
+
}
|
|
1359
|
+
// Owner 2026-10-15 "u should always know what scope it is set to": every turn names the current org (name and
|
|
1360
|
+
// id), the profile and the previous org, even with /context off, so "which org am I in" and "switch back" are
|
|
1361
|
+
// answered from this, never from history.
|
|
1362
|
+
const scope = scopeLine();
|
|
1363
|
+
if (!blk?.text) return scope ? { ...(rc || {}), scope } : rc;
|
|
1364
|
+
return { ...(rc || {}), terminal: blk.text, ...(scope ? { scope } : {}) };
|
|
1365
|
+
}
|
|
1366
|
+
const open = (signal) => api.stream({ turnId, message: withShared(queued ? QUEUED_FRAME + text : text, shared), agent: via || SN.current.agent || 'quox', conversationId: S.current.ctx.conversationId, ...(handoffFrom ? { handoffFrom } : {}), heldSlot: where.current.slot ? { name: where.current.slot, session: slotSession(), display: myDisplay() } : null, context: turnContext(), overrideId: takeOverride(), ...(MD.current === 'plan' ? { mode: 'plan' } : {}), ...streamFields(MC.current) }, signal);
|
|
1367
|
+
// QUOXON-FW3: the server was unreachable before it said anything (a redeploy). Keep the message, wait for it
|
|
1368
|
+
// to come back, and send once. Only a send that produced NO event is ever retried, so nothing runs twice.
|
|
1369
|
+
let r = await consumeTurn(turnId, open, { keepOnDown: true });
|
|
1370
|
+
if (!r?.down) return r;
|
|
1371
|
+
say(SERVER_RESTARTING, 'warn');
|
|
1372
|
+
for (let round = 0; r?.down && round < 3; round++) {
|
|
1373
|
+
const ctrl = new AbortController();
|
|
1374
|
+
R.current.abort = ctrl; R.current.waitingServer = { text };
|
|
1375
|
+
const res = await waitForServer({ health: (ms) => api.health(ms), signal: ctrl.signal, delays: resendDelays });
|
|
1376
|
+
R.current.waitingServer = null; R.current.abort = null;
|
|
1377
|
+
if (res !== 'up') {
|
|
1378
|
+
dispatch({ type: 'turn.deferred' });
|
|
1379
|
+
edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
|
|
1380
|
+
return say(res === 'cancelled' ? 'Cancelled. Your message is back in the composer; it was not sent.' : 'The Quox server did not come back in time. Your message is back in the composer; it was not sent.', 'warn');
|
|
1381
|
+
}
|
|
1382
|
+
r = await consumeTurn(turnId, open, { keepOnDown: true });
|
|
1383
|
+
}
|
|
1384
|
+
if (r?.down) { dispatch({ type: 'turn.deferred' }); edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text }); say('The server keeps dropping the connection. Your message is back in the composer; it was not sent.', 'warn'); }
|
|
1385
|
+
return r;
|
|
367
1386
|
}
|
|
368
1387
|
|
|
369
1388
|
// QUOXON C5: render a turn that is already running on the server (after /resume, --conversation or a
|
|
@@ -379,23 +1398,72 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
379
1398
|
if (r?.running && r.turnId && !S.current.turn && S.current.ctx.conversationId === conversationId) followTurn(conversationId, r.turnId);
|
|
380
1399
|
}
|
|
381
1400
|
|
|
382
|
-
|
|
1401
|
+
// The assistant handed us a slash command (terminal_suggest_command). Vet it here; the y/N answer runs it as if typed.
|
|
1402
|
+
function offerSuggestion(ev) {
|
|
1403
|
+
const seen = (R.current.suggestSeen ||= new Set());
|
|
1404
|
+
if (ev.call_id && seen.has(ev.call_id)) return; // a replayed event must not ask twice
|
|
1405
|
+
if (ev.call_id) seen.add(ev.call_id);
|
|
1406
|
+
const v = vetSuggestion(ev.suggest_command);
|
|
1407
|
+
if (!v.ok) { say(`Not offered: ${clip(String(ev.suggest_command).slice(0, 60), 60)} (${v.reason}).`, 'warn'); return; }
|
|
1408
|
+
if (v.readOnly && ev.suggest_explicit === true) { say(`ran ${v.line}`, 'dim'); command(v.line); return; }
|
|
1409
|
+
R.current.suggestAsk = { line: v.line, at: Date.now() };
|
|
1410
|
+
// Asked AFTER the answer, last thing on screen (owner 2026-10-15: mid-stream it scrolled away above the reply).
|
|
1411
|
+
R.current.suggestLine = askText(`Run ${clip(v.line, cols - 20)}?`, { who: askerName(), action: v.line, reason: ev.suggest_reason ? String(ev.suggest_reason).replace(/[.\s]+$/, '') : '' });
|
|
1412
|
+
}
|
|
1413
|
+
// QUOXON-ASK safety net: the answer named ONE runnable command but the model did not offer it: offer it here, same ask.
|
|
1414
|
+
function offerNamed(text) {
|
|
1415
|
+
const v = namedCommand(text);
|
|
1416
|
+
if (!v) return;
|
|
1417
|
+
R.current.suggestAsk = { line: v.line, at: Date.now() };
|
|
1418
|
+
R.current.suggestLine = askText(`Run ${clip(v.line, cols - 20)}?`, { who: askerName(), action: v.line, reason: 'Quox named this command in its answer' });
|
|
1419
|
+
}
|
|
1420
|
+
function flushSuggestion() {
|
|
1421
|
+
const t = R.current.suggestLine; if (!t) return;
|
|
1422
|
+
R.current.suggestLine = null;
|
|
1423
|
+
if (R.current.suggestAsk) R.current.suggestAsk.at = Date.now(); // the answer window starts when it is shown
|
|
1424
|
+
say(t, 'ask');
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// QUOXON handoff offer: the SAME question to the suggested agent for this one turn; the window stays attached.
|
|
1428
|
+
async function runHandoff(a) {
|
|
1429
|
+
say(`↪ Asking ${a.h.name} this one: ${clip(a.text, Math.max(20, cols - 30))}`, 'dim');
|
|
1430
|
+
R.current.handoffTurn = true; // the handed-over turn answers without a suggestion; that must not break the streak
|
|
1431
|
+
try { await runTurn(a.text, { via: a.h.agent, handoffFrom: a.h.from }); } finally { R.current.handoffTurn = false; }
|
|
1432
|
+
say(handoffAnsweredNote(a.h), 'dim');
|
|
1433
|
+
bump(n => n + 1);
|
|
1434
|
+
}
|
|
1435
|
+
async function consumeTurn(turnId, open, { keepOnDown = false } = {}) {
|
|
383
1436
|
R.current.caps.steer = true; R.current.steerNoted = false; // steer support is learned per turn (provider paths differ)
|
|
384
1437
|
R.current.durable = false; // learned from the server: v1.1 events carry a seq
|
|
385
1438
|
dispatch({ type: 'turn.begin', turnId });
|
|
386
1439
|
const ctrl = new AbortController();
|
|
387
1440
|
R.current.abort = ctrl;
|
|
388
|
-
let offered = false;
|
|
1441
|
+
let offered = false, sawEvent = false, down = false;
|
|
1442
|
+
let answerText = '', suggested = false, gate = null; // the safety net reads the answer when no suggestion came
|
|
389
1443
|
try {
|
|
390
1444
|
for await (const ev of open(ctrl.signal)) {
|
|
391
1445
|
if (ctrl.signal.aborted) break;
|
|
1446
|
+
sawEvent = true;
|
|
392
1447
|
if (typeof ev?.seq === 'number') R.current.durable = true;
|
|
393
1448
|
LG.current.observe(ev);
|
|
394
1449
|
dispatch({ type: 'event', ev });
|
|
1450
|
+
if (ev.type === 'tool.finished' && ev.suggest_command) { suggested = true; offerSuggestion(ev); }
|
|
1451
|
+
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
|
+
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
|
+
// Z2H-3B: a server default that is not connected must not linger in the status bar.
|
|
1454
|
+
if (ev.type === 'error' && /No AI is connected/i.test(String(ev.error || ev.message || ''))) {
|
|
1455
|
+
R.current.noAi = true;
|
|
1456
|
+
dispatch({ type: 'ctx', ctx: { provider: null, model: null, endpoint: null, auth: null } });
|
|
1457
|
+
}
|
|
395
1458
|
const fb = fallbackNote(MC.current, ev);
|
|
396
1459
|
if (fb) say(fb, 'warn');
|
|
397
1460
|
const offer = connectOfferFor(ev);
|
|
398
1461
|
if (offer && !offered) { offered = true; say(offer); }
|
|
1462
|
+
if (isConsentError(ev)) { // the words are in the error block; here the link and the way back
|
|
1463
|
+
R.current.consent = ev.retryable ? null : { provider: ev.provider || null, text: R.current.lastSent || null };
|
|
1464
|
+
let base = null; try { base = await api.dashboardBase?.(); } catch { base = null; }
|
|
1465
|
+
for (const l of consentHintLines(ev, base)) say(l, 'dim');
|
|
1466
|
+
}
|
|
399
1467
|
}
|
|
400
1468
|
if (S.current.turn?.id === turnId) {
|
|
401
1469
|
// stream closed without a terminal event
|
|
@@ -403,36 +1471,113 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
403
1471
|
dispatch({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
|
|
404
1472
|
}
|
|
405
1473
|
} catch (e) {
|
|
406
|
-
if (!ctrl.signal.aborted) {
|
|
407
|
-
|
|
1474
|
+
if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
|
|
1475
|
+
else if (!ctrl.signal.aborted) {
|
|
1476
|
+
// FW4: settle in-flight tools explicitly (never leave a row "running"), then explain the cut with a way forward.
|
|
1477
|
+
if (e?.status === 423 && typeof e?.body?.block_id === 'string') { // QUOXON-STEPUP: a policy block; a person may override it once
|
|
1478
|
+
R.current.lastBlock = { blockId: e.body.block_id, agentId: e.body.safety?.agent || null, text: String(e.body.message || e.message || '').slice(0, 200) };
|
|
1479
|
+
say(`Blocked by policy (${e.body.block_id}). A person can override this one action: /override last <reason>`, 'warn');
|
|
1480
|
+
}
|
|
1481
|
+
// TUI-DIVIDER: a refusal before the stream opened names the layers it really evaluated; the rule lights them.
|
|
1482
|
+
if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers) dispatch({ type: 'event', ev: { type: 'aocl.layer', ...l } });
|
|
1483
|
+
const why = sawEvent ? explainTermination(e) : null;
|
|
1484
|
+
if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running') dispatch({ type: 'event', ev: { type: 'tool.finished', call_id: b.callId, tool: b.tool, status: 'interrupted', result_summary: 'connection lost before a result; verify before retrying' } });
|
|
1485
|
+
const dead = !why && (e?.status === 401 || isSessionDead(e));
|
|
1486
|
+
dispatch({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
|
|
1487
|
+
if (dead) markSignedOut(e?.reason || null);
|
|
1488
|
+
if (why) say(RETRY_HINT, 'warn');
|
|
408
1489
|
// A lost reattach is not a failed turn: the server may still be finishing it (C5).
|
|
409
1490
|
if (S.current.turn?.id === turnId) dispatch({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
|
|
410
1491
|
}
|
|
411
1492
|
} finally {
|
|
412
1493
|
clearTimeout(R.current.fallbackTimer);
|
|
413
1494
|
LG.current.end();
|
|
414
|
-
R.current.abort = null;
|
|
415
|
-
if (S.current.turn?.id === turnId) dispatch({ type: 'stopped.local' });
|
|
1495
|
+
if (!down) R.current.abort = null;
|
|
1496
|
+
if (!down && S.current.turn?.id === turnId) dispatch({ type: 'stopped.local' });
|
|
1497
|
+
const planDone = MD.current === 'plan' && !down && !ctrl.signal.aborted && Boolean(answerText);
|
|
1498
|
+
if (!suggested && !down && !ctrl.signal.aborted && answerText && MD.current !== 'plan') offerNamed(answerText); // plan mode: the plan card below is the one next step
|
|
1499
|
+
// QUOXON handoff offer: the attached agent lacks the tools for this ask. The handoff card answers the actual question,
|
|
1500
|
+
// so it wins over the agent's own suggested command (owner live 22:42: "Run /vm list?" took the slot and the handoff
|
|
1501
|
+
// card was dropped silently). Nothing is dropped silently: a superseded or skipped card says so in one line.
|
|
1502
|
+
const hp = R.current.handoffPending; R.current.handoffPending = null;
|
|
1503
|
+
const hpOk = Boolean(hp && !down && !ctrl.signal.aborted && hp.text);
|
|
1504
|
+
if (hpOk && !planDone && R.current.suggestLine) {
|
|
1505
|
+
const line = R.current.suggestAsk?.line || '';
|
|
1506
|
+
R.current.suggestLine = null; R.current.suggestAsk = null;
|
|
1507
|
+
say(handoffSupersededNote(hp.h, line), 'dim');
|
|
1508
|
+
}
|
|
1509
|
+
const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone);
|
|
1510
|
+
flushSuggestion();
|
|
1511
|
+
if (hpOk && planDone) say(handoffSkippedNote(hp.h), 'dim');
|
|
1512
|
+
else if (hpOk) {
|
|
1513
|
+
const hs = (R.current.handoffStreak ??= createHandoffStreak()).offered(hp.h.from);
|
|
1514
|
+
if (hs.escalate) { R.current.handoffAsk = { ...hp, at: Date.now(), bigger: true, count: hs.count }; say(handoffBiggerAskText(hp.h, hs.count), 'ask'); } // same agent, 2+ turns in a row: offer to move the window
|
|
1515
|
+
else { R.current.handoffAsk = { ...hp, at: Date.now() }; say(handoffAskText(hp.h, hp.text, cols), 'ask'); }
|
|
1516
|
+
}
|
|
1517
|
+
if (!hpOk && !down && !ctrl.signal.aborted && !R.current.handoffTurn) R.current.handoffStreak?.clear(); // a turn with no suggestion breaks the streak
|
|
1518
|
+
if (planDone) { R.current.planAsk = { at: Date.now() }; ask('Run this plan now?', { who: askerName(), action: `switch to /mode ask and send "${PLAN_GO}"` }); }
|
|
1519
|
+
if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
|
|
1520
|
+
const ra = R.current.reask; R.current.reask = null; // y/N/c: the chat about an ask is over: the same ask again, fresh window
|
|
1521
|
+
if (ra && !newOffer && askOpen()) { touchAsks(); if (ra.redo) ra.redo(); else say(ra.text, 'ask'); }
|
|
416
1522
|
}
|
|
1523
|
+
return down ? { down: true } : undefined;
|
|
417
1524
|
}
|
|
418
1525
|
|
|
419
1526
|
React.useEffect(() => {
|
|
1527
|
+
if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length) wakeDragon(); // an approval always wakes it
|
|
420
1528
|
const s = S.current;
|
|
421
1529
|
if (!s.turn && s.autoDrain && s.pending.length) {
|
|
422
1530
|
const next = s.pending[0];
|
|
423
1531
|
dispatch({ type: 'queue.shift' });
|
|
424
|
-
|
|
425
|
-
runTurn(next.text);
|
|
1532
|
+
runQueued(next.text);
|
|
426
1533
|
}
|
|
427
1534
|
});
|
|
428
1535
|
|
|
1536
|
+
// 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
|
+
function runQueued(text, { checked = false } = {}) {
|
|
1538
|
+
if (text.startsWith('/') && !text.includes('\n')) { command(text); return; }
|
|
1539
|
+
const stray = checked ? null : strayCardAnswer(text, commandNames()); // TUI-INPUT: "n/help" = a card answer that leaked into the composer
|
|
1540
|
+
if (stray) { R.current.strayAsk = { text, command: stray.command, at: Date.now() }; ask(strayQuestion(stray)); return; }
|
|
1541
|
+
dispatch({ type: 'user', text, queued: true });
|
|
1542
|
+
runTurn(text, { queued: true });
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function echoCmd(text) { // a command the human typed (or confirmed): shown like any prompt line, before its output
|
|
1546
|
+
const e = echoLine(text, { cols });
|
|
1547
|
+
dispatch({ type: 'user', text: e.shown, full: e.full, echo: true });
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// PRESENCE (owner 2026-10-16): /afk and /sleep tell the server, so sessions and LLMs know you are not at this
|
|
1551
|
+
// terminal (approvals still queue; nothing is approved). /back or any typed turn marks you present.
|
|
1552
|
+
async function setPresence(state, note) {
|
|
1553
|
+
if (!api.setPresence) return { ok: true, local: true }; // demo / offline: the local mark is all there is
|
|
1554
|
+
try { return await api.setPresence(state, { note: note || undefined, slots: where.current.slot ? [where.current.slot] : undefined }); }
|
|
1555
|
+
catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
|
|
1556
|
+
}
|
|
1557
|
+
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
|
+
function markPresent(action = 'chat') {
|
|
1559
|
+
if (/^\/(afk|sleep|back|away)(\s|$)/.test(String(action).replace(/^slash /, ''))) return;
|
|
1560
|
+
if (AWAY.current.afk) { // this window set the flag: a typed turn here is the owner back (explicit, as before)
|
|
1561
|
+
AWAY.current.afk = null;
|
|
1562
|
+
setPresence('present').then((r) => { if (!r.ok) presenceFail(r); }, () => {});
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
// PRESENCE (owner 2026-10-19): a window that never set the flag (restarted TUI) still clears the server's stale one,
|
|
1566
|
+
// but only from a real terminal (api.noteActivity is a no-op for --print, pipes and proof runs). Welcome shows once.
|
|
1567
|
+
if (!api.noteActivity) return;
|
|
1568
|
+
api.noteActivity(action).then((r) => {
|
|
1569
|
+
if (r?.welcome?.away_text && !AWAY.current.welcomed) { AWAY.current.welcomed = true; say(`✔ Welcome back, you were away ${r.welcome.away_text}`, 'ok'); }
|
|
1570
|
+
}, () => {});
|
|
1571
|
+
}
|
|
429
1572
|
function submit(text) {
|
|
1573
|
+
touchSeen();
|
|
1574
|
+
markPresent(/^\//.test(text.trim()) ? `slash ${text.trim().split(/\s+/)[0]}` : 'chat');
|
|
430
1575
|
dispatch({ type: 'user', text });
|
|
431
1576
|
const tip = intentHint(text); // a hint only: the turn still goes to the model
|
|
432
1577
|
if (tip) say(tip);
|
|
433
1578
|
const panel = intentPanel(text); // the same panel as the command, from local facts, above the answer
|
|
434
1579
|
if (panel) showPanel(panel, { local: true });
|
|
435
|
-
|
|
1580
|
+
remember(text);
|
|
436
1581
|
R.current.histIdx = -1;
|
|
437
1582
|
runTurn(text);
|
|
438
1583
|
}
|
|
@@ -460,6 +1605,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
460
1605
|
}
|
|
461
1606
|
|
|
462
1607
|
async function interrupt() {
|
|
1608
|
+
if (R.current.waitingServer) { R.current.abort?.abort(); return; } // QUOXON-FW3: Esc cancels the wait; runTurn restores the text
|
|
463
1609
|
const s = S.current;
|
|
464
1610
|
if (!s.turn || s.turn.status === 'interrupting') return;
|
|
465
1611
|
dispatch({ type: 'interrupt.requested' });
|
|
@@ -482,13 +1628,42 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
482
1628
|
say(`A turn is running. ${kind === 'restart' ? 'Restarting' : 'Leaving'} disconnects it, and the server stops it before its next step (a tool already running finishes and is recorded). /${kind === 'restart' ? 'restart' : 'quit'} again within 5 s to go ahead, or wait for the turn.`, 'warn');
|
|
483
1629
|
return false;
|
|
484
1630
|
};
|
|
1631
|
+
async function agentsPicker(filter) {
|
|
1632
|
+
if (!api.agents) return say('Agents are not available in this mode.', 'warn');
|
|
1633
|
+
let list;
|
|
1634
|
+
try { list = await api.agents(); } catch (e) { return say(`Couldn't check agents: ${listFailure(e, profile)}`, 'warn'); }
|
|
1635
|
+
const rows = filterAgents(list, filter);
|
|
1636
|
+
if (!rows.length) return say(filter ? `No agent matches "${filter}". /agents list shows them all.` : 'No agents found.', 'warn');
|
|
1637
|
+
PK.current.open({ title: filter ? `Agents matching "${filter}"` : 'Agents', rows: rows.map(agentPickerRow), hint: '↑↓ select · Enter show card · Esc cancel', onPick: (_, i) => command(`/agents ${rows[i].agent_id}`) });
|
|
1638
|
+
return undefined;
|
|
1639
|
+
}
|
|
1640
|
+
async function agentTools(name) {
|
|
1641
|
+
if (!api.objects?.agent) return say('Object views are not available in this mode.', 'warn');
|
|
1642
|
+
let r;
|
|
1643
|
+
try { r = await api.objects.agent(name); } catch (e) { return say(`agent: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}`, 'warn'); }
|
|
1644
|
+
if (r?.error) return say(clip(r.error, cols - 4), r.tone || 'warn');
|
|
1645
|
+
for (const [t, tone] of toolLines(r.data)) say(clip(t, cols - 4), tone === 'plain' ? undefined : tone);
|
|
1646
|
+
return undefined;
|
|
1647
|
+
}
|
|
1648
|
+
function showWhich(arg) {
|
|
1649
|
+
const sl = where.current.slot || null;
|
|
1650
|
+
const r = whichReply(arg, {
|
|
1651
|
+
profile, agentLogin, org: R.current.orgName ?? org ?? null, agent: SN.current.agent || where.current.agent,
|
|
1652
|
+
hat: S.current.ctx.hat, model: modelText(S.current.ctx, MC.current),
|
|
1653
|
+
slot: SN.current.slotTarget ? `${SN.current.slotTarget} (chatting)` : sl, slotLive: Boolean(sl && SC.current.listener?.state?.().connected),
|
|
1654
|
+
conversationId: S.current.ctx.conversationId, sessionName: SN.current.name
|
|
1655
|
+
});
|
|
1656
|
+
if (r.error) return say(r.error, 'warn');
|
|
1657
|
+
if (r.rows.length === 1) return say(`${r.rows[0][0]}: ${r.rows[0][1]}`);
|
|
1658
|
+
return help([{ kind: 'head', text: 'Which' }, ...r.rows.map(([key, text]) => ({ kind: 'row', key, text }))]);
|
|
1659
|
+
}
|
|
485
1660
|
async function showPanel(cmd, { local = false } = {}) {
|
|
486
1661
|
const base = { ctx: S.current.ctx, choice: MC.current, agent: SN.current.agent || where.current.agent, conversationId: S.current.ctx.conversationId };
|
|
487
1662
|
if (cmd === '/cost') return help(costModel(LG.current));
|
|
488
1663
|
if (cmd === '/status') {
|
|
489
1664
|
let hl = R.current.lastHealth || null;
|
|
490
1665
|
if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; }
|
|
491
|
-
return help(statusModel({ ...base, profile, org:
|
|
1666
|
+
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 }));
|
|
492
1667
|
}
|
|
493
1668
|
if (cmd === '/context') {
|
|
494
1669
|
let skills = R.current.skillsInfo || null; let memory = R.current.memoryInfo || null;
|
|
@@ -504,32 +1679,58 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
504
1679
|
try { server = contextFacts(await api.conversationContext(base.conversationId)); }
|
|
505
1680
|
catch (e) { serverError = e?.status === 404 ? 'no stored turns yet' : e?.status ? `HTTP ${e.status}` : (e?.message || 'failed'); }
|
|
506
1681
|
}
|
|
507
|
-
return help(contextModel({ ...base, server, serverError, tokens: LG.current.conversationTokens(S.current.ctx.conversationId), rules, rulesOn: R.current.rulesOn !== false, skills, memory }));
|
|
1682
|
+
return help(contextModel({ ...base, server, serverError, tokens: LG.current.conversationTokens(S.current.ctx.conversationId), rules, rulesOn: R.current.rulesOn !== false, skills, memory, screen: { on: SCX.current.on, last: R.current.screenSent || null } }));
|
|
508
1683
|
}
|
|
509
1684
|
return undefined;
|
|
510
1685
|
}
|
|
511
|
-
|
|
1686
|
+
/** gh #1157: a resume that meets a restarting collector says one calm line and retries with backoff (about 2 min); past the cap it rethrows marked gaveUp. */
|
|
1687
|
+
async function reconnecting(fn, st = {}) {
|
|
1688
|
+
for (let i = 0; ; i++) {
|
|
1689
|
+
try { return await fn(); } catch (e) {
|
|
1690
|
+
if (!isResumeRetryable(e) || R.current.closed) throw e;
|
|
1691
|
+
if (i >= resumeRetryDelays.length) { if (e && typeof e === 'object') e.reconnectGaveUp = true; throw e; }
|
|
1692
|
+
if (!st.noted) { st.noted = true; say(RESUME_RECONNECTING, 'dim'); }
|
|
1693
|
+
await new Promise(r => setTimeout(r, resumeRetryDelays[i]));
|
|
1694
|
+
if (R.current.closed) throw e;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
function resumeGaveUp(id) { return say(`Quox server is still not answering after about 2 minutes. ${id ? `/resume ${id}` : '/resume'} to try again.`, 'warn'); }
|
|
1699
|
+
async function resumeTo(row, st = {}) {
|
|
512
1700
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
513
1701
|
let turns;
|
|
514
|
-
try { turns = await api.conversationTurns(row.id); } catch (e) { return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
|
|
1702
|
+
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'}` }); }
|
|
515
1703
|
const t = turnsToHistory(turns);
|
|
516
1704
|
dispatch({ type: 'ctx', ctx: { conversationId: row.id } });
|
|
517
1705
|
SN.current.name = loadNames(namesPath)[row.id]?.name || null; SN.current.saved = null;
|
|
1706
|
+
if (!t.total && SN.current.name) SN.current.saved = `${row.id}|${SN.current.name}`; // nothing found: do not re-stamp the saved name
|
|
518
1707
|
say(`Resumed "${clip(row.title, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
|
|
519
1708
|
dispatch({ type: 'history.add', items: t.items, answers: t.answers });
|
|
1709
|
+
if (!t.total && agentLogin && row.byName) {
|
|
1710
|
+
const h = humanProfiles.find(x => x !== profile);
|
|
1711
|
+
return say(`${row.byName} was saved by another profile. Start with quox -p ${h || '<human profile>'} to resume it.`, 'warn');
|
|
1712
|
+
}
|
|
520
1713
|
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');
|
|
521
1714
|
return reattachIfRunning(row.id);
|
|
522
1715
|
}
|
|
523
1716
|
async function resumeCmd(q) {
|
|
524
1717
|
if (!api.conversations || !api.conversationTurns) return say('Resume is not available in this mode.', 'warn');
|
|
525
|
-
let rows;
|
|
1718
|
+
let rows; const st = {};
|
|
526
1719
|
try {
|
|
527
|
-
const [list, index] = await Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]);
|
|
1720
|
+
const [list, index] = await reconnecting(() => Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]), st);
|
|
528
1721
|
rows = resumeRows(list, index, loadNames(namesPath));
|
|
529
|
-
} catch (e) { return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
|
|
1722
|
+
} catch (e) { if (R.current.closed) return undefined; if (e?.reconnectGaveUp) return resumeGaveUp(q ? String(q).trim() : null); return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
|
|
530
1723
|
if (q) {
|
|
531
1724
|
const m = matchConversation(rows, q);
|
|
532
|
-
if (m.row) return resumeTo(m.row);
|
|
1725
|
+
if (m.row) return resumeTo(m.row, st);
|
|
1726
|
+
if (!m.ambiguous) {
|
|
1727
|
+
// Beyond the last 30 listed: a full id, or an exact saved local name, resumes directly.
|
|
1728
|
+
const names = loadNames(namesPath); const want = String(q).trim();
|
|
1729
|
+
if (/^QXC_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(want)) return resumeTo({ id: want, title: names[want]?.name || want, agent: null }, st);
|
|
1730
|
+
const hit = pickNamed(names, want, profile);
|
|
1731
|
+
if (hit?.ambiguous) return say(`"${q}" matches ${hit.ambiguous.length} conversations (saved by different profiles): ${hit.ambiguous.map(x => x.slice(0, 12)).join(', ')}. Use more of the id.`, 'warn');
|
|
1732
|
+
if (hit) return resumeTo({ id: hit.id, title: hit.entry.name, agent: null, byName: want }, st);
|
|
1733
|
+
}
|
|
533
1734
|
return say(m.ambiguous ? `"${q}" matches ${m.ambiguous.length} conversations: ${m.ambiguous.map(x => x.slice(0, 12)).join(', ')}. Use more of the id.` : `No recent conversation matches "${q}". /resume lists them.`, 'warn');
|
|
534
1735
|
}
|
|
535
1736
|
if (!rows.length) return say('No recent conversations to resume.');
|
|
@@ -537,38 +1738,267 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
537
1738
|
PK.current.open({ title: 'Resume a conversation', rows: rows.map(r => pickerRow(r, now)), hint: '↑↓ select · Enter resume · Esc cancel', onPick: (_, i) => resumeTo(rows[i]) });
|
|
538
1739
|
return undefined;
|
|
539
1740
|
}
|
|
1741
|
+
// ---- /dragon explain | ask | sleep (dragonModes.js) -----------------------------------------------
|
|
1742
|
+
const dragonTalkVisible = (now = Date.now()) => {
|
|
1743
|
+
const d = R.current.dragonTalk;
|
|
1744
|
+
return Boolean(d) && (d.armed || !d.endedAt || now - d.endedAt < 4000);
|
|
1745
|
+
};
|
|
1746
|
+
/** One governed turn on whichever provider is selected, framed for the simplest possible answer. */
|
|
1747
|
+
function dragonTurn(label, prompt) {
|
|
1748
|
+
const d = R.current.dragonTalk = { since: Date.now(), endedAt: null, armed: false, label };
|
|
1749
|
+
say(label, 'ok');
|
|
1750
|
+
Promise.resolve(runTurn(prompt)).catch(() => {}).finally(() => { d.endedAt = Date.now(); bump(n => n + 1); });
|
|
1751
|
+
bump(n => n + 1);
|
|
1752
|
+
return true;
|
|
1753
|
+
}
|
|
1754
|
+
function dragonExplain() {
|
|
1755
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
1756
|
+
const o = lastOutput(S.current);
|
|
1757
|
+
if (!o) return say(NOTHING_TO_EXPLAIN);
|
|
1758
|
+
return dragonTurn(DRAGON_EXPLAINS, explainPrompt(o.text));
|
|
1759
|
+
}
|
|
1760
|
+
function dragonAsk(question) {
|
|
1761
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
1762
|
+
if (!question) { R.current.askArmed = true; R.current.dragonTalk = { since: Date.now(), endedAt: Date.now(), armed: true, label: ASK_PROMPT }; bump(n => n + 1); return true; }
|
|
1763
|
+
dispatch({ type: 'user', text: question });
|
|
1764
|
+
return dragonTurn(DRAGON_ANSWERS, askPrompt(question));
|
|
1765
|
+
}
|
|
1766
|
+
function dragonSleep() {
|
|
1767
|
+
if (!isInteractive) return say('/dragon sleep needs an interactive terminal.', 'warn');
|
|
1768
|
+
if (S.current.approvals.length) return say('An approval is waiting. Answer it first; the dragon will not sleep over it.', 'warn');
|
|
1769
|
+
R.current.sleep = { startedAt: Date.now(), wakeAt: null, still: !motionOn || mono() };
|
|
1770
|
+
bump(n => n + 1); return true;
|
|
1771
|
+
}
|
|
1772
|
+
function dragonFly() { // one pass of the dragon in flight in the live region; Esc stops it, a new call restarts it
|
|
1773
|
+
R.current.fly = { startedAt: Date.now(), still: !motionOn || mono() };
|
|
1774
|
+
bump(n => n + 1); return true;
|
|
1775
|
+
}
|
|
1776
|
+
const flyActive = (now = Date.now()) => { const f = R.current.fly; return Boolean(f) && now - f.startedAt < (f.still ? FLY_STILL_MS : FLY_PASS_MS + 400); };
|
|
1777
|
+
function wakeDragon() {
|
|
1778
|
+
const sl = R.current.sleep;
|
|
1779
|
+
if (!sl || sl.wakeAt != null) return;
|
|
1780
|
+
sl.wakeAt = Date.now();
|
|
1781
|
+
const end = () => { R.current.sleep = null; bump(n => n + 1); };
|
|
1782
|
+
if (sl.still) end(); else R.current.wakeTimer = setTimeout(end, WAKE_MS + 60);
|
|
1783
|
+
bump(n => n + 1);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
540
1786
|
function openConfig() {
|
|
541
1787
|
const list = settingsList();
|
|
542
1788
|
const saved = api.settings ? api.settings() : (R.current.sessionSettings ||= {});
|
|
543
1789
|
const vals = { verbose: S.current.verbose, ...saved, sound: SND.current.settings?.().profile };
|
|
544
|
-
|
|
1790
|
+
// R3 D2: changes are staged in the card; Enter saves them, Esc (or typing) discards them.
|
|
1791
|
+
let staged = {};
|
|
1792
|
+
const apply = (it, v) => {
|
|
545
1793
|
vals[it.id] = v;
|
|
546
1794
|
if (it.id === 'sound') { SND.current.command(v, say); return; } // the sound controller saves its own profile
|
|
547
1795
|
const ok = api.saveSetting ? api.saveSetting(it.id, v) : ((R.current.sessionSettings ||= {})[it.id] = v, false);
|
|
548
1796
|
if (it.id === 'verbose') dispatch({ type: 'verbose', mode: v });
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
1797
|
+
// Theme and motion apply to what is drawn from now on; history already in scrollback keeps its look
|
|
1798
|
+
// (repainting it would print the whole transcript again).
|
|
1799
|
+
if (it.id === 'theme' || it.id === 'motion') applySettings(vals, env);
|
|
553
1800
|
R.current.configSaved = ok;
|
|
554
1801
|
};
|
|
1802
|
+
const commit = () => {
|
|
1803
|
+
const ids = Object.keys(staged);
|
|
1804
|
+
if (!ids.length) return;
|
|
1805
|
+
for (const id of ids) apply(list.find(x => x.id === id), staged[id]);
|
|
1806
|
+
const what = ids.map(id => `${id} ${staged[id]}`).join(', ');
|
|
1807
|
+
staged = {};
|
|
1808
|
+
say(`${api.saveSetting ? `Saved to profile ${profile}` : 'Applied for this session'}: ${what}.${ids.includes('theme') ? ' The theme applies to new output; earlier lines keep their colours.' : ''}`, 'ok');
|
|
1809
|
+
};
|
|
555
1810
|
PK.current.open({
|
|
556
|
-
title: api.saveSetting ? `Settings ·
|
|
557
|
-
rowsFn: () => configRows(list, vals, { model: modelLabel(S.current.ctx, MC.current) || 'auto' }),
|
|
558
|
-
hint: '↑↓ select · ←→ or Space change · Enter
|
|
559
|
-
onAdjust: (i, dir) => {
|
|
560
|
-
|
|
561
|
-
|
|
1811
|
+
title: api.saveSetting ? `Settings · Enter saves to profile ${profile} · Esc cancels` : 'Settings · Enter applies for this session · Esc cancels',
|
|
1812
|
+
rowsFn: () => configRows(list, { ...vals, ...staged }, { model: modelLabel(S.current.ctx, MC.current) || 'auto' }).map((r, i) => (list[i].id in staged ? { ...r, note: 'unsaved · Enter saves' } : r)),
|
|
1813
|
+
hint: '↑↓ select · ←→ or Space change · Enter save · Esc cancel · typing goes to the composer',
|
|
1814
|
+
onAdjust: (i, dir) => {
|
|
1815
|
+
const it = list[i]; if (it.action) return;
|
|
1816
|
+
const now = it.id === 'motion' ? (normaliseLevel(vals.motion) || it.def) : (vals[it.id] ?? it.def); // F3: a pre-levels full/reduced profile
|
|
1817
|
+
const v = cycle(it, staged[it.id] ?? now, dir);
|
|
1818
|
+
if (v === now) delete staged[it.id]; else staged[it.id] = v;
|
|
1819
|
+
},
|
|
1820
|
+
onClose: () => { if (Object.keys(staged).length) say('Settings closed; nothing saved.', 'dim'); staged = {}; },
|
|
1821
|
+
onPick: (_, i) => { commit(); if (list[i].action === 'model') command('/model'); }
|
|
562
1822
|
});
|
|
563
1823
|
}
|
|
564
1824
|
|
|
1825
|
+
// ---- QUOXON-FW8: ! commands ---------------------------------------------------------------------
|
|
1826
|
+
function bangCommand(bang, { echoed = false } = {}) {
|
|
1827
|
+
if (bang.kind === 'reserved') return say('!! is reserved. Use ! followed by a command, e.g. !ls or !quox inbox list.', 'warn');
|
|
1828
|
+
if (bang.kind === 'empty') return say('Usage: !<command> runs it here, !quox <args> runs this quox as you. Output stays on your screen; /share sends it to Quox.', 'warn');
|
|
1829
|
+
let plan = { cmd: bang.cmd };
|
|
1830
|
+
if (bang.kind === 'quox') {
|
|
1831
|
+
plan = quoxArgv(bang.args, { profile });
|
|
1832
|
+
if (plan.refuse && !plan.tty) return say(plan.refuse, 'warn');
|
|
1833
|
+
}
|
|
1834
|
+
const cmd = bang.cmd;
|
|
1835
|
+
const timeoutMs = Number(env.QUOX_BANG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
|
|
1836
|
+
if (!echoed) say(`$ ${cmd}`, 'accent');
|
|
1837
|
+
// BANG-TTY (owner 2026-10-16): a known interactive command or an interactive quox leaf gets the terminal
|
|
1838
|
+
if (plan.tty || needsTerminal(bang)) return bangHandOver(cmd, plan.tty || plan);
|
|
1839
|
+
let pending = ''; let timer = null;
|
|
1840
|
+
const flush = (all) => {
|
|
1841
|
+
if (R.current.exiting) { pending = ''; return; }
|
|
1842
|
+
let out = pending;
|
|
1843
|
+
if (!all) { const i = out.lastIndexOf('\n'); if (i < 0) return; out = out.slice(0, i + 1); }
|
|
1844
|
+
pending = pending.slice(out.length);
|
|
1845
|
+
out = cleanOutput(out).replace(/\n+$/, '');
|
|
1846
|
+
if (out) dispatch({ type: 'bang.out', text: out });
|
|
1847
|
+
};
|
|
1848
|
+
const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
|
|
1849
|
+
const slow = setTimeout(() => { if (R.current.bang?.run === run && !R.current.exiting) say('still running (Ctrl+C stops it)', 'accent'); }, 5000);
|
|
1850
|
+
R.current.bang = { run, cmd };
|
|
1851
|
+
run.done.then((r) => {
|
|
1852
|
+
clearTimeout(timer); clearTimeout(slow); timer = null; flush(true);
|
|
1853
|
+
R.current.bang = null;
|
|
1854
|
+
const results = R.current.bangResults ??= [];
|
|
1855
|
+
results.push({ cmd, text: r.text, truncated: r.truncated });
|
|
1856
|
+
if (results.length > 10) results.shift();
|
|
1857
|
+
transcript?.bang?.(cmd, r.timedOut ? 'timeout' : r.killed ? 'killed' : r.code);
|
|
1858
|
+
if (R.current.exiting) return;
|
|
1859
|
+
if (r.truncated) say(bangTruncNote(r), 'warn');
|
|
1860
|
+
if (ttyFailure(r)) { say(bangFooter(r, { timeoutMs }), 'warn'); return bangHandOver(cmd, plan); } // it wanted a terminal: retry handed over
|
|
1861
|
+
if (r.stdinNote) say(r.stdinNote, 'warn');
|
|
1862
|
+
say(bangFooter(r, { timeoutMs }), r.code === 0 && !r.timedOut && !r.killed ? 'ok' : 'warn');
|
|
1863
|
+
bump(n => n + 1);
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/** BANG-TTY: run a ! command with the terminal handed over (the run-request path: raw mode and mouse off, back after). */
|
|
1868
|
+
function bangHandOver(cmd, plan) {
|
|
1869
|
+
say('This needs a real terminal: handing it over. Quit it to come back.', 'dim');
|
|
1870
|
+
const hold = { run: { kill: () => {}, done: null }, cmd, handover: true };
|
|
1871
|
+
R.current.bang = hold; // nothing else starts in the gap before the hand-over
|
|
1872
|
+
setTimeout(() => {
|
|
1873
|
+
if (R.current.bang === hold) R.current.bang = null;
|
|
1874
|
+
if (R.current.closed || R.current.exiting) return;
|
|
1875
|
+
const target = plan.argv ? { file: process.execPath, args: plan.argv } : { cmd: plan.cmd ?? cmd };
|
|
1876
|
+
const r = handOverRun({ ...target, env, cwd: process.cwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
|
|
1877
|
+
transcript?.bang?.(cmd, r.error ? 'error' : (r.signal || r.code));
|
|
1878
|
+
say(runFooter(r), !r.error && !r.signal && r.code === 0 ? 'ok' : 'warn');
|
|
1879
|
+
bump(n => n + 1);
|
|
1880
|
+
}, runHandOverDelayMs);
|
|
1881
|
+
return undefined;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function cliSlashRun(hit) {
|
|
1885
|
+
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
|
+
const bang = { kind: 'quox', args: hit.args, cmd: hit.display };
|
|
1887
|
+
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.' }); }
|
|
1888
|
+
if (R.current.bang) return say('A command is still running. Ctrl+C stops it.', 'warn');
|
|
1889
|
+
say(`ran ${hit.display}`, 'dim');
|
|
1890
|
+
return bangCommand(bang);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function shareCommand(arg) {
|
|
1894
|
+
const res = R.current.bangResults || [];
|
|
1895
|
+
if (!res.length) return say('Nothing to share yet. Run a command with ! first, e.g. !quox inbox list, then /share.', 'warn');
|
|
1896
|
+
const n = arg ? Number(arg) : 1;
|
|
1897
|
+
if (!Number.isInteger(n) || n < 1 || n > res.length) return say(`Usage: /share [N] where N is 1 (latest) to ${res.length}.`, 'warn');
|
|
1898
|
+
const r = res[res.length - n];
|
|
1899
|
+
const sh = shareText(r.text);
|
|
1900
|
+
R.current.shared = [...(R.current.shared || []).filter(x => x.cmd !== r.cmd), { cmd: r.cmd, text: sh.text, truncated: sh.truncated }];
|
|
1901
|
+
say(`Output of \`${r.cmd}\` will be shared with Quox with your next message${sh.truncated ? ' (first 16 KB only, it was longer)' : ''}.`, 'accent');
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/** /update (owner 2026-10-16): the shared updater (core/selfUpdate.js) checks, the y answer hands the terminal to it. */
|
|
1905
|
+
async function updateCommand() {
|
|
1906
|
+
if (!onRestart) return say('Update is not available in this mode.', 'warn');
|
|
1907
|
+
if (S.current.turn) return say('A turn is running. Wait for it, then /update.', 'warn');
|
|
1908
|
+
if (R.current.updateChecking) return say('Already checking for updates…', 'dim');
|
|
1909
|
+
R.current.updateChecking = true;
|
|
1910
|
+
say('Checking for updates…', 'dim');
|
|
1911
|
+
let plan;
|
|
1912
|
+
try {
|
|
1913
|
+
const u = selfUpdate;
|
|
1914
|
+
plan = updater ? await updater.check() : await u.checkUpdate(u.detectInstall({}));
|
|
1915
|
+
const lines = u.updateLines(plan);
|
|
1916
|
+
const tone = !plan.ok || plan.refused ? 'warn' : plan.upToDate ? 'ok' : undefined;
|
|
1917
|
+
for (const l of lines) say(l.startsWith(' · ') ? clip(l, cols - 4) : l, tone); // a refusal or failure is said in full
|
|
1918
|
+
if (!plan.ok || plan.upToDate || plan.refused) return undefined;
|
|
1919
|
+
R.current.updateAsk = { plan, at: Date.now() };
|
|
1920
|
+
return say(u.updateAsk(plan), 'ask');
|
|
1921
|
+
} catch (e) {
|
|
1922
|
+
return say(`Could not check for updates: ${e?.message || e}`, 'warn');
|
|
1923
|
+
} finally { R.current.updateChecking = false; }
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// PROFILE-SWITCH: an agent-login window that needs a person offers the human login (y switches, never automatic);
|
|
1927
|
+
// with no human login on this machine it offers `quox login -p <name>` as a run card instead.
|
|
1928
|
+
function offerSwitch() {
|
|
1929
|
+
const { profile: me, agentLogin: isAgent } = WHO.current;
|
|
1930
|
+
if (!isAgent) return undefined;
|
|
1931
|
+
const human = humanProfiles.find(x => x !== me && okProfileName(x));
|
|
1932
|
+
if (human) { R.current.profileAsk = { name: human, at: Date.now() }; return say(switchAskLine(me, human), 'ask'); }
|
|
1933
|
+
const cand = loginCandidates.find(x => x !== me && okProfileName(x)) || 'me';
|
|
1934
|
+
return offerLogin(cand, `This window is an agent login (${me}) and no human login is signed in on this machine.`);
|
|
1935
|
+
}
|
|
1936
|
+
function offerLogin(name, lead) {
|
|
1937
|
+
return armRun(loginRunRequest(name, `${lead} Sign ${name} in, then /profile ${name} switches this window to it.`, runNow()), { who: 'Quox', glyph: 'Ⓠ', text: '' });
|
|
1938
|
+
}
|
|
1939
|
+
/** Switch this window to `name`: in place when main can build its api (keeps everything), else /restart into -p name. */
|
|
1940
|
+
async function switchTo(name) {
|
|
1941
|
+
const me = WHO.current.profile;
|
|
1942
|
+
if (!okProfileName(name)) return say(`"${clip(String(name), 40)}" is not a profile name.`, 'warn');
|
|
1943
|
+
if (name === me) return say(`This window is already ${me}.`, 'dim');
|
|
1944
|
+
if (S.current.turn) return say('A turn is running. Wait for it (or press Esc), then switch.', 'warn');
|
|
1945
|
+
if (switchProfile && !where.current.slot) { // a held slot belongs to the old identity: the restart hand-over carries it properly
|
|
1946
|
+
say(`Switching this window to ${name}…`, 'dim');
|
|
1947
|
+
let r;
|
|
1948
|
+
try { r = await switchProfile(name, { org: api.windowOrgId?.() || null }); } catch (e) { r = { ok: false, reason: e?.message || 'failed' }; }
|
|
1949
|
+
if (r?.ok && r.api) {
|
|
1950
|
+
APX.current.live = r.api;
|
|
1951
|
+
const next = { profile: name, agentLogin: Boolean(r.agentLogin) };
|
|
1952
|
+
WHO.current = next; setWho(next);
|
|
1953
|
+
if (r.orgName !== undefined) R.current.orgName = r.orgName;
|
|
1954
|
+
R.current.notify = { fails: 0, authLost: false };
|
|
1955
|
+
dispatch({ type: 'notify', patch: { error: null } });
|
|
1956
|
+
refreshNotify(); refreshRoster();
|
|
1957
|
+
return say(`✔ Switched this window to ${name}. Same conversation${SN.current.agent ? `, still attached to ${SN.current.agent}` : ''}.${r.note ? ` ${r.note}` : ''}`, 'ok');
|
|
1958
|
+
}
|
|
1959
|
+
if (r?.reason === 'not_signed_in') return offerLogin(name, `${name} is not signed in on this machine.`);
|
|
1960
|
+
if (!onRestart) return say(`Could not switch to ${name} (${r?.reason || 'failed'}). Still ${me}.`, 'warn');
|
|
1961
|
+
say(`Could not switch in place (${r?.reason || 'failed'}). Restarting into ${name} instead.`, 'warn');
|
|
1962
|
+
} else if (!switchProfile && !humanProfiles.includes(name)) return offerLogin(name, `${name} is not a signed-in profile on this machine.`);
|
|
1963
|
+
if (!onRestart) return say(`Switching profile is not available in this mode. Quit and run: quox -p ${name}`, 'warn');
|
|
1964
|
+
return doRestart({ profile: name });
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
async function doRestart(extra = {}) {
|
|
1968
|
+
if (R.current.restarting) return say('Already restarting…', 'dim');
|
|
1969
|
+
R.current.restarting = true; stopCountdown();
|
|
1970
|
+
let target;
|
|
1971
|
+
if (restartCheck) { // never let go of the terminal for a quox that cannot start: say so and stay
|
|
1972
|
+
say('Checking the installed quox…', 'dim');
|
|
1973
|
+
const c = await restartCheck();
|
|
1974
|
+
if (!c.ok) { R.current.restarting = false; return say(`Could not restart: ${c.bin} did not start (${c.error}). Still in this client.`, 'warn'); }
|
|
1975
|
+
target = c.onPath || undefined;
|
|
1976
|
+
}
|
|
1977
|
+
say(`Restarting${S.current.ctx.conversationId ? ' into this conversation' : ''}…`, 'dim');
|
|
1978
|
+
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' } : {}), bin: target, ...extra });
|
|
1979
|
+
return exit(30);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
565
1982
|
async function command(line) {
|
|
566
1983
|
if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/note');
|
|
567
|
-
const [
|
|
568
|
-
|
|
1984
|
+
const [cmd0, arg] = line.trim().split(/\s+/);
|
|
1985
|
+
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 !== '/note' && cmd !== '/context') SCX.current.command(line.trim());
|
|
1988
|
+
if (cmd !== '/note') transcript?.command(/^\/(authenticate|authorise|authorize)$/.test(cmd) && !/^[\d,\s-]*$/.test(line.trim().replace(/^\S+\s*/, '')) ? cmd : line); // QUOXON-STEPUP: never record a stray argument that might be a PIN
|
|
569
1989
|
const restArg = line.trim().replace(/^\S+\s*/, '');
|
|
1990
|
+
touchSeen();
|
|
1991
|
+
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
|
|
1992
|
+
let objArg = restArg;
|
|
1993
|
+
if (cmd === '/agents') { // list picker, tools list, or the card (agents.js: list/tools/show are verbs, never names)
|
|
1994
|
+
const p = parseAgentsArgs(restArg);
|
|
1995
|
+
if (p.mode === 'usage') return say(p.text, 'warn');
|
|
1996
|
+
if (p.mode === 'list') return agentsPicker(p.filter);
|
|
1997
|
+
if (p.mode === 'tools') return agentTools(p.name);
|
|
1998
|
+
objArg = p.name;
|
|
1999
|
+
}
|
|
570
2000
|
if (OBJECT_CMDS.has(cmd) && !(cmd === '/rules' && arg)) { // inline object views (objects/): a card into history, or an honest note
|
|
571
|
-
const r = await runObjectCommand(cmd,
|
|
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 });
|
|
572
2002
|
if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : say(clip(r.note, cols - 4), r.tone);
|
|
573
2003
|
}
|
|
574
2004
|
switch (cmd) {
|
|
@@ -578,7 +2008,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
578
2008
|
if (stdout?.isTTY) stdout.write(osc52(r.text));
|
|
579
2009
|
return say(`copied ${r.text.length.toLocaleString('en-US')} chars to the clipboard (needs a terminal that allows OSC 52)`, 'dim');
|
|
580
2010
|
}
|
|
2011
|
+
case '/share': return shareCommand(arg);
|
|
581
2012
|
case '/note': {
|
|
2013
|
+
if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
|
|
582
2014
|
if (!restArg) return say('Usage: /note <text> (or // text). Nothing to note.', 'warn');
|
|
583
2015
|
if (!transcript?.note) return say('notes need dev transcripts on (touch ~/.config/quox/dev-transcripts)', 'warn');
|
|
584
2016
|
transcript.note(restArg.slice(0, 2000));
|
|
@@ -588,63 +2020,229 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
588
2020
|
if (!restArg) return say(SN.current.name ? `Session name: ${SN.current.name}` : 'This session has no name. Use /rename <name>.');
|
|
589
2021
|
const n = cleanName(restArg);
|
|
590
2022
|
SN.current.name = n; SN.current.saved = null; bump(x => x + 1);
|
|
2023
|
+
if (where.current.slot && api.checkoutSlot) { // the slot's holder label follows the name, or this window would be locked out of its own slot
|
|
2024
|
+
const r = await api.checkoutSlot(where.current.slot, n, { take: true });
|
|
2025
|
+
if (!r.ok) return say(`Session named "${n}", but slot ${where.current.slot} still carries the old label: ${r.message}`, 'warn');
|
|
2026
|
+
}
|
|
591
2027
|
return say(`Session named "${n}".`, 'ok');
|
|
592
2028
|
}
|
|
2029
|
+
case '/challenge': return CH.current.run(restArg);
|
|
2030
|
+
case '/spawn': return SPN.current.run(restArg);
|
|
2031
|
+
case '/ask': // owner typed /ask hyperion <question> (2026-10-16): same as /say
|
|
2032
|
+
case '/say': { // QUOXON-MESH: straight to a slot, no model turn
|
|
2033
|
+
const m = /^(\S+)\s+([\s\S]*\S[\s\S]*)$/.exec(restArg || '');
|
|
2034
|
+
if (!m) return say('Usage: /say <slot>[,<slot>...] <text>', 'warn');
|
|
2035
|
+
const to = [...new Set(m[1].split(',').map(x => x.trim().toLowerCase()).filter(Boolean))];
|
|
2036
|
+
if (to.length < 2) return sendToSlot(to[0] || m[1].toLowerCase(), m[2].trim());
|
|
2037
|
+
// QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
|
|
2038
|
+
const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
|
|
2039
|
+
const head = `(to ${slots.join(', ')}) `;
|
|
2040
|
+
for (const sl of slots) await sendToSlot(sl, head + m[2].trim());
|
|
2041
|
+
return undefined;
|
|
2042
|
+
}
|
|
2043
|
+
case '/join': { const nm = roomName(restArg); if (!nm) return say('Usage: /join <slot> (or #<slot>)', 'warn'); return joinRoom(nm); }
|
|
2044
|
+
case '/leave': { const nm = roomName(restArg); if (!nm) return say('Usage: /leave <slot>', 'warn'); return leaveRoom(nm); }
|
|
2045
|
+
case '/rooms': case '/groups': return showRooms();
|
|
2046
|
+
case '/broadcast': case '/page':
|
|
2047
|
+
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
|
+
case '/ack': return runAck({ api, arg: restArg, slot: where.current.slot || null, say });
|
|
2049
|
+
case '/send': { // QUOXON-MESH: a local text file into a slot (inline, 64 KB cap, secrets scrubbed on a yes)
|
|
2050
|
+
const m = /^(\S+)\s+(.+)$/.exec(restArg || '');
|
|
2051
|
+
if (!m) return say('Usage: /send <slot> <file>', 'warn');
|
|
2052
|
+
if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
|
|
2053
|
+
return sendFile(m[1].toLowerCase(), m[2]);
|
|
2054
|
+
}
|
|
593
2055
|
case '/slot': {
|
|
594
2056
|
if (!restArg) return say(where.current.slot ? `Slot: ${where.current.slot}` : 'No slot. Use /slot <name> to give this session an address.');
|
|
2057
|
+
if (/^log(\s+\d+)?$/.test(restArg.trim())) return slotLog(restArg.trim().split(/\s+/)[1]);
|
|
2058
|
+
if (/^(?:list|ls|help|all)$/i.test(restArg.trim())) return listSlots(/^all$/i.test(restArg.trim())); // owner 2026-10-15: "/slot help" must not check out a slot called help
|
|
595
2059
|
if (!api.checkoutSlot) return say('Slots are not available in this mode.', 'warn');
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
2060
|
+
const toks = restArg.split(/\s+/);
|
|
2061
|
+
const forceCreate = toks.includes('--create');
|
|
2062
|
+
const take = toks.includes('--take');
|
|
2063
|
+
let nm = toks.find(t => !t.startsWith('--')) || '';
|
|
2064
|
+
if (!nm) return say('Usage: /slot <name> [--create] [--take]', 'warn');
|
|
2065
|
+
if (!forceCreate) nm = await canonSlot(nm);
|
|
2066
|
+
if (!forceCreate && api.slots) { // a name that is not a real slot but is close to one is a typo, not a request to create
|
|
2067
|
+
let slots = null;
|
|
2068
|
+
try { slots = await api.slots(); } catch { slots = null; }
|
|
2069
|
+
if (Array.isArray(slots) && !slots.some(x => String(x.name) === nm)) {
|
|
2070
|
+
const near = await nearbyTargets(nm, slots);
|
|
2071
|
+
if (near.length) { // owner 2026-10-16: offer, don't instruct. The near misses stay as the line above the card.
|
|
2072
|
+
say(`Slot "${nm}" not found. Did you mean: ${near.join(', ')}?`, 'warn');
|
|
2073
|
+
R.current.slotCreateAsk = { nm, at: Date.now() };
|
|
2074
|
+
return say(slotCreateAskLine(nm), 'ask');
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
return takeSlot(nm, take);
|
|
601
2079
|
}
|
|
602
2080
|
case '/attach': {
|
|
603
2081
|
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.');
|
|
604
2082
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
605
2083
|
if (!api.agents) return say('Agents are not available in this mode.', 'warn');
|
|
2084
|
+
const pt = parseTarget(restArg);
|
|
606
2085
|
let list;
|
|
607
|
-
try { list = await api.agents(); } catch (e) { return say(`
|
|
608
|
-
|
|
609
|
-
if (
|
|
2086
|
+
try { list = await api.agents(); } catch (e) { return say(`Couldn't check agents: ${listFailure(e, profile)}`, 'warn'); }
|
|
2087
|
+
let slots = null;
|
|
2088
|
+
if (pt.kind !== 'agent' && api.slots) {
|
|
2089
|
+
try { slots = await api.slots(); } catch (e) { if (pt.kind === 'slot') return say(`Couldn't check slots: ${listFailure(e, profile)}`, 'warn'); slots = null; }
|
|
2090
|
+
}
|
|
2091
|
+
const slotList = Array.isArray(slots) ? slots : [];
|
|
2092
|
+
const slotKey = pt.kind === 'agent' ? null : uniqueSquash(pt.name, slotList.map(x => x.name));
|
|
2093
|
+
const slotRow = slotKey ? slotList.find(x => String(x.name) === slotKey) : null;
|
|
2094
|
+
const m = pt.kind === 'slot' ? { matches: [] } : matchAgent(pt.name, list);
|
|
2095
|
+
const slotLive = !!(slotRow && slotRow.holder_session);
|
|
2096
|
+
let choice = null; // 'slot' | 'agent'
|
|
2097
|
+
if (slotRow && m.agent) choice = pt.kind === 'slot' || slotLive ? 'slot' : 'agent';
|
|
2098
|
+
else if (slotRow) choice = 'slot';
|
|
2099
|
+
else if (m.agent) choice = 'agent';
|
|
2100
|
+
if (!choice) {
|
|
2101
|
+
// A miss is only "no such agent" when the lists answered; a failed list is the failure, not a not-found.
|
|
2102
|
+
if (list?.listError) return say(`Couldn't check agents: ${listFailure(list.listError, profile)}`, 'warn');
|
|
2103
|
+
if (api.slots && slots === null && pt.kind !== 'agent') { try { slots = await api.slots(); } catch (e) { return say(`Couldn't check slots: ${listFailure(e, profile)}`, 'warn'); } }
|
|
2104
|
+
const near = await nearbyTargets(pt.name, slots, list);
|
|
2105
|
+
return say(`No ${pt.kind || 'agent or slot'} called "${pt.name}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ' Try quox agent list.'}`, 'warn');
|
|
2106
|
+
}
|
|
2107
|
+
const both = !!(slotRow && m.agent);
|
|
2108
|
+
if (choice === 'slot') {
|
|
2109
|
+
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;
|
|
2111
|
+
where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
|
|
2112
|
+
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
|
+
const lbl = `slot ${slotRow.name}${host ? ` (${host})` : ''}`;
|
|
2114
|
+
const alt = both ? ` To pick the agent instead: /attach agent:${pt.name}` : '';
|
|
2115
|
+
if (!pt.rest) return say(`Attached to ${lbl}. Your next messages go to it. /detach returns to CommanderQ.${alt}`, 'ok');
|
|
2116
|
+
const r = await api.slotSay(slotRow.name, pt.rest, { from: myLabel(), fromSlot: where.current.slot || null });
|
|
2117
|
+
if (!r.ok) return say(`Attached to ${lbl}, but the message was not sent: ${r.message}`, 'warn');
|
|
2118
|
+
return say(`Attached to ${lbl}. Sent: ${pt.rest}${alt}`, 'ok');
|
|
2119
|
+
}
|
|
2120
|
+
SN.current.slotTarget = null;
|
|
610
2121
|
SN.current.agent = m.agent.id; where.current = { ...where.current, agent: m.agent.id }; bump(x => x + 1);
|
|
611
|
-
|
|
2122
|
+
const alt = both ? ` To pick the slot instead: /attach slot:${pt.name}` : '';
|
|
2123
|
+
say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.${alt}`, 'ok');
|
|
2124
|
+
if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
|
|
2125
|
+
return undefined;
|
|
2126
|
+
}
|
|
2127
|
+
case '/peek': { // PEEK: the same lookup as `quox peek <id>`, in this window's org
|
|
2128
|
+
const pid = restArg.trim().split(/\s+/)[0] || '';
|
|
2129
|
+
if (!api.peek) return say('Peek is not available in this mode. In a terminal: quox peek <id>', 'warn');
|
|
2130
|
+
if (!pid) return say('Usage: /peek <id>. An envelope, inbox item, objective, workflow run, WARD receipt or agent id.', 'warn');
|
|
2131
|
+
let pr;
|
|
2132
|
+
try { pr = await api.peek(pid); } catch (e) { return say(`Peek failed: ${listFailure(e, profile)}`, 'warn'); }
|
|
2133
|
+
if (pr?.hit) return dispatch({ type: 'object', obj: { type: 'peek', data: pr } }); // a boxed card: the label column survives
|
|
2134
|
+
for (const l of peekIdLines(pr)) say(clip(l.text, cols - 4), l.tone === 'ok' ? undefined : l.tone || undefined);
|
|
2135
|
+
return undefined;
|
|
2136
|
+
}
|
|
2137
|
+
case '/aside': { // ASIDE: no tools, no conversation id; the answer is a separate read-only block
|
|
2138
|
+
const q = restArg.trim();
|
|
2139
|
+
if (!api.aside) return say('Asides are not available in this mode. In a terminal: quox aside "<question>"', 'warn');
|
|
2140
|
+
if (!q) return say('Usage: /aside <question>. A read-only side question: no tools, not added to this conversation.', 'warn');
|
|
2141
|
+
say('Asking on the side (read only, no tools)…', 'dim');
|
|
2142
|
+
let ar;
|
|
2143
|
+
try { ar = await api.aside(q, turnsFromItems([...S.current.history, ...S.current.blocks])); } catch (e) { return say(`Aside failed: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}. Nothing was changed.`, 'warn'); }
|
|
2144
|
+
dispatch({ type: 'note', text: asideNoteText(q, ar.text, ar), tone: 'aside' });
|
|
2145
|
+
return undefined;
|
|
2146
|
+
}
|
|
2147
|
+
case '/hosts': {
|
|
2148
|
+
if (/^add\b/i.test(restArg || '')) { for (const l of await hostLines()) say(l); return undefined; }
|
|
2149
|
+
say('To see the hosts your Quox knows: quox fleet hosts (in a terminal).');
|
|
2150
|
+
return say('To add one, no AI needed: /hosts add');
|
|
2151
|
+
}
|
|
2152
|
+
case '/login': { // /login <name> for a profile that is already signed in: switch this window to it (PROFILE-SWITCH)
|
|
2153
|
+
const ln = restArg.trim().split(/\s+/)[0] || '';
|
|
2154
|
+
if (ln && ln !== profile && humanProfiles.includes(ln)) return switchTo(ln);
|
|
2155
|
+
LI.current.open(); return undefined;
|
|
2156
|
+
}
|
|
2157
|
+
case '/profile': {
|
|
2158
|
+
const pn = restArg.trim().split(/\s+/)[0] || '';
|
|
2159
|
+
if (pn && pn !== 'list') return switchTo(pn);
|
|
2160
|
+
const others = humanProfiles.filter(x => x !== profile);
|
|
2161
|
+
say(`This window is ${profile} (${agentLogin ? 'agent login' : 'human login'}).${others.length ? ` Switch with ${others.map(o => `/profile ${o}`).join(' or ')}.` : ' No other signed-in profile on this machine: /profile <name> offers to sign one in.'}`);
|
|
2162
|
+
// PROFILE-CHOOSER: one list of profiles and this login's orgs; a pick changes THIS window only.
|
|
2163
|
+
let orgList = [], orgNote = null;
|
|
2164
|
+
if (api.orgs) { try { orgList = await Promise.race([api.orgs(), new Promise((_, rej) => setTimeout(() => rej(new Error('timed out')), 5000))]); } catch (e) { orgNote = `Orgs not listed: ${listFailure(e, profile)}`; } }
|
|
2165
|
+
const prow = profileChooserRows({ current: WHO.current.profile, humanProfiles, loginCandidates, orgs: orgList, orgId: R.current.orgId ?? api.orgId?.() ?? null });
|
|
2166
|
+
if (orgNote) say(orgNote, 'warn');
|
|
2167
|
+
if (prow.length < 2) return undefined;
|
|
2168
|
+
PK.current.open({ title: 'Switch profile or org (this window only)', rows: prow, sel: 0, hint: '↑↓ select · Enter switch (an org asks first) · Esc cancel', onPick: (row) => {
|
|
2169
|
+
const p = row?.pick || {};
|
|
2170
|
+
if (p.profile) return p.profile === WHO.current.profile ? say(`This window is already ${p.profile}.`, 'dim') : switchTo(p.profile);
|
|
2171
|
+
if (p.login) return switchTo(p.login);
|
|
2172
|
+
if (p.org) return p.org.id === (R.current.orgId ?? api.orgId?.() ?? null) ? say(`Already in ${p.org.name}.`, 'dim') : command(`/org switch ${p.org.slug || p.org.id}`);
|
|
2173
|
+
return undefined;
|
|
2174
|
+
} });
|
|
2175
|
+
return undefined;
|
|
612
2176
|
}
|
|
613
2177
|
case '/connect': {
|
|
2178
|
+
if (BL.current) return say(BL.current, 'warn');
|
|
614
2179
|
if (!restArg) {
|
|
615
2180
|
if (!api.connections) return say('Connections are not available in this mode.', 'warn');
|
|
616
|
-
|
|
2181
|
+
const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
|
|
2182
|
+
for (const [t, tone] of connectionLines(vault, caps)) say(clip(t, cols - 4), tone);
|
|
617
2183
|
return undefined;
|
|
618
2184
|
}
|
|
619
2185
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2186
|
+
if (/^(yes|no)$/i.test(restArg.trim())) { // answers the private-network card
|
|
2187
|
+
const offer = R.current.lanOffer; R.current.lanOffer = null;
|
|
2188
|
+
if (!offer) return say('Nothing to confirm. Run /connect again.', 'warn');
|
|
2189
|
+
if (/^no$/i.test(restArg.trim())) return say('Left blocked. Nothing was changed.');
|
|
2190
|
+
const c = await confirmLanAllow(api, offer);
|
|
2191
|
+
for (const [t, tone] of c.lines) say(t, tone);
|
|
2192
|
+
// Z2H-R4: a LAN provider always arrives here; adopt it like the direct path does.
|
|
2193
|
+
if (c.ok && offer.providerId) await adoptConnected(offer.providerId, { host: c.host, model: c.model });
|
|
2194
|
+
return undefined;
|
|
2195
|
+
}
|
|
620
2196
|
const pv = providerFor(arg);
|
|
621
2197
|
if (pv?.url && pv.id !== 'ollama') { // B5: LM Studio / vLLM / custom: probed server side, then saved (custom may need a key)
|
|
622
2198
|
const rest = restArg.replace(/^\S+\s*/, '');
|
|
623
2199
|
say(`Checking the URL from the server…`);
|
|
624
2200
|
const r = await connectCompatFlow(api, pv.id, rest);
|
|
625
2201
|
for (const [t, tone] of r.lines) say(clip(t, cols - 4), tone);
|
|
2202
|
+
R.current.lanOffer = r.lanOffer ? { ...r.lanOffer, providerId: pv.id } : null;
|
|
626
2203
|
if (r.needsKey) CN.current.openCustom(r.extra);
|
|
2204
|
+
else if (r.ok) { await adoptConnected(pv.id, { host: r.host, model: r.model }); await CN.current.askConsent(pv.type); }
|
|
627
2205
|
return undefined;
|
|
628
2206
|
}
|
|
629
2207
|
if (pv?.url) { // B3: Ollama takes a URL, probed and saved server side
|
|
630
|
-
|
|
2208
|
+
{ const u = parseOllamaArg(restArg.replace(/^\S+\s*/, '')).url; if (u) say(`Checking ${u} from the server…`); }
|
|
631
2209
|
const r = await connectOllamaFlow(api, restArg.replace(/^\S+\s*/, ''));
|
|
632
|
-
for (const [t, tone] of r.lines) say(
|
|
2210
|
+
for (const [t, tone] of r.lines) say(t, tone) /* Z2H-R3: notes wrap; clipping cut the egress message at "localho…" */;
|
|
2211
|
+
R.current.lanOffer = r.lanOffer ? { ...r.lanOffer, providerId: 'ollama' } : null;
|
|
2212
|
+
if (r.privateAsk) CN.current.askPrivate(r.privateAsk, async (c) => { if (c.ok) await adoptConnected('ollama', { host: c.host, model: c.model }); }); // #1288: y/N for this one address
|
|
2213
|
+
if (r.ok) await adoptConnected('ollama', { host: r.host, model: r.model });
|
|
633
2214
|
return undefined;
|
|
634
2215
|
}
|
|
635
2216
|
CN.current.open(arg);
|
|
636
2217
|
return undefined;
|
|
637
2218
|
}
|
|
638
2219
|
case '/model': {
|
|
2220
|
+
const sub = parseModelSub(restArg);
|
|
2221
|
+
const handled = MM.current.handle(sub);
|
|
2222
|
+
if (handled) return handled;
|
|
2223
|
+
if (sub.kind === 'pass') MP.current.touched = true;
|
|
639
2224
|
if (!api.connections) return say('Model choice is not available in this mode.', 'warn');
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
2225
|
+
// One provider status for /model, /connect, /doctor, /capabilities: the server's capabilities, vault for detail.
|
|
2226
|
+
const [res, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
|
|
2227
|
+
if (sub.kind === 'list' || !restArg) {
|
|
2228
|
+
for (const [t, tone] of modelLines(res, MC.current, caps)) say(clip(t, cols - 4), tone);
|
|
2229
|
+
return undefined;
|
|
2230
|
+
}
|
|
2231
|
+
let r = selectModel(res, restArg, caps);
|
|
643
2232
|
let installed = null;
|
|
644
2233
|
if (r.ok && r.choice?.provider === 'ollama' && api.ollamaModels) { // B3: check against what that Ollama has installed
|
|
645
2234
|
r = selectOllamaModel(await api.ollamaModels(), r.choice.model || null);
|
|
646
2235
|
if (r.models) installed = ollamaModelRows(r.models, r.current || r.choice?.model);
|
|
647
2236
|
}
|
|
2237
|
+
if (r.ok && r.choice?.via === 'subscription') { // QUOXON-MODEL: Claude CLI family aliases, no list to fetch
|
|
2238
|
+
if (r.choice.model) r = selectSubscriptionModel(r.choice);
|
|
2239
|
+
} else if (r.ok && (r.choice?.provider === 'openai' || r.choice?.provider === 'anthropic') && api.providerModels) {
|
|
2240
|
+
// QUOXON-MODEL: live model list (server cached 1 h, refreshed now); no hardcoded lists to go stale
|
|
2241
|
+
const row = CHOICES.find(x => x.provider === r.choice.provider && !x.via);
|
|
2242
|
+
const d = selectDiscoveredModel(await api.providerModels(r.choice.provider, { refresh: true }), r.choice, row);
|
|
2243
|
+
r = d.ok && r.tone === 'warn' && !d.models ? { ...d, text: `${d.text} ${r.text}`, tone: 'warn' } : d;
|
|
2244
|
+
if (r.models) installed = compatModelRows(r.models, r.current || r.choice?.model);
|
|
2245
|
+
}
|
|
648
2246
|
if (r.ok && byId(r.choice?.provider)?.compat && api.compatModels) { // B5: check against the provider's own /models
|
|
649
2247
|
r = selectCompatModel(await api.compatModels(r.choice.provider), r.choice.provider, r.choice.model || null);
|
|
650
2248
|
if (r.models) installed = compatModelRows(r.models, r.current || r.choice?.model);
|
|
@@ -657,6 +2255,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
657
2255
|
if (installed) for (const [t, tone] of installed) say(clip(t, cols - 4), tone);
|
|
658
2256
|
return undefined;
|
|
659
2257
|
}
|
|
2258
|
+
case '/row': return RW.current.handle(restArg);
|
|
2259
|
+
case '/watch': return KC.current.watch(restArg);
|
|
2260
|
+
case '/automate': return KC.current.automate(restArg);
|
|
2261
|
+
case '/crystallize': return KC.current.crystallize(restArg);
|
|
2262
|
+
case '/undo': return UW.current.undo(restArg);
|
|
2263
|
+
case '/wish': return UW.current.wish(restArg);
|
|
660
2264
|
case '/capabilities': {
|
|
661
2265
|
if (!api.capabilities) return say('Capabilities are not available in this mode.', 'warn');
|
|
662
2266
|
const res = await api.capabilities();
|
|
@@ -670,49 +2274,69 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
670
2274
|
for (const [t, tone] of capabilityLines(res)) say(clip(t, cols - 4), tone);
|
|
671
2275
|
return undefined;
|
|
672
2276
|
}
|
|
673
|
-
case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': return undefined; // answered above (runObjectCommand)
|
|
674
|
-
case '/
|
|
2277
|
+
case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': case '/fleet': case '/prove': case '/replay': return undefined; // answered above (runObjectCommand)
|
|
2278
|
+
case '/agent': case '/agents': return undefined; // answered above (picker, tools, card)
|
|
2279
|
+
case '/which': return showWhich(arg);
|
|
675
2280
|
case '/detach':
|
|
676
|
-
SN.current.agent = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
|
|
2281
|
+
SN.current.agent = null; SN.current.slotTarget = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
|
|
677
2282
|
return say('Back to CommanderQ.');
|
|
678
2283
|
case '/sessions': {
|
|
679
|
-
|
|
680
|
-
|
|
2284
|
+
// SESSIONS (owner 2026-10-15): name and where first; ids only with `refs`.
|
|
2285
|
+
const flags = new Set(String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean));
|
|
2286
|
+
if (api.sessionsIndex) {
|
|
681
2287
|
try {
|
|
682
|
-
|
|
683
|
-
|
|
2288
|
+
const lines = humanSessionLines(await api.sessionsIndex(), { localHost: osHostname(), names: loadNames(namesPath), all: flags.has('all'), refs: flags.has('refs') || flags.has('ids') });
|
|
2289
|
+
for (const l of lines) say(clip(l.text, cols - 4), l.tone);
|
|
2290
|
+
} catch (e) { dispatch({ type: 'error', text: `sessions: ${e.status ? `HTTP ${e.status}: ` : ''}${e.message}` }); }
|
|
684
2291
|
return undefined;
|
|
685
2292
|
}
|
|
686
2293
|
if (!api.conversations) return say('Sessions are not available in this mode.', 'warn');
|
|
687
2294
|
try {
|
|
688
2295
|
const rows = await api.conversations(); const names = loadNames(namesPath);
|
|
689
2296
|
if (!rows.length) return say('No recent conversations.');
|
|
690
|
-
for (const r of rows) say(`${
|
|
2297
|
+
for (const r of rows) say(clip(`${names[r.id]?.name || r.title || '(untitled)'} ${r.updated_at ? `· ${String(r.updated_at).slice(0, 16).replace('T', ' ')}` : ''}${flags.has('refs') ? ` · ${r.id}` : ''}`, cols - 4));
|
|
691
2298
|
} catch (e) { dispatch({ type: 'error', text: `sessions: ${e.message}` }); }
|
|
692
2299
|
return undefined;
|
|
693
2300
|
}
|
|
2301
|
+
case '/active': {
|
|
2302
|
+
const words = String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
2303
|
+
const filter = words.find(w => ACTIVE_KINDS.includes(w)) || null;
|
|
2304
|
+
if (words.some(w => w !== 'all' && !ACTIVE_KINDS.includes(w))) return say('Usage: /active [sessions|slots|agents] [all]', 'warn');
|
|
2305
|
+
if (!api.sessionsIndex) return say('Active is not available in this mode.', 'warn');
|
|
2306
|
+
try {
|
|
2307
|
+
let unread = null; if (api.slots) { try { unread = new Map((await api.slots()).map((x) => [x.name, x.unread_count || 0])); } catch { /* the count is a nicety */ } }
|
|
2308
|
+
for (const l of activeRows(await api.sessionsIndex(), { filter, cols, unread })) say(l.text, l.tone); // TUI-UX: one wrapped row per session, never clipped
|
|
2309
|
+
} catch (e) { say(`active: ${e.status ? `HTTP ${e.status}: ` : ''}${e.message}`, 'warn'); }
|
|
2310
|
+
return undefined;
|
|
2311
|
+
}
|
|
694
2312
|
case '/quit': case '/exit': {
|
|
695
2313
|
const midTurn = Boolean(S.current.turn);
|
|
696
2314
|
if (!leaveArmed('quit')) return undefined;
|
|
697
2315
|
R.current.leaveAsk = null;
|
|
698
|
-
return midTurn ?
|
|
2316
|
+
return exit(midTurn ? 30 : 0); // mid-turn: let the "keeps working" line paint first
|
|
699
2317
|
}
|
|
700
2318
|
case '/restart': {
|
|
701
2319
|
if (!onRestart) return say('Restart is not available in this mode.', 'warn');
|
|
2320
|
+
if (arg === 'auto') { // /restart auto [on|off]: bare toggles
|
|
2321
|
+
const v = String(restArg.split(/\s+/)[1] || '').toLowerCase();
|
|
2322
|
+
if (v && v !== 'on' && v !== 'off') return say('Usage: /restart auto [on|off]', 'warn');
|
|
2323
|
+
const a = AR.current; a.on = v ? v === 'on' : !a.on;
|
|
2324
|
+
if (!a.on) { clearTimeout(a.timer); a.timer = null; }
|
|
2325
|
+
api.saveSetting?.('restartAuto', a.on); bump(n => n + 1);
|
|
2326
|
+
return say(a.on ? 'Auto-restart is on. When a newer quox is installed and you are idle, this client restarts into the same conversation.' : 'Auto-restart is off.', a.on ? 'accent' : 'dim');
|
|
2327
|
+
}
|
|
702
2328
|
if (!leaveArmed('restart')) return undefined;
|
|
703
2329
|
R.current.leaveAsk = null;
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
onRestart({ conversationId: S.current.ctx.conversationId || null, name: SN.current.name || null, bin: target });
|
|
715
|
-
return setTimeout(() => exit(), 30);
|
|
2330
|
+
return doRestart();
|
|
2331
|
+
}
|
|
2332
|
+
case '/update': return updateCommand();
|
|
2333
|
+
case '/demo': {
|
|
2334
|
+
if (!leaveArmed('demo')) return undefined;
|
|
2335
|
+
R.current.leaveAsk = null;
|
|
2336
|
+
if (!onDemo) return say('/demo needs the terminal client.', 'warn');
|
|
2337
|
+
say('Starting the demo (autoplay, nothing real runs). Quit it to come back.', 'dim');
|
|
2338
|
+
onDemo();
|
|
2339
|
+
return exit(30);
|
|
716
2340
|
}
|
|
717
2341
|
case '/gripe': case '/gripe!': {
|
|
718
2342
|
if (!api.gripe) return say('/gripe is not available in this mode.', 'warn');
|
|
@@ -735,17 +2359,61 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
735
2359
|
}
|
|
736
2360
|
return undefined;
|
|
737
2361
|
}
|
|
738
|
-
case '/
|
|
2362
|
+
case '/context':
|
|
2363
|
+
if (arg === 'off' || arg === 'on') {
|
|
2364
|
+
SCX.current.on = arg === 'on';
|
|
2365
|
+
bump(n => n + 1);
|
|
2366
|
+
return say(arg === 'on' ? 'Terminal context on: each message carries what this screen shows (2 KB max, secrets masked).' : 'Terminal context off for this session: Quox no longer sees your roster, approvals, commands or notices.');
|
|
2367
|
+
}
|
|
2368
|
+
return showPanel('/context');
|
|
2369
|
+
case '/status': case '/cost': case '/usage':
|
|
739
2370
|
return showPanel(cmd === '/usage' ? '/cost' : cmd);
|
|
740
2371
|
case '/doctor': {
|
|
741
2372
|
if (!api.doctorChecks) return say('/doctor is not available in this mode.', 'warn');
|
|
742
2373
|
say('Running the quox doctor checks…', 'dim');
|
|
743
2374
|
const [plat, chat] = await Promise.all([api.doctorChecks().then(c => ({ c }), e => ({ e })), chatChecks(api)]);
|
|
744
|
-
|
|
2375
|
+
const empty = !plat.e && (!Array.isArray(plat.c) || !plat.c.length); // a run that produced no rows is a failure to say, not a blank panel
|
|
2376
|
+
return help(doctorModel(Array.isArray(plat.c) ? plat.c : [], chat, plat.e ? (plat.e.message || 'failed') : empty ? 'the doctor checks returned no results' : null));
|
|
2377
|
+
}
|
|
2378
|
+
case '/server': {
|
|
2379
|
+
if (!api.serverStatus) return say('/server is not available in this mode.', 'warn');
|
|
2380
|
+
say('Reading the server status…', 'dim');
|
|
2381
|
+
const st = await api.serverStatus().then(r => r, e => ({ ok: false, error: e?.message || 'failed' }));
|
|
2382
|
+
if (!st.ok) return say(`No server status: ${st.error}`, 'warn');
|
|
2383
|
+
help(st.rows);
|
|
2384
|
+
return say('Change it from a terminal: quox server update --dry-run shows the plan first (also rollback, uninstall, doctor, diag).', 'dim');
|
|
2385
|
+
}
|
|
2386
|
+
case '/dashboard': {
|
|
2387
|
+
if (!api.dashboardLink) return say('/dashboard is not available in this mode.', 'warn');
|
|
2388
|
+
const r = await api.dashboardLink(arg || '').catch(e => ({ kind: 'error', error: e?.message || 'failed' }));
|
|
2389
|
+
// The link is a credential: the lines go to the screen only; the dev transcript records that one was minted.
|
|
2390
|
+
for (const l of dashboardLines(r, { hyperlinks: supportsHyperlinks(env, Boolean(stdout?.isTTY)) })) {
|
|
2391
|
+
const sensitive = r.kind === 'link' && l.text.includes(r.url);
|
|
2392
|
+
dispatch({ type: 'note', text: l.text, tone: l.tone, ...(sensitive ? { redact: 'dashboard link minted' } : {}) });
|
|
2393
|
+
}
|
|
2394
|
+
return undefined;
|
|
745
2395
|
}
|
|
746
2396
|
case '/config': case '/settings':
|
|
747
2397
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
748
2398
|
return openConfig();
|
|
2399
|
+
case '/consent': {
|
|
2400
|
+
if (!api.consentStatus) return say('/consent is not available in this mode.', 'warn');
|
|
2401
|
+
const pending = R.current.consent || null;
|
|
2402
|
+
if (arg === 'grant') {
|
|
2403
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2404
|
+
const prov = String(restArg.replace(/^\S+\s*/, '') || pending?.provider || '').toLowerCase();
|
|
2405
|
+
if (!prov) return say('Which provider? /consent grant openai', 'warn');
|
|
2406
|
+
try { await api.grantConsent(prov); } catch (e) { return say(`Could not record your consent (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}). Settings > Privacy in the dashboard does the same.`, 'warn'); }
|
|
2407
|
+
say(`✔ Consent recorded: ${prov} may receive your messages and tool results. Change it any time in Settings > Privacy.`, 'ok');
|
|
2408
|
+
R.current.consent = null;
|
|
2409
|
+
if (pending?.text) { say('Sending your message again.', 'dim'); return runTurn(pending.text); }
|
|
2410
|
+
return undefined;
|
|
2411
|
+
}
|
|
2412
|
+
let rec = null;
|
|
2413
|
+
try { rec = await api.consentStatus(); } catch (e) { return say(`Could not read your consent (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}).`, 'warn'); }
|
|
2414
|
+
for (const [t, tone] of consentStatusLines(rec, { provider: pending?.provider })) say(t, tone);
|
|
2415
|
+
return undefined;
|
|
2416
|
+
}
|
|
749
2417
|
case '/compact': {
|
|
750
2418
|
if (arg === 'show') {
|
|
751
2419
|
const last = R.current.lastCompact;
|
|
@@ -771,8 +2439,48 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
771
2439
|
case '/resume':
|
|
772
2440
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
773
2441
|
return resumeCmd(restArg);
|
|
774
|
-
case '/
|
|
2442
|
+
case '/dragon': { // the living dragon (qmotion DragonLive): /dragon, classic and hero = the one dragon show, fire = its last act; explain / ask / sleep are dragonModes.js
|
|
2443
|
+
const m = parseDragonArg(restArg);
|
|
2444
|
+
if (!m) return say(DRAGON_USAGE);
|
|
2445
|
+
if (m.mode === 'explain') return dragonExplain();
|
|
2446
|
+
if (m.mode === 'ask') return dragonAsk(m.question);
|
|
2447
|
+
if (m.mode === 'sleep') return dragonSleep();
|
|
2448
|
+
if (m.mode === 'fly') return dragonFly();
|
|
2449
|
+
playDragon(m.mode, undefined); bump(n => n + 1); return true;
|
|
2450
|
+
}
|
|
2451
|
+
case '/dragon-fly': return arg ? say('Usage: /dragon-fly', 'warn') : dragonFly();
|
|
775
2452
|
case '/sound': return SND.current.command(arg, say);
|
|
2453
|
+
case '/tip': return showTip();
|
|
2454
|
+
case '/whatsnew': {
|
|
2455
|
+
const tg = toggleArg(arg);
|
|
2456
|
+
if (tg) { api.saveSetting?.('whatsNewStartup', tg); return say(tg === 'on' ? "What's new will show at startup when there is something new." : "What's new is off at startup. /whatsnew still works any time; /whatsnew on turns it back on.", 'ok'); }
|
|
2457
|
+
const all = String(arg).trim().toLowerCase() === 'all';
|
|
2458
|
+
const st = api.settings?.() || {};
|
|
2459
|
+
const cs = unseenCommands({ registry: visibleRegistry(), seen: st.whatsNewSeen, all });
|
|
2460
|
+
if (cs.length) { say(`${all ? 'All commands' : 'New commands'} (${cs.length}):`); cs.forEach(e => say(`${e.usage ? e.usage.split(/\s+[\[<|]/)[0] : '/' + e.id}: ${e.desc}`)); }
|
|
2461
|
+
else say('No new commands since you last looked.');
|
|
2462
|
+
api.saveSetting?.('whatsNewSeen', seenAfter(REGISTRY));
|
|
2463
|
+
// QUOXON-WHATSNEW3: only entries newer than the last one shown (all: every entry), then mark them seen.
|
|
2464
|
+
const unseen = all ? listWhatsNew('all', whatsNewEntries).entries : unseenEntries(st.whatsNew, whatsNewEntries);
|
|
2465
|
+
const shown = all ? unseen : unseen.slice(0, LIST_DEFAULT);
|
|
2466
|
+
const entry = (e) => { say(`${e.title}: ${e.text}`); if (tryText(e)) say(`Try: ${tryText(e)}`); };
|
|
2467
|
+
say("What's new in Quox:");
|
|
2468
|
+
if (shown.length) { say(all ? `Every entry (${unseen.length}):` : `New since you last looked (${unseen.length}):`); shown.forEach(entry); if (unseen.length > shown.length) say(`${unseen.length - shown.length} more: /whatsnew all`); }
|
|
2469
|
+
else { const top = listWhatsNew('', whatsNewEntries).entries[0]; say('Nothing new since you last looked.'); if (top) say(`Latest: ${top.title}${tryText(top) ? ` (try ${tryText(top)})` : ''}. /whatsnew all lists every entry.`); }
|
|
2470
|
+
if (shown.length || !st.whatsNew?.id) api.saveSetting?.('whatsNew', seenRecord(version, whatsNewEntries)); return true; }
|
|
2471
|
+
case '/tips': { let plugins = []; try { plugins = (await api.installedPlugins?.()) || []; } catch { /* unreadable: plugin tips hidden */ }
|
|
2472
|
+
const r = tipsReply(arg, { mode: TP.current.mode, plugins });
|
|
2473
|
+
if (r.tips) { say(r.head); r.tips.forEach(t => dispatch({ type: 'tip', text: t.text, cmd: t.cmd })); r.tail.forEach(l => say(l)); return true; }
|
|
2474
|
+
r.lines.forEach((l, i) => say(l, i ? undefined : r.tone === 'plain' ? undefined : r.tone)); return true; }
|
|
2475
|
+
case '/tip-settings': {
|
|
2476
|
+
const r = tipSettingsReply(arg, TP.current.mode);
|
|
2477
|
+
if (r.mode) { TP.current.mode = r.mode; api.saveSetting?.('tips', r.mode); }
|
|
2478
|
+
return say(r.text, r.tone === 'plain' ? undefined : r.tone);
|
|
2479
|
+
}
|
|
2480
|
+
case '/welcome': { // replay the first screen: the Q art with the greeting text beside it, and (a real terminal, 40+ rows) the dragon greeting. No setup side effects: nothing is saved, no state is reset.
|
|
2481
|
+
dispatch({ type: 'introShow', text: introItem.text, cols: introItem.cols });
|
|
2482
|
+
if (motionOn && !mono() && (termRows(stdout) ?? 0) >= 40) { playDragon('greeting'); bump(n => n + 1); }
|
|
2483
|
+
return true; }
|
|
776
2484
|
case '/about': return dispatch({ type: 'welcome', version: packageVersion() });
|
|
777
2485
|
case '/help': return dispatch({ type: 'help', model: helpModel(arg, R.current.caps) });
|
|
778
2486
|
case '/clear': case '/new': case '/reset': {
|
|
@@ -785,10 +2493,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
785
2493
|
setEpoch(e => e + 1);
|
|
786
2494
|
return say(prev ? 'Previous conversation saved. /resume to go back.' : 'New conversation.');
|
|
787
2495
|
}
|
|
2496
|
+
case '/aocl': return dispatch({ type: 'aocl.table' });
|
|
788
2497
|
case '/verbose':
|
|
789
2498
|
if (!['off', 'on', 'full'].includes(arg)) return say(`verbose is ${S.current.verbose}. Use /verbose off|on|full`);
|
|
790
2499
|
dispatch({ type: 'verbose', mode: arg });
|
|
791
|
-
|
|
2500
|
+
if (api.saveSetting) api.saveSetting('verbose', arg); // persisted per user (profile settings)
|
|
2501
|
+
return say(`verbose ${arg}${api.saveSetting ? ' (saved)' : ''}`);
|
|
792
2502
|
case '/rules': {
|
|
793
2503
|
if (arg === 'off' || arg === 'on') {
|
|
794
2504
|
R.current.rulesOn = arg === 'on';
|
|
@@ -802,6 +2512,93 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
802
2512
|
if (pages > 1) say(`page ${pg}/${pages}${pg < pages ? ` · /rules ${pg + 1} for more` : ''}`);
|
|
803
2513
|
return undefined;
|
|
804
2514
|
}
|
|
2515
|
+
case '/hat': { // quox#1183: change hats with no model turn. A normal hat switches now; a privileged one asks y/N.
|
|
2516
|
+
if (!api.hats || !api.bindHat) return say('Hat switching is not available in this mode.', 'warn');
|
|
2517
|
+
const R0 = R.current; const q = restArg.trim().replace(/^switch\b\s*/i, ''); // "/hat switch" is "/hat": the chooser
|
|
2518
|
+
const hatSub = q.split(/\s+/)[0].toLowerCase();
|
|
2519
|
+
if (hatSub === 'new' || hatSub === 'edit' || hatSub === 'delete') { // CUSTOM-HATS (owner 2026-10-17): own hats, walked one card at a time
|
|
2520
|
+
if (!api.createHat || !api.updateHat || !api.deleteHat) return say('Custom hats are not available in this mode.', 'warn');
|
|
2521
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2522
|
+
return HW.current.start(hatSub, q.slice(hatSub.length).trim());
|
|
2523
|
+
}
|
|
2524
|
+
if (q === 'yes') {
|
|
2525
|
+
const t = R0.hatPending; R0.hatPending = null;
|
|
2526
|
+
if (!t) return say('Nothing to confirm. /hat <name> first.', 'warn');
|
|
2527
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2528
|
+
let conv = S.current.ctx.conversationId;
|
|
2529
|
+
if (!conv) { conv = mintConversationId(randomUUID()); dispatch({ type: 'ctx', ctx: { conversationId: conv } }); }
|
|
2530
|
+
try { await api.bindHat(conv, t.id); } catch (e) { return say(`Hat not switched: ${e?.message || 'the server refused'}.`, 'warn'); }
|
|
2531
|
+
return say(`Hat switched: ${t.from || 'current'} → ${t.name}. Your next message runs on it.`, 'ok');
|
|
2532
|
+
}
|
|
2533
|
+
let hats, active = null;
|
|
2534
|
+
try { hats = await api.hats(); } catch (e) { return say(`Couldn't list hats: ${listFailure(e, profile)}`, 'warn'); }
|
|
2535
|
+
try { active = api.activeHat ? await api.activeHat(S.current.ctx.conversationId || null) : null; } catch { active = null; }
|
|
2536
|
+
if (!q || q === 'list') {
|
|
2537
|
+
if (!hats.length) return say('No hats on this server.', 'warn');
|
|
2538
|
+
const hw = Math.min(26, Math.max(4, ...hats.map(x => [...String(x.name || x.key)].length)));
|
|
2539
|
+
const hrows = hats.map(x => ({ key: `${x.key === active?.key ? '* ' : ' '}${String(x.name || x.key).padEnd(hw)}`, text: `${x.description || x.definition?.description || x.key}${hatIsPrivileged(x) ? ' (privileged: asks you first)' : ''}` }));
|
|
2540
|
+
PK.current.open({ title: 'Change hat for this conversation', rows: hrows, sel: Math.max(0, hats.findIndex(x => x.key === active?.key)), hint: '↑↓ select · Enter put it on · Esc cancel', onPick: (_, i) => command(`/hat ${hats[i].key}`) });
|
|
2541
|
+
return undefined;
|
|
2542
|
+
}
|
|
2543
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2544
|
+
const m = matchHat(q, hats);
|
|
2545
|
+
if (m.ambiguous) return say(`"${q}" matches more than one hat: ${hatChoices(m.ambiguous)}. Be more specific.`, 'warn');
|
|
2546
|
+
if (!m.hat) return say(`No hat matches "${q}". Hats you can pick: ${hatChoices(hats) || 'none'}.`, 'warn');
|
|
2547
|
+
if (active?.key === m.hat.key) return say(`Already wearing the ${m.hat.name || m.hat.key} hat.`);
|
|
2548
|
+
R0.hatPending = { id: m.hat.id, key: m.hat.key, name: m.hat.name || m.hat.key, from: active?.name || null, at: Date.now() };
|
|
2549
|
+
if (!hatIsPrivileged(m.hat)) return command('/hat yes'); // the person typed it: that is the authorization
|
|
2550
|
+
return ask(`Put on the ${R0.hatPending.name} hat?`, { action: `/hat ${m.hat.key}`, reason: 'It is a privileged hat: its tools can change real systems.' });
|
|
2551
|
+
}
|
|
2552
|
+
case '/org': {
|
|
2553
|
+
if (!api.orgs) return say('Org switching is not available in this mode.', 'warn');
|
|
2554
|
+
const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
|
|
2555
|
+
const sub = (restArg.match(/^(\S+)\s*(.*)$/) || [])[1] || '';
|
|
2556
|
+
const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
|
|
2557
|
+
let list;
|
|
2558
|
+
try { list = await api.orgs(); } catch (e) { return say(`Couldn't list your orgs: ${listFailure(e, profile)}`, 'warn'); }
|
|
2559
|
+
if (!sub || sub === 'list' || (sub === 'switch' && !query)) { // a bare /org switch offers the chooser, never a Usage line
|
|
2560
|
+
if (!list.length) return say('No orgs returned for this login.', 'warn');
|
|
2561
|
+
const orows = orgRows(list);
|
|
2562
|
+
const ow = Math.min(30, Math.max(4, ...orows.map(x => [...x.name].length)));
|
|
2563
|
+
PK.current.open({ title: 'Move this session to an org', rows: orows.map(x => ({ key: `${x.id === curId ? '* ' : ' '}${x.name.padEnd(ow)}`, text: `${x.slug || x.id}${x.role ? ` (${x.role})` : ''}` })), sel: Math.max(0, orows.findIndex(x => x.id === curId)), hint: '↑↓ select · Enter switch (you confirm first) · Esc cancel', onPick: (_, i) => (orows[i].id === curId ? say(`Already in ${orows[i].name}.`) : command(`/org switch ${orows[i].slug || orows[i].id}`)) });
|
|
2564
|
+
return undefined;
|
|
2565
|
+
}
|
|
2566
|
+
if (sub === 'yes') {
|
|
2567
|
+
const t = R0.orgPending; R0.orgPending = null;
|
|
2568
|
+
if (!t) return say('Nothing to confirm. /org switch <name> first.', 'warn');
|
|
2569
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2570
|
+
if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
|
|
2571
|
+
let done;
|
|
2572
|
+
try { done = await api.switchOrg(t.id); } catch (e) { return say(`Not switched. ${e?.message || 'The server refused.'} You are still in ${R0.orgName ?? org ?? 'the same org'}.`, 'warn'); }
|
|
2573
|
+
R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
|
|
2574
|
+
R0.orgId = done.id; R0.orgName = done.name;
|
|
2575
|
+
dispatch({ type: 'ctx', ctx: { conversationId: null } });
|
|
2576
|
+
SN.current.name = null; SN.current.saved = null;
|
|
2577
|
+
dispatch({ type: 'history.reset' });
|
|
2578
|
+
if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
2579
|
+
setEpoch(e => e + 1); bump(n => n + 1);
|
|
2580
|
+
return say(`Switched to ${done.name}. New conversation in that org.`, 'ok');
|
|
2581
|
+
}
|
|
2582
|
+
if (sub === 'back' || sub === '-') { // owner 2026-10-15 "switch back": no "type /org switch <the org you were in>"
|
|
2583
|
+
const p = R0.prevOrg;
|
|
2584
|
+
if (!p) return say('No previous org in this session yet. /org lists yours.', 'warn');
|
|
2585
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2586
|
+
R0.orgPending = { id: p.id, name: p.name, at: Date.now() };
|
|
2587
|
+
if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
|
|
2588
|
+
return ask(`Switch back to ${p.name}?`, { action: `/org switch ${p.id} (${p.name})`, reason: 'This starts a new conversation in that org.' });
|
|
2589
|
+
}
|
|
2590
|
+
if (sub !== 'switch') return say('Usage: /org, /org switch <name or id>, or /org back', 'warn');
|
|
2591
|
+
if (!query) return say('Usage: /org switch <name or id>. /org lists them.', 'warn');
|
|
2592
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2593
|
+
const m = matchOrg(query, list);
|
|
2594
|
+
if (m.ambiguous) return say(`"${query}" matches more than one: ${m.ambiguous.map(o => o.name).join(', ')}. Be more specific.`, 'warn');
|
|
2595
|
+
if (!m.org) return say(`No org of yours matches "${query}". /org lists them.`, 'warn');
|
|
2596
|
+
if (m.org.id === curId) return say(`Already in ${m.org.name}.`);
|
|
2597
|
+
R0.orgPending = { id: m.org.id, name: m.org.name, at: Date.now() };
|
|
2598
|
+
// Already said yes to the suggested "/org switch X? y/N": one confirmation, not two (owner 2026-10-15).
|
|
2599
|
+
if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
|
|
2600
|
+
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
|
+
}
|
|
805
2602
|
case '/skills': {
|
|
806
2603
|
if (!api.skills) return say('This server has no skills list.', 'warn');
|
|
807
2604
|
try {
|
|
@@ -849,60 +2646,572 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
849
2646
|
if (ro.sources) say('Down on an empty composer focuses the roster. Enter peeks, s stops (y confirms), Esc returns.');
|
|
850
2647
|
return undefined;
|
|
851
2648
|
}
|
|
852
|
-
case '/
|
|
2649
|
+
case '/vm': {
|
|
2650
|
+
if (!api.vm) return say('/vm is not available in this mode.', 'warn');
|
|
2651
|
+
if (!restArg) return say('Usage: /vm <list|info|doctor|start|stop|reboot|shutdown|snapshot|pool|node> <target> [args]', 'warn');
|
|
2652
|
+
try {
|
|
2653
|
+
const r = await api.vm(restArg.split(/\s+/));
|
|
2654
|
+
for (const l of r.lines) say(l, r.tone || undefined);
|
|
2655
|
+
} catch (e) { dispatch({ type: 'error', text: `vm: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}` }); }
|
|
2656
|
+
return undefined;
|
|
2657
|
+
}
|
|
2658
|
+
case '/hyperion': {
|
|
2659
|
+
if (!api.hyperion) return say('/hyperion is not available in this mode.', 'warn');
|
|
2660
|
+
try {
|
|
2661
|
+
const r = await api.hyperion();
|
|
2662
|
+
for (const l of r.lines) say(l, r.tone || undefined);
|
|
2663
|
+
} catch (e) { dispatch({ type: 'error', text: `hyperion: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}` }); }
|
|
2664
|
+
return undefined;
|
|
2665
|
+
}
|
|
2666
|
+
case '/sitrep': {
|
|
2667
|
+
if (!api.sitrep) return say('/sitrep is not available in this mode.', 'warn');
|
|
2668
|
+
const r = await api.sitrep();
|
|
2669
|
+
return dispatch({ type: 'object', obj: { type: 'sitrep', data: sitrepData(r.situation, r.approvals) } });
|
|
2670
|
+
}
|
|
2671
|
+
case '/afk': { // owner 2026-10-15: "/afk need a break, keep my requests organised for my return"
|
|
2672
|
+
const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: restArg || '' };
|
|
2673
|
+
try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
|
|
2674
|
+
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);
|
|
2676
|
+
if (!pr.ok) presenceFail(pr);
|
|
2677
|
+
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');
|
|
2678
|
+
return undefined;
|
|
2679
|
+
}
|
|
2680
|
+
case '/back': { // PRESENCE: present on the server too, then the digest
|
|
2681
|
+
AWAY.current.afk = null;
|
|
2682
|
+
const pr = await setPresence('present');
|
|
2683
|
+
if (!pr.ok) presenceFail(pr);
|
|
2684
|
+
return command('/away');
|
|
2685
|
+
}
|
|
2686
|
+
case '/observe': { // owner 2026-10-16: a note to the observer (the QUOXON session watching this window)
|
|
2687
|
+
if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
|
|
2688
|
+
if (!restArg.trim()) return say('Usage: /observe <text> (posts into the quoxon slot, for the session that watches your window)', 'warn');
|
|
2689
|
+
if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
|
|
2690
|
+
const r = await api.slotSay('quoxon', restArg.trim(), { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
|
|
2691
|
+
if (!r.ok) return say(`observe: not delivered (${r.message})`, 'warn');
|
|
2692
|
+
return say('✔ noted for the observer (quoxon)', 'ok');
|
|
2693
|
+
}
|
|
2694
|
+
case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
|
|
2695
|
+
const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
|
|
2696
|
+
if (!m) return say(`Usage: ${cmd} <slot or session> [text] (an urgent page; they are asked to acknowledge, and an idle session is woken to read it, even with an unsent draft, which is set aside and put back)`, 'warn');
|
|
2697
|
+
return command(`/page ${m.slot} ${m.text ? m.text.trim() : `are you there? (${cmd.slice(1)} from ${myLabel() || 'the owner'})`}`);
|
|
2698
|
+
}
|
|
2699
|
+
case '/away': {
|
|
2700
|
+
if (!api.away) return say('/away is not available in this mode.', 'warn');
|
|
2701
|
+
const p = parseSince(restArg, { lastSeen: AWAY.current.last });
|
|
2702
|
+
if (!p.ok) return say(p.error, 'warn');
|
|
2703
|
+
try { dispatch({ type: 'object', obj: { type: 'away', data: await api.away(p.since) } }); }
|
|
2704
|
+
catch (e) { say(`away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). Nothing is assumed.`, 'warn'); }
|
|
2705
|
+
return undefined;
|
|
2706
|
+
}
|
|
2707
|
+
case '/mind': {
|
|
2708
|
+
if (!api.mind) return say('/mind is not available in this mode.', 'warn');
|
|
2709
|
+
if (MIND_HELP_ARGS.has(restArg.toLowerCase())) { for (const [text, tone] of mindHelpLines()) say(text, tone); return undefined; }
|
|
2710
|
+
try { for (const [text, tone] of mindLines(await api.mind(restArg || undefined))) say(text, tone); }
|
|
2711
|
+
catch (e) {
|
|
2712
|
+
const isCommand = (n) => REGISTRY.some(x => x.kind === 'command' && x.status === 'active' && x.id === n);
|
|
2713
|
+
for (const [text, tone] of mindErrorLines(e, restArg, { isCommand })) tone === 'error' ? dispatch({ type: 'error', text }) : say(text, tone);
|
|
2714
|
+
}
|
|
2715
|
+
return undefined;
|
|
2716
|
+
}
|
|
2717
|
+
case '/observer': case '/observers': case '/lucid': {
|
|
853
2718
|
if (!api.observers) return say('Observers are not available in this mode.', 'warn');
|
|
854
2719
|
try { for (const [text, tone] of observerLines(await api.observers(), cols)) say(text, tone); }
|
|
855
2720
|
catch (e) { dispatch({ type: 'error', text: `observers: ${e.message}` }); }
|
|
856
2721
|
return undefined;
|
|
857
2722
|
}
|
|
858
2723
|
case '/approvals': {
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
2724
|
+
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')})`);
|
|
2726
|
+
return undefined;
|
|
2727
|
+
}
|
|
2728
|
+
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
|
+
let stats = null; let statsErr = null;
|
|
2732
|
+
if (api.approvalStats) { try { stats = await api.approvalStats(); } catch (e) { statsErr = e.message; } }
|
|
2733
|
+
const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
|
|
2734
|
+
const groups = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds });
|
|
2735
|
+
const flat = groups.flatMap(g => g.items.map(x => x.item));
|
|
2736
|
+
dispatch({ type: 'inbox', items: flat });
|
|
2737
|
+
R.current.shownIds = flat.map(x => x.id);
|
|
2738
|
+
dispatch({ type: 'notify', patch: { approvals: flat.length, error: null } });
|
|
2739
|
+
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
|
+
const fArg = restArg.trim();
|
|
2742
|
+
if (/^walkthrough$/i.test(fArg)) return walkStart(flat, { fresh: true });
|
|
2743
|
+
if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
|
|
2744
|
+
const it = flat[Number(fArg) - 1];
|
|
2745
|
+
if (!it) return say(`No such item: [${fArg}] (${flat.length} listed).`, 'warn');
|
|
2746
|
+
for (const l of approvalDetail(it, Number(fArg))) say(l);
|
|
2747
|
+
say(' ');
|
|
2748
|
+
if (isDecision(it)) { R.current.focusedDecision = { n: Number(fArg), id: it.id, at: Date.now() }; return say(`${mark(`/answer ${fArg} ${it.payload.options.map(o => o.key).join('|')}`)} with an optional "note", or ${mark(`/deny ${fArg} <reason>`)}`, 'warn'); }
|
|
2749
|
+
return say(`${mark(`/approve ${fArg}`)} or ${mark(`/deny ${fArg} <reason>`)}`, 'warn');
|
|
2750
|
+
}
|
|
2751
|
+
const keep = filterPredicate(fArg || null, { userId: api.userId?.() || null, conversationId: S.current.ctx.conversationId });
|
|
2752
|
+
const shown = new Set(flat.filter(keep));
|
|
2753
|
+
const nDec = flat.filter(isDecision).length;
|
|
2754
|
+
const cardFirst = nDec > 0 && !fArg; // plain /approvals opens straight on the one-at-a-time card
|
|
2755
|
+
if (cardFirst) say(`${nDec} question${nDec === 1 ? '' : 's'} waiting for you.`, 'warn');
|
|
2756
|
+
else if (nDec) { const k1 = flat.findIndex(isDecision) + 1; const ks = flat[k1 - 1].payload.options.map(o => o.key).join('|'); say(`${nDec} question${nDec === 1 ? '' : 's'} waiting for you; I take them one at a time below${nDec > 1 ? ' (/yestoall answers every yes/no at once)' : ''}. Typed form: /answer ${k1} ${ks}, with an optional "note".`, 'dim'); }
|
|
2757
|
+
if (cardFirst && flat.length > nDec) say(`${flat.length - nDec} other approval${flat.length - nDec === 1 ? '' : 's'} wait${flat.length - nDec === 1 ? 's' : ''} too: ${mark('/approve N')} or ${mark('/deny N <reason>')}; ${mark('/approvals help')} has the syntax.`, 'dim'); // plain /approvals: the walk takes the questions; the syntax wall lives in /approvals help
|
|
2758
|
+
else if (flat.length > nDec || (fArg && !cardFirst)) say(`${flat.length} waiting${fArg ? `, ${shown.size} match "${fArg}"` : ''}. ${mark('/approve N')} ${mark('/deny N <reason>')} ${mark('/approvals N')} detail ${mark('/approvals mine|agents|old|<agent>')} (privileged: ${mark('/approve N confirm')}):`, 'warn');
|
|
2759
|
+
let n = 0;
|
|
2760
|
+
for (const g of groups) {
|
|
2761
|
+
const rows = g.items.map(x => ({ x, k: ++n })).filter(r => shown.has(r.x.item) && !cardFirst);
|
|
2762
|
+
if (!rows.length) continue;
|
|
2763
|
+
say(' ');
|
|
2764
|
+
say(`${g.group}:`, 'warn');
|
|
2765
|
+
for (const r of rows) { say(approvalRow(r.x.item, r.k, { cols: cols - 2 })); if (isDecision(r.x.item)) say(' '); }
|
|
2766
|
+
}
|
|
2767
|
+
if (flat.length && !shown.size) say(`Nothing matches "${fArg}".`, 'warn');
|
|
2768
|
+
if (flat.length) say(' ');
|
|
2769
|
+
if (nDec && !fArg) walkStart(flat, { fresh: true, quiet: true });
|
|
2770
|
+
if (hidden) {
|
|
2771
|
+
const others = humanProfiles.filter(h => h !== profile);
|
|
2772
|
+
const whoTxt = agentLogin ? `${profile} (an agent login), so it can't approve these` : `${profile}, which cannot act on these`;
|
|
2773
|
+
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
|
+
if (agentLogin) offerSwitch(); // one keypress instead of quit and relaunch
|
|
2775
|
+
}
|
|
2776
|
+
if (statsErr) say(`Could not check the org-wide approval count (${statsErr}); the list above is what the inbox returned.`, 'warn');
|
|
2777
|
+
return undefined;
|
|
2778
|
+
}
|
|
2779
|
+
case '/feedback': { // owner 2026-10-15: /feedback goes to the quox.ai feedback API (/gripe stays GitHub)
|
|
2780
|
+
if (!api.feedback) return say('/feedback is not available in this mode.', 'warn');
|
|
2781
|
+
const fb = feedbackPayload(restArg, version);
|
|
2782
|
+
if (!fb) return say('Usage: /feedback <text> (5 to about 4900 characters; goes to the Quox team at quox.ai)', 'warn');
|
|
2783
|
+
for (const l of feedbackPreview(fb)) say(clip(l, cols - 4), 'dim');
|
|
2784
|
+
R.current.feedbackAsk = { payload: fb, at: Date.now() };
|
|
2785
|
+
return ask('Send it?', { action: 'send the feedback above to https://api.quox.ai/api/feedback (the Quox team)' });
|
|
2786
|
+
}
|
|
2787
|
+
case '/mouse': {
|
|
2788
|
+
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.`);
|
|
2790
|
+
if (v !== 'on' && v !== 'off') return say('Usage: /mouse on|off', 'warn');
|
|
2791
|
+
MO.current.on = v === 'on'; MO.current.drag = null;
|
|
2792
|
+
api.saveSetting?.('mouse', v);
|
|
2793
|
+
mouseTerm(MO.current.on);
|
|
2794
|
+
return say(MO.current.on ? mouseOnNote() : 'Mouse off. Your terminal selects, copies and scrolls as usual again.', MO.current.on ? undefined : 'ok');
|
|
2795
|
+
}
|
|
2796
|
+
case '/r': case '/reply': {
|
|
2797
|
+
if (!R.current.lastSender) return say('Nobody has messaged you in this window yet. /say <slot> <text> starts one.', 'warn');
|
|
2798
|
+
if (!restArg) return say(`Usage: /r <text> (replies to ${R.current.lastSender})`, 'warn');
|
|
2799
|
+
return sendToSlot(R.current.lastSender.toLowerCase(), restArg.trim());
|
|
2800
|
+
}
|
|
2801
|
+
// Dragon verbs (owner 2026-10-15), only where an existing command does exactly that; the rest are an idea, not code.
|
|
2802
|
+
case '/mode': { // PLAN MODE (owner 2026-10-16): /mode [ask|plan]; auto is planned, not built
|
|
2803
|
+
if (!restArg.trim()) { // same arrow-key chooser as /model; /mode -h still explains
|
|
2804
|
+
const cur = normMode(MD.current);
|
|
2805
|
+
PK.current.open({ title: 'Mode for this window', rows: MODE_ROWS.map(x => ({ key: `${x.id === cur ? '* ' : ' '}${x.id}`, text: x.text })), sel: MODES.indexOf(cur), hint: '↑↓ select · Enter use · Esc cancel', onPick: (_, i) => command(`/mode ${MODES[i]}`) });
|
|
2806
|
+
return undefined;
|
|
2807
|
+
}
|
|
2808
|
+
const m = modeReply(restArg, MD.current);
|
|
2809
|
+
if (m.mode) { MD.current = m.mode; R.current.planAsk = null; bump(n => n + 1); }
|
|
2810
|
+
return say(m.text, m.tone || undefined);
|
|
2811
|
+
}
|
|
2812
|
+
case '/sleep': { // owner typed /sleep first (2026-10-16); it now MEANS sleeping: sessions stop waiting on approvals
|
|
2813
|
+
const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: 'sleeping' };
|
|
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);
|
|
2817
|
+
return command('/dragon sleep');
|
|
2818
|
+
}
|
|
2819
|
+
case '/roar': return command(`/broadcast${restArg ? ` ${restArg}` : ''}`);
|
|
2820
|
+
case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
|
|
2821
|
+
case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
|
|
2822
|
+
case '/huh': return command(`/dragon explain${restArg ? ` ${restArg}` : ''}`); // owner 2026-10-15: shortcut for /dragon explain
|
|
2823
|
+
case '/slots': return listSlots(/^all$/i.test(restArg.trim()));
|
|
2824
|
+
case '/mirror': {
|
|
2825
|
+
// The Mirror itself: this org's server-built Mirror, what CommanderQ is told every turn (not /mind).
|
|
2826
|
+
if (!api.mirror) return say('/mirror is not available in this mode.', 'warn');
|
|
2827
|
+
const view = /^agents?$/i.test(restArg.trim()) ? 'agents' : null;
|
|
2828
|
+
try { dispatch({ type: 'object', obj: { type: 'mirror', data: { ...(await api.mirror()), ...(view ? { view } : {}) } } }); }
|
|
2829
|
+
catch (e) { say(mirrorErrorNote(e), 'warn'); }
|
|
2830
|
+
return undefined;
|
|
2831
|
+
}
|
|
2832
|
+
case '/core': case '/health': case '/collector-health': return command('/status'); // owner reached for these first; /health and /core are the dashboard card above when the objects api is there
|
|
2833
|
+
case '/walkthrough': return command('/approvals walkthrough');
|
|
2834
|
+
case '/yestoall': return command(`/approve all${arg ? ` ${arg}` : ''}`); // owner 2026-10-15: alias of /approve all
|
|
2835
|
+
case '/authenticate': {
|
|
2836
|
+
if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
|
|
2837
|
+
await SUC.current.authenticate(); bump(n => n + 1); return undefined;
|
|
2838
|
+
}
|
|
2839
|
+
case '/override': {
|
|
2840
|
+
const res = await SUC.current.override(restArg);
|
|
2841
|
+
if (res?.override_id && res.enforced) R.current.override = { id: res.override_id, until: Date.parse(res.expires_at) || Date.now() + 300000 };
|
|
2842
|
+
return undefined;
|
|
2843
|
+
}
|
|
2844
|
+
case '/authorise': case '/authorize': {
|
|
2845
|
+
if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
|
|
2846
|
+
let authArg = restArg;
|
|
2847
|
+
if (!authArg.trim()) { // QUOXON-1191: bare /authorise picks the one item that needs a PIN; with several it lists them
|
|
2848
|
+
let li = S.current.inbox;
|
|
2849
|
+
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
|
+
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
|
+
if (needs.length === 1) authArg = String(needs[0]);
|
|
2852
|
+
else return say(needs.length ? `${needs.length} items need your PIN, so say which: ${needs.map(k => mark(`/authorise ${k}`)).join(' ')}` : 'Usage: /authorise N (approve item N with your PIN; list them with /approvals). Nothing waiting needs a PIN right now.', 'warn');
|
|
2853
|
+
}
|
|
2854
|
+
R.current.authorising = true; // /approve's answer() asks for the PIN first when there is no live step-up
|
|
2855
|
+
try { await command(`/approve ${authArg}`); } finally { R.current.authorising = false; }
|
|
2856
|
+
return undefined;
|
|
2857
|
+
}
|
|
2858
|
+
case '/answer': { // quox ask: pick an option of a decision a session asked
|
|
2859
|
+
const words = line.trim().split(/\s+/).slice(1);
|
|
2860
|
+
const fo = R.current.focusedDecision;
|
|
2861
|
+
const pa = parseAnswer(words, { focusN: fo && Date.now() - fo.at < 600000 ? fo.n : null, raw: line.trim().replace(/^\S+\s*/, '') });
|
|
2862
|
+
if (pa.bad) return say(`Usage: ${mark('/answer 3 b')} (item number, then the option letter), optionally with a note: ${mark('/answer 3 b "after Friday"')}. Several at once: ${mark('/answer 1 y 2 n')}. List them with ${mark('/approvals')}.`, 'warn');
|
|
2863
|
+
if (await listChanged()) return undefined;
|
|
2864
|
+
let list = S.current.inbox;
|
|
2865
|
+
if (!list.length || (pa.n ? !list[pa.n - 1] : true)) {
|
|
2866
|
+
try {
|
|
2867
|
+
const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
|
|
2868
|
+
list = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item));
|
|
2869
|
+
dispatch({ type: 'inbox', items: list });
|
|
2870
|
+
} catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
|
|
2871
|
+
}
|
|
2872
|
+
if (pa.ns) { // QUOXON-1191: /answer 1-5 y, /answer 1,3,4 y: validate all, ask the PIN once if any is high risk
|
|
2873
|
+
const keyOf = (k) => pa.keys?.[k] ?? pa.key; // QUOXON-1191b: pairs carry their own y|n per item
|
|
2874
|
+
if (pa.ns.some(k => !list[k - 1])) {
|
|
2875
|
+
try { list = 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: list }); }
|
|
2876
|
+
catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
|
|
2877
|
+
}
|
|
2878
|
+
if (pa.note && noteNeedsConfirm(pa.note) && !R.current.noteBypass) { // NOTE-CONFIRM: one card for the whole batch
|
|
2879
|
+
const base = pa.keys ? pa.ns.map(k => `${k} ${pa.keys[k]}`).join(' ') : `${pa.ns.join(',')} ${pa.key}`;
|
|
2880
|
+
noteCard({ walk: false, batch: true, count: pa.ns.length, cmd: base, key: pa.key, label: pa.keys ? [...new Set(Object.values(pa.keys))].join('/') : pa.key, note: pa.note });
|
|
2881
|
+
return undefined;
|
|
2882
|
+
}
|
|
2883
|
+
const bad = pa.ns.filter(k => !list[k - 1] || !isDecision(list[k - 1]) || !list[k - 1].payload.options.some(o => o.key === keyOf(k)));
|
|
2884
|
+
if (bad.length) return say(`Nothing sent. ${bad.map(k => `[${k}]`).join(' ')} ${bad.length === 1 ? 'is' : 'are'} not ${list.length ? `a decision with option "${bad.map(keyOf).filter((v, i, a2) => a2.indexOf(v) === i).join('/')}"` : 'listed'}. Use ${mark('/approvals')} to see the options.`, 'warn');
|
|
2885
|
+
const bulkAns = pa.ns.length > 1 && !pa.keys; // explicit per-item pairs (1 y 2 n) name each item and key themselves
|
|
2886
|
+
let walkAfter = null;
|
|
2887
|
+
const heldNs = bulkAns ? pa.ns.filter(k => keyOf(k) !== 'n' && isHeldFromBulk(list[k - 1])) : []; // a bulk yes never covers a risky ask
|
|
2888
|
+
if (heldNs.length) {
|
|
2889
|
+
pa.ns = pa.ns.filter(k => !heldNs.includes(k));
|
|
2890
|
+
say(`${heldNs.length} risky decision${heldNs.length === 1 ? '' : 's'} held back from the bulk yes (${[...new Set(heldNs.map(k => heldReason(list[k - 1])))].join(', ')}). I will take ${heldNs.length === 1 ? 'it' : 'them'} one at a time, with the full text, after the rest.`, 'warn');
|
|
2891
|
+
walkAfter = heldNs.map(k => list[k - 1]);
|
|
2892
|
+
}
|
|
2893
|
+
const items = pa.ns.map(k => list[k - 1]);
|
|
2894
|
+
const gate = pa.ns.some(k => keyOf(k) !== 'n' && list[k - 1].payload.high_risk === true);
|
|
2895
|
+
const askPin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject: { line1: `answer ${items.length} decisions ${[...new Set(pa.ns.map(keyOf))].join('/')}`, raisedBy: null, risk: 'high' } }); if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; } };
|
|
2896
|
+
try { if (gate && !SU.current.token('approve')) await askPin(); }
|
|
2897
|
+
catch (e) { return say(`Not answered: ${e.message}. Nothing was sent.`, 'warn'); }
|
|
2898
|
+
const okIds = []; const failedK = [];
|
|
2899
|
+
for (const k of pa.ns) {
|
|
2900
|
+
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 });
|
|
2902
|
+
try {
|
|
2903
|
+
try { await go(); }
|
|
2904
|
+
catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
|
|
2905
|
+
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');
|
|
2907
|
+
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
|
+
} catch (e) { failedK.push(`[${k}] ${e?.status === 404 || e?.status === 400 ? 'already handled or expired' : e.message}`); if (e?.stepUpDeclined) break; }
|
|
2909
|
+
}
|
|
2910
|
+
if (failedK.length) say(`Not answered: ${failedK.join('; ')}`, 'warn');
|
|
2911
|
+
R.current.focusedDecision = null;
|
|
2912
|
+
dispatch({ type: 'inbox', items: list.filter(x => !okIds.includes(x.id)) });
|
|
2913
|
+
if (R.current.shownIds) R.current.shownIds = list.filter(x => !okIds.includes(x.id)).map(x => x.id);
|
|
2914
|
+
if (walkAfter) walkStart(walkAfter);
|
|
2915
|
+
return undefined;
|
|
2916
|
+
}
|
|
2917
|
+
if (!pa.n) { // no number: fine when exactly one decision is open
|
|
2918
|
+
const idx = list.map((x, k) => (isDecision(x) ? k + 1 : 0)).filter(Boolean);
|
|
2919
|
+
if (idx.length !== 1) return say(idx.length ? `${idx.length} decisions are open, so say which: ${idx.slice(0, 3).map(k => mark(`/answer ${k} ${list[k - 1].payload.options.map(o => o.key).join('|')}`)).join(' ')}` : 'No decision is waiting for an answer.', 'warn');
|
|
2920
|
+
pa.n = idx[0];
|
|
2921
|
+
}
|
|
2922
|
+
const it = list[pa.n - 1];
|
|
2923
|
+
if (!it) return say(`No such item: [${pa.n}] (${list.length} listed).`, 'warn');
|
|
2924
|
+
if (!isDecision(it)) return say(`[${pa.n}] is an approval, not a question. Use ${mark(`/approve ${pa.n}`)} or ${mark(`/deny ${pa.n} <reason>`)}.`, 'warn');
|
|
2925
|
+
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
|
+
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
|
+
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
|
|
2928
|
+
try { // QUOXON-1191: a yes to a high-risk ask needs the PIN: ask up front, or on the server's step_up_required, then retry once
|
|
2929
|
+
const gate = it.payload.high_risk === true && pa.key !== 'n';
|
|
2930
|
+
const subject = { line1: `answer ${String(it.title || "")}`, raisedBy: it.payload.session || null, risk: 'high' };
|
|
2931
|
+
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
|
+
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') });
|
|
2934
|
+
try { await go(); }
|
|
2935
|
+
catch (e) {
|
|
2936
|
+
if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
|
|
2937
|
+
SU.current.drop('approve'); await askPin(); await go();
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
catch (e) {
|
|
2941
|
+
if (e?.stepUpDeclined) return say(R.current.walkSend ? `Not answered: ${e.message}.` : `Not answered: ${e.message}. Run ${mark(`/answer ${pa.n} ${pa.key}`)} again when you can confirm.`, 'warn'); // in a walkthrough the card says what to do next
|
|
2942
|
+
if (e?.status === 404 || e?.status === 400) { R.current.goneAsk = it.id; return say(`[${pa.n}] ${expiredText(it)}`, 'accent'); }
|
|
2943
|
+
return dispatch({ type: 'error', text: `answer: ${e.message}` });
|
|
2944
|
+
}
|
|
2945
|
+
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');
|
|
2947
|
+
say(' ');
|
|
2948
|
+
R.current.lastAnswered = it.id; walkAnsweredIds().add(it.id);
|
|
2949
|
+
if (it.payload.slot && api.slotSay) { // WALK-FIXES: the answer is already recorded; the slot post never holds up the next card
|
|
2950
|
+
const slotNote = (m) => say(`Answer recorded; could not post it to slot ${it.payload.slot} (${m}). The asker gets it from: quox ask wait ${it.id}`, 'warn');
|
|
2951
|
+
const posting = Promise.resolve().then(() => api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was answered: ${pa.key}${pa.note ? ` (${pa.note})` : ''}. Read it with: quox ask status ${it.id}`, { from: myLabel() })).then(r => { if (!r?.ok) slotNote(r?.message || 'no reply'); }, e => slotNote(e?.message || 'failed'));
|
|
2952
|
+
if (!R.current.walkSend) await posting;
|
|
2953
|
+
}
|
|
2954
|
+
R.current.focusedDecision = null;
|
|
2955
|
+
dispatch({ type: 'inbox', items: list.filter(x => x.id !== it.id) });
|
|
2956
|
+
if (R.current.shownIds) R.current.shownIds = list.filter(x => x.id !== it.id).map(x => x.id);
|
|
867
2957
|
return undefined;
|
|
868
2958
|
}
|
|
869
2959
|
case '/approve': case '/deny': {
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
2960
|
+
const verb = cmd === '/approve' ? 'approve' : 'reject';
|
|
2961
|
+
const words = line.trim().split(/\s+/).slice(1);
|
|
2962
|
+
const prs = cmd === '/approve' ? parsePairs(words) : null; // QUOXON-1191b: /approve 1 y 2 n, 1y 2n
|
|
2963
|
+
if (prs) {
|
|
2964
|
+
if (prs.bad) return say(`Could not read "${words.join(' ')}". Use each number once: ${mark('/approve 1 y 2 n')}.`, 'warn');
|
|
2965
|
+
let pl = S.current.inbox;
|
|
2966
|
+
if (!pl.length || prs.some(x => !pl[x.k - 1])) {
|
|
2967
|
+
try { 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 }); }
|
|
2968
|
+
catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
|
|
2969
|
+
}
|
|
2970
|
+
const missingP = prs.filter(x => !pl[x.k - 1]);
|
|
2971
|
+
if (missingP.length) return say(`Nothing sent. ${missingP.map(x => `[${x.k}]`).join(' ')} ${missingP.length === 1 ? 'is' : 'are'} not listed (${pl.length} listed). Use ${mark('/approvals')}.`, 'warn');
|
|
2972
|
+
const decP = prs.filter(x => isDecision(pl[x.k - 1])); const plainP = prs.filter(x => !isDecision(pl[x.k - 1]));
|
|
2973
|
+
if (decP.length) await command(`/answer ${decP.map(x => `${x.k} ${x.key}`).join(' ')}`);
|
|
2974
|
+
for (const x of plainP) await command(x.key === 'y' ? `/approve ${x.k}` : `/deny ${x.k}`);
|
|
2975
|
+
return undefined;
|
|
2976
|
+
}
|
|
2977
|
+
const pd = parseDecision(words, verb, { raw: line.trim().replace(/^\S+\s*/, '') });
|
|
2978
|
+
const { confirm, all, agent, reason } = pd; const nums = pd.nums;
|
|
2979
|
+
const spec = words.filter(w => w !== 'confirm');
|
|
2980
|
+
const usage = `Usage: ${mark(`${cmd} N [N...]`)} | ${mark(`${cmd} 1,2`)}${verb === 'approve' ? ` | ${mark('/approve 1 y 2 n')}` : ''} | ${mark(`${cmd} 1-3`)} | ${mark(`${cmd} all`)} | ${mark(`${cmd} agent <name>`)}${verb === 'reject' ? ` (add a reason after the target: ${mark('/deny 2 wrong host')})` : ''} (privileged ones need: ${mark(`${cmd} N confirm`)}). List them with ${mark('/approvals')}.`;
|
|
2981
|
+
if (pd.bad) return say(`${spec.length ? `Could not read "${spec.join(' ')}". ` : ''}${usage}`, 'warn');
|
|
2982
|
+
if (!all && !agent && nums.length && await listChanged()) return undefined;
|
|
2983
|
+
let list = S.current.inbox;
|
|
2984
|
+
if (!list.length || (!all && nums.some(k => !list[k - 1]))) { // never send the user back to /approvals: fetch the same list, in the same order, ourselves
|
|
2985
|
+
try {
|
|
2986
|
+
const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
|
|
2987
|
+
list = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item));
|
|
2988
|
+
dispatch({ type: 'inbox', items: list });
|
|
2989
|
+
} catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
|
|
2990
|
+
}
|
|
2991
|
+
if (!list.length) return say('Nothing is waiting for your approval.', 'warn');
|
|
2992
|
+
let targets = all ? list.map((_, k) => k + 1) : [...new Set(nums)];
|
|
2993
|
+
if (agent) {
|
|
2994
|
+
targets = list.map((it, k) => (matchesAgent(it, agent) ? k + 1 : 0)).filter(Boolean);
|
|
2995
|
+
if (!targets.length) return say(`Nothing waiting from "${agent}". ${mark('/approvals agents')} shows who is asking.`, 'warn');
|
|
2996
|
+
}
|
|
2997
|
+
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 = [];
|
|
2999
|
+
const heldDecisions = []; const done = []; const heldPriv = []; const failed = []; const missing = []; const gone = [];
|
|
3000
|
+
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
|
+
for (const k of targets) {
|
|
3002
|
+
const it = list[k - 1];
|
|
3003
|
+
if (!it) { missing.push(k); continue; }
|
|
3004
|
+
if (isDecision(it)) {
|
|
3005
|
+
if (all || agent) { heldDecisions.push(k); continue; } // a bulk approve never answers a human question for you
|
|
3006
|
+
if (verb === 'approve' && bulkish && isHeldFromBulk(it)) { heldRisky.push(k); continue; } // risky asks are never part of a bulk yes
|
|
3007
|
+
if (verb === 'approve' && !bulkish && it.payload.options.some(o => o.key === 'y')) say(`Answering: ${it.title || it.id} → ${it.payload.options.find(o => o.key === 'y').label} (y)`, 'dim');
|
|
3008
|
+
if (verb === 'approve' && !it.payload.options.some(o => o.key === 'y')) { failed.push(`[${k}] is a question, not yes/no: /answer ${k} ${it.payload.options.map(o => o.key).join('|')} ${it.payload.options.map(o => `(${o.key}) ${o.label}`).join(' ')}`); continue; }
|
|
3009
|
+
}
|
|
3010
|
+
if (verb === 'approve' && privOf(it) && !(confirm && !all && !agent)) { heldPriv.push(k); continue; }
|
|
3011
|
+
try {
|
|
3012
|
+
const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
|
|
3013
|
+
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) { failed.push(`[${k}] ${e.message}`); }
|
|
3016
|
+
}
|
|
3017
|
+
if (done.length || gone.length || failed.length || missing.length) say(' ');
|
|
3018
|
+
for (const k of done) say(`${verb === 'approve' ? '✔ approved' : '✔ denied'} ${label(k)}${reason ? ` (reason: ${clip(reason, 40)})` : ''}`, 'ok');
|
|
3019
|
+
if (gone.length) say(`already handled: ${gone.map(k => `[${k}]`).join(' ')}`, 'accent');
|
|
3020
|
+
if (done.length > 1) say(`${verb === 'approve' ? 'Approved' : 'Denied'}: ${done.map(k => `[${k}]`).join(' ')}`, 'ok');
|
|
3021
|
+
if (missing.length) say(`No such item: ${missing.map(k => `[${k}]`).join(' ')} (${list.length} listed).`, 'warn');
|
|
3022
|
+
for (const f of [...new Set(failed)]) dispatch({ type: 'error', text: `approval: ${f}` });
|
|
3023
|
+
for (const k of done) { // quox ask: tell the asking session on its slot, as /answer does
|
|
3024
|
+
const it = list[k - 1];
|
|
3025
|
+
if (isDecision(it) && it.payload.slot && api.slotSay) await api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was ${verb === 'approve' ? 'answered: y' : `declined${reason ? ` (${reason})` : ''}`}. Read it with: quox ask status ${it.id}`, { from: myLabel() });
|
|
3026
|
+
}
|
|
3027
|
+
if (done.length || gone.length || failed.length || missing.length) say(' ');
|
|
3028
|
+
const letteredHeld = heldDecisions.filter(k => !list[k - 1].payload.options.some(o => o.key === 'y'));
|
|
3029
|
+
const riskyYn = verb === 'approve' ? heldDecisions.filter(k => !letteredHeld.includes(k) && isHeldFromBulk(list[k - 1])) : [];
|
|
3030
|
+
const walkKs = [...new Set([...letteredHeld, ...riskyYn, ...heldRisky])].sort((a, b) => a - b);
|
|
3031
|
+
const ynShown = heldDecisions.filter(k => !letteredHeld.includes(k) && !riskyYn.includes(k));
|
|
3032
|
+
const ynHeld = verb === 'approve' && all && !agent ? ynShown : [];
|
|
3033
|
+
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
|
+
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
|
+
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 };
|
|
3037
|
+
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
|
+
}
|
|
3039
|
+
if (walkKs.length) {
|
|
3040
|
+
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
|
|
3042
|
+
}
|
|
3043
|
+
if (heldPriv.length && (all || agent)) say(`${heldPriv.length} privileged left: ${heldPriv.map(k => `${cmd} ${k} confirm`).join(', ')}`, 'warn');
|
|
3044
|
+
else if (heldPriv.length) { // ask inline instead of sending the user to a second command
|
|
3045
|
+
R.current.pendingGrant = { cmd, verb, items: heldPriv.map(k => list[k - 1]), at: Date.now() };
|
|
3046
|
+
for (const k of heldPriv) {
|
|
3047
|
+
const it = list[k - 1]; const c = it.payload?.approval_context || {};
|
|
3048
|
+
const tools = [].concat(c.elevated_tools || c.tools || it.payload?.tools || c.method?.target || []).filter(Boolean).join(', ');
|
|
3049
|
+
say(`Grant ${clip(it.title || it.id, cols - 30)} (privileged)${tools ? `: elevated tools ${tools}` : ''}${c.on_behalf_of?.conversation_id ? ' for that conversation' : ''}`, 'warn');
|
|
3050
|
+
}
|
|
3051
|
+
ask(`Grant ${heldPriv.length === 1 ? 'it' : `all ${heldPriv.length}`}?`, { who: heldPriv.map(k => approvalSourceLabel(list[k - 1])).filter(Boolean)[0] || '', action: `${cmd} ${heldPriv.join(' ')} confirm`, reason: 'privileged: it grants elevated tools.', keys: 'y grants, anything else cancels' });
|
|
3052
|
+
}
|
|
875
3053
|
return undefined;
|
|
876
3054
|
}
|
|
877
3055
|
default: {
|
|
878
3056
|
const e = findEntry(cmd);
|
|
879
3057
|
if (e && e.status === 'planned') return say(`${e.usage} is planned, not built yet. /help shows what works today.`, 'warn');
|
|
880
|
-
|
|
3058
|
+
if (!e && await slotNameCommand(cmd, restArg)) return undefined;
|
|
3059
|
+
const cliHit = resolveCliSlash(cmd, restArg); // QUOXON-FW: slash words that name a real quox CLI leaf
|
|
3060
|
+
if (cliHit) return cliSlashRun(cliHit);
|
|
3061
|
+
const loose = looseCommand(cmd, restArg);
|
|
3062
|
+
if (loose?.run) { say(`ran ${loose.name}`, 'dim'); return command(loose.run); }
|
|
3063
|
+
if (loose?.suggest) return say(`Unknown command ${cmd}. Did you mean ${loose.suggest}? (/help lists them)`, 'warn');
|
|
3064
|
+
if (loose?.ask) { R.current.askQuox = { text: loose.ask, at: Date.now() }; return ask('Ask Quox instead?', { action: `send as a chat turn: ${loose.ask}`, reason: 'Not a command.' }); }
|
|
3065
|
+
const near = nearestCommands(cmd);
|
|
3066
|
+
if (!near.length) return say(`Unknown command ${cmd}. Try /help`, 'warn');
|
|
3067
|
+
// spelling-only hints keep the plain "/a or /b" line; keyword hints say what each command does
|
|
3068
|
+
const byWords = near.some(x => x.kw);
|
|
3069
|
+
say(`Unknown command ${cmd}. Did you mean ${byWords ? near.map(x => (x.desc ? `${x.name} (${x.desc})` : x.name)).join(', ') : near.map(x => x.name).join(' or ')}? (/help lists them)`, 'warn');
|
|
3070
|
+
if (near.length === 1) { // one strong match: offer it as a y/N card (vetted, never a human-typed command)
|
|
3071
|
+
const v = vetSuggestion(near[0].name);
|
|
3072
|
+
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.' }); }
|
|
3073
|
+
}
|
|
3074
|
+
return undefined;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
// Closest slots (with host) and agents for a mistyped name, each labelled which is which.
|
|
3080
|
+
async function nearbyTargets(name, slots, agents = null) {
|
|
3081
|
+
let ag = agents;
|
|
3082
|
+
if (!ag && api.agents) { try { ag = await api.agents(); } catch { ag = null; } }
|
|
3083
|
+
const out = [];
|
|
3084
|
+
const sl = Array.isArray(slots) ? slots : [];
|
|
3085
|
+
for (const n of nearest(name, sl.map(x => x.name))) {
|
|
3086
|
+
const row = sl.find(x => x.name === n);
|
|
3087
|
+
const host = row?.holder_identity ? String(row.holder_identity).replace(/^resident:/, '') : (row?.host_name || row?.hostname || null); // never host_id: an internal id
|
|
3088
|
+
out.push(`slot ${n}${host ? ` (${host})` : ''}`);
|
|
3089
|
+
}
|
|
3090
|
+
const ids = (Array.isArray(ag) ? ag : []).map(a => String(a?.agent_id ?? a?.id ?? a?.name ?? ''));
|
|
3091
|
+
for (const n of nearest(name, ids)) out.push(`agent ${n}`);
|
|
3092
|
+
return out;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
// QUOXON-ASK y/N/c (owner 2026-10-15 "i need y/n/chat more"): the open composer asks, each with its own answer
|
|
3096
|
+
// handler below. An ask counts as open while its 2-minute window runs.
|
|
3097
|
+
const ASK_TTL = 120000;
|
|
3098
|
+
function openAskStates() {
|
|
3099
|
+
const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
|
|
3100
|
+
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);
|
|
3102
|
+
}
|
|
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?.(); }
|
|
3105
|
+
// ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): each live ask card is
|
|
3106
|
+
// bound to the open state it asks about; once that state is no longer open the card goes and ONE line stays:
|
|
3107
|
+
// "✔ Wake it now? yes (00:44)", "⌛ ... expired, not answered", "↷ ... no longer open". Run requests likewise.
|
|
3108
|
+
const askBind = React.useRef(new Map()); // card id -> its state object | 'kc' | 'uw' | 'any'
|
|
3109
|
+
function askStateOpen(st) {
|
|
3110
|
+
if (st === 'kc') return Boolean(KC.current.pending?.());
|
|
3111
|
+
if (st === 'uw') return Boolean(UW.current.pending?.());
|
|
3112
|
+
if (st === 'any') return askOpen();
|
|
3113
|
+
return openAskStates().includes(st);
|
|
3114
|
+
}
|
|
3115
|
+
function settleRun(rq, outcome, now = Date.now()) {
|
|
3116
|
+
SC.current.runCards?.delete(rq.askId);
|
|
3117
|
+
dispatch({ type: 'slotmsg.settle', askId: rq.askId, text: settledAskLine(`Run ${clip(String(rq.cmd || ''), Math.max(20, cols - 40))}?`, outcome, now), tone: settledAskTone(outcome.kind) });
|
|
3118
|
+
}
|
|
3119
|
+
function reconcileAsks() {
|
|
3120
|
+
const now = Date.now();
|
|
3121
|
+
const takeAnswer = () => { const a = R.current.askAnswer; R.current.askAnswer = null; return a && now - a.at < 3000 ? a : null; };
|
|
3122
|
+
const bind = askBind.current;
|
|
3123
|
+
const cards = [...(S.current.asks || [])].reverse(); // newest first: an answer belongs to the newest card it closes
|
|
3124
|
+
for (const cd of cards) {
|
|
3125
|
+
if (!bind.has(cd.id)) {
|
|
3126
|
+
const taken = new Set(bind.values());
|
|
3127
|
+
const st = openAskStates().filter(x => !taken.has(x)).sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0];
|
|
3128
|
+
bind.set(cd.id, st || (KC.current.pending?.() ? 'kc' : UW.current.pending?.() ? 'uw' : 'any'));
|
|
3129
|
+
}
|
|
3130
|
+
const st = bind.get(cd.id);
|
|
3131
|
+
if (askStateOpen(st)) continue;
|
|
3132
|
+
bind.delete(cd.id);
|
|
3133
|
+
const expired = st && typeof st === 'object' ? now - (st.at ?? 0) >= ASK_TTL : false;
|
|
3134
|
+
if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
|
|
3135
|
+
takeAnswer();
|
|
3136
|
+
if (st.walkWhy === 'stop') dispatch({ type: 'ask.settle', id: cd.id, text: 'The question closed before you answered.', tone: 'plain' });
|
|
3137
|
+
else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
|
|
3138
|
+
continue;
|
|
881
3139
|
}
|
|
3140
|
+
const outcome = expired ? { kind: 'expired' } : takeAnswer() || { kind: st === 'any' || st === 'kc' || st === 'uw' ? 'expired' : 'closed' };
|
|
3141
|
+
dispatch({ type: 'ask.settle', id: cd.id, text: settledAskLine(askQuestion(cd.text), outcome, now), tone: settledAskTone(outcome.kind) });
|
|
3142
|
+
}
|
|
3143
|
+
for (const [id, rq] of [...(SC.current.runCards || new Map())]) {
|
|
3144
|
+
const live = SC.current.runAsk?.askId === id;
|
|
3145
|
+
if (live && runAskOpen()) continue;
|
|
3146
|
+
const expired = rq.expiresAt != null && (runNow() >= rq.expiresAt || now >= rq.expiresAt);
|
|
3147
|
+
const outcome = expired ? { kind: 'expired' } : (!live && takeAnswer()) || { kind: 'closed' };
|
|
3148
|
+
settleRun(rq, outcome, now);
|
|
882
3149
|
}
|
|
883
3150
|
}
|
|
3151
|
+
React.useEffect(() => {
|
|
3152
|
+
const t = setInterval(() => { if ((S.current.asks || []).length || SC.current.runCards?.size) reconcileAsks(); }, 1000);
|
|
3153
|
+
t.unref?.();
|
|
3154
|
+
return () => clearInterval(t);
|
|
3155
|
+
}, []);
|
|
3156
|
+
/** c or free text while an ask is open: keep it open, send a chat turn about it, and show the same ask again after. */
|
|
3157
|
+
function chatAboutAsk(text) {
|
|
3158
|
+
const la = R.current.lastAsk;
|
|
3159
|
+
const q = la?.question || 'the question above';
|
|
3160
|
+
touchAsks();
|
|
3161
|
+
R.current.reask = la || null;
|
|
3162
|
+
const msg = askChatMessage(text, q);
|
|
3163
|
+
touchSeen(); dispatch({ type: 'user', text: /^c(hat)?$/i.test(text.trim()) ? msg : text.trim() }); remember(text); R.current.histIdx = -1;
|
|
3164
|
+
runTurn(msg);
|
|
3165
|
+
}
|
|
884
3166
|
|
|
885
3167
|
function onEnter(now) {
|
|
886
3168
|
if (now - K.current.lastPasteAt < PASTE_GUARD_MS) return; // post-paste guard
|
|
887
3169
|
if (now - K.current.cardGoneAt < CARD_GONE_MS) return; // keys meant for the card must not send a turn
|
|
888
|
-
|
|
3170
|
+
let buf = B.current;
|
|
3171
|
+
// y/N/c: Enter alone answers an open ask "no" (the default), as typing n does.
|
|
3172
|
+
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
|
+
if (!buf.text.trim() && !R.current.denyReason && !S.current.turn && askOpen()) { B.current = { ...initialBuffer(), text: 'n', cursor: 1 }; buf = B.current; }
|
|
889
3174
|
if (buf.text.endsWith('\\')) { edit({ type: 'set', text: buf.text.slice(0, -1) + '\n', keepPastes: true }); return; }
|
|
890
3175
|
if (!buf.text.trim()) {
|
|
3176
|
+
if (R.current.noteAsk && Date.now() - R.current.noteAsk.at < 300000) { const st = R.current.noteAsk; R.current.noteAsk = null; noteResolve('1', st); return; } // Enter on the note card = the recommended choice
|
|
3177
|
+
if (R.current.denyReason) { const d = R.current.denyReason; R.current.denyReason = null; if (Date.now() - d.at < 120000) { answer(d.card, 'reject'); return; } } // Enter on "Reason?" skips it
|
|
891
3178
|
const s = S.current;
|
|
892
|
-
if (!s.turn && s.pending.length) { dispatch({ type: 'queue.shift' });
|
|
3179
|
+
if (!s.turn && s.pending.length) { dispatch({ type: 'queue.shift' }); runQueued(s.pending[0].text); }
|
|
893
3180
|
return;
|
|
894
3181
|
}
|
|
895
3182
|
const chk = sanitizeForSend(buf);
|
|
896
3183
|
if (!chk.ok) { B.current = { ...chk.buffer, undo: buf.undo, redo: buf.redo }; say(chk.note, 'warn'); bump(n => n + 1); return; }
|
|
897
|
-
|
|
3184
|
+
let text = chk.text.trim();
|
|
3185
|
+
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'); }
|
|
898
3187
|
const keyHit = looksLikeKey(text);
|
|
899
3188
|
if (keyHit) { B.current = initialBuffer(); R.current.confirmMulti = null; say(keyWarning(keyHit), 'warn'); return; } // never sent, never in history
|
|
3189
|
+
if (text.startsWith('!') && text.includes('\n')) { // owner 2026-10-15: a wrapped `!git ... push origin\nHEAD:main` ran as two commands
|
|
3190
|
+
const joined = text.split('\n').map(l => l.trim()).filter(Boolean).join(' ');
|
|
3191
|
+
B.current = { ...initialBuffer(), text: joined, cursor: joined.length }; R.current.confirmMulti = null;
|
|
3192
|
+
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
|
+
bump(n => n + 1); return;
|
|
3194
|
+
}
|
|
3195
|
+
const bang = parseBang(text);
|
|
3196
|
+
if (bang) { // QUOXON-FW8: the user's own command on their own machine; never a turn, never the server
|
|
3197
|
+
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; }
|
|
3198
|
+
B.current = initialBuffer(); R.current.confirmMulti = null; remember(text); R.current.histIdx = -1;
|
|
3199
|
+
echoCmd(text); bangCommand(bang, { echoed: true }); bump(n => n + 1); return;
|
|
3200
|
+
}
|
|
900
3201
|
const s = S.current;
|
|
901
3202
|
const isCommand = text.startsWith('/') && !text.includes('\n');
|
|
902
|
-
|
|
903
|
-
if (text.startsWith('
|
|
904
|
-
|
|
905
|
-
|
|
3203
|
+
const ws = R.current.wokeSwallowed; R.current.wokeSwallowed = null;
|
|
3204
|
+
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
|
+
B.current = { ...initialBuffer(), text, cursor: text.length };
|
|
3206
|
+
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
|
+
}
|
|
3208
|
+
if (blocked && !isCommand) { // Z2H-R4: clear the box (else the next /login is appended and refused); Up recalls the message
|
|
3209
|
+
remember(text); R.current.histIdx = -1; B.current = initialBuffer(); say(blocked, 'warn'); bump(n => n + 1); return;
|
|
3210
|
+
}
|
|
3211
|
+
if (text.startsWith('/') && text.includes('\n') && !R.current.confirmMulti) {
|
|
3212
|
+
// commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter.
|
|
3213
|
+
// The second Enter sends it even after edits (R3 D3: requiring a byte-identical draft swallowed every later Enter).
|
|
3214
|
+
R.current.confirmMulti = true;
|
|
906
3215
|
if (!R.current.multiWarned) {
|
|
907
3216
|
R.current.multiWarned = true;
|
|
908
3217
|
say('That draft starts with / but has several lines, so it is not a command. Press Enter again to send it to Quox as chat, or edit it (Esc Esc clears it).', 'warn');
|
|
@@ -911,7 +3220,204 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
911
3220
|
}
|
|
912
3221
|
R.current.confirmMulti = null; R.current.multiWarned = false;
|
|
913
3222
|
B.current = initialBuffer();
|
|
914
|
-
|
|
3223
|
+
K.current.composerClicked = false;
|
|
3224
|
+
if (R.current.handoffAsk?.bigger && /^(y(es)?|no?|c|keep)$/i.test(text.trim())) { // "Switch this window back to CommanderQ?" y switch / n just this one / c keep asking
|
|
3225
|
+
const a = R.current.handoffAsk; R.current.handoffAsk = null;
|
|
3226
|
+
const t = text.trim().toLowerCase();
|
|
3227
|
+
const kind = t[0] === 'y' ? 'yes' : t[0] === 'n' ? 'no' : 'dismissed';
|
|
3228
|
+
R.current.askAnswer = { kind, at: Date.now() }; queueMicrotask(() => reconcileAsks());
|
|
3229
|
+
const hs = R.current.handoffStreak;
|
|
3230
|
+
if (Date.now() - a.at >= 120000) { say('That offer expired. Nothing changed.', 'dim'); return; }
|
|
3231
|
+
if (kind === 'yes') { hs?.clear(); echoCmd('/detach'); command('/detach'); return; }
|
|
3232
|
+
if (kind === 'no') { hs?.restart(); runHandoff(a); return; }
|
|
3233
|
+
hs?.mute(); say(`Staying with ${a.h.fromName}.`, 'dim'); return;
|
|
3234
|
+
}
|
|
3235
|
+
// ASK-SETTLE: an answer is remembered; the card it closes settles to one line once this line is handled.
|
|
3236
|
+
let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
|
|
3237
|
+
if (askOpen() && !R.current.noteAsk) {
|
|
3238
|
+
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'];
|
|
3240
|
+
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
|
+
if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
|
|
3242
|
+
else if (o) {
|
|
3243
|
+
askDismissed = o.kind === 'dismissed';
|
|
3244
|
+
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
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
// y/N/c: c or any other free text keeps the open ask and asks Quox about it first (never a silent cancel).
|
|
3249
|
+
if (R.current.noteAsk) { // NOTE-CONFIRM: the line is the pick (1, 2, 3); anything else leaves the card and nothing was sent
|
|
3250
|
+
const st = R.current.noteAsk; R.current.noteAsk = null;
|
|
3251
|
+
const pick = ({ 1: '1', 2: '2', 3: '3', y: '1', yes: '1', n: '2', no: '2', e: '3', edit: '3' })[text.trim().toLowerCase()];
|
|
3252
|
+
if (Date.now() - st.at < 300000 && pick) { noteResolve(pick, st); return; }
|
|
3253
|
+
noteResolve('3', st); // anything else backs out: the whole answer is put back in the composer, never dropped
|
|
3254
|
+
say('Not sent. Your answer and note are back in the composer; press Enter to send it, or edit it first.', 'warn');
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
3257
|
+
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
|
+
if (!askDismissed && !S.current.turn && !noAiNow() && askAnswer(text) === 'chat' && askOpen() && !(runAskOpen() && /^run$/i.test(text.trim()))) { chatAboutAsk(text); return; }
|
|
3259
|
+
if (KC.current.answer(text)) return; // y/n after a /watch, /automate or /crystallize preview card
|
|
3260
|
+
if (UW.current.answer(text)) return; // y/n after a /undo or /wish card
|
|
3261
|
+
if (HW.current.active() && HW.current.answer(text)) return; // CUSTOM-HATS: the next line of a /hat new|edit|delete walk
|
|
3262
|
+
if (RW.current.walk.active() && RW.current.walk.answer(text)) return; // ROWS: the next line of a /row new|edit walk
|
|
3263
|
+
if (R.current.denyReason) { // the n card asked "Reason? (Enter to skip)": this one line is the reason
|
|
3264
|
+
const d = R.current.denyReason; R.current.denyReason = null;
|
|
3265
|
+
if (Date.now() - d.at < 120000 && !text.startsWith('/')) { answer(d.card, 'reject', { reason: text.trim() || null }); return; }
|
|
3266
|
+
}
|
|
3267
|
+
if (R.current.pendingGrant) { // /approve N on a privileged item asked "y/N": this one line is the answer
|
|
3268
|
+
const g = R.current.pendingGrant; R.current.pendingGrant = null;
|
|
3269
|
+
if (Date.now() - g.at < 120000 && !text.startsWith('/')) {
|
|
3270
|
+
if (/^y(es)?$/i.test(text.trim())) {
|
|
3271
|
+
(async () => {
|
|
3272
|
+
if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it)); // defence: a risky ask never rides the bulk card
|
|
3273
|
+
if (g.decisions && g.hi) { // one PIN for the whole batch; the token then rides on every item
|
|
3274
|
+
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
|
+
if (!r.ok) return say(`Not answered: ${declinedText(r.reason)}. Nothing was sent.`, 'warn');
|
|
3276
|
+
}
|
|
3277
|
+
for (const it of g.items) {
|
|
3278
|
+
if (g.decisions) {
|
|
3279
|
+
try {
|
|
3280
|
+
const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: null, tool: clip(it.title || it.id, cols - 40), target: null, raisedBy: it.payload.session || null, risk: it.payload.high_risk ? 'high' : null }, 'approve', { batch: true, bulk: true });
|
|
3281
|
+
walkAnsweredIds().add(it.id); // answered (or already handled): a walk never shows it again
|
|
3282
|
+
if (r?.gone) say(`already handled: ${clip(it.title || it.id, cols - 30)}`, 'accent'); else say(`✔ answered yes: ${clip(it.title || it.id, cols - 24)}`, 'ok');
|
|
3283
|
+
if (r?.ok && it.payload.slot && api.slotSay) await api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was answered: y. Read it with: quox ask status ${it.id}`, { from: myLabel() });
|
|
3284
|
+
} catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); if (e?.stepUpDeclined) return; }
|
|
3285
|
+
continue;
|
|
3286
|
+
}
|
|
3287
|
+
try { await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: clip(it.title || it.id, cols - 40), target: null }, g.verb); say(`✔ Granted ${clip(it.title || it.id, cols - 20)}`, 'ok'); }
|
|
3288
|
+
catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); }
|
|
3289
|
+
}
|
|
3290
|
+
if (g.thenWalk) walkStart(g.thenWalk); // WALK-FIXES: the ONE start, once the yeses are in (it used to start here AND straight away: two cards)
|
|
3291
|
+
})();
|
|
3292
|
+
} else { say('Cancelled; nothing granted.', 'dim'); if (g.thenWalk) { const w = g.thenWalk; setTimeout(() => walkStart(w), 0); } }
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
if (R.current.feedbackAsk) { // /feedback showed the exact payload and asked "Send it? y/N"
|
|
3297
|
+
const a = R.current.feedbackAsk; R.current.feedbackAsk = null;
|
|
3298
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
|
|
3299
|
+
say('Sending to quox.ai…', 'dim');
|
|
3300
|
+
(async () => {
|
|
3301
|
+
const r = await api.feedback(a.payload, { version });
|
|
3302
|
+
if (!r?.ok) say(`Feedback not sent: ${r?.message || 'failed'}`, 'warn');
|
|
3303
|
+
else say(`✔ Sent to the Quox team at quox.ai${r.id != null ? ` (feedback #${r.id})` : ''}. Thank you.`, 'ok');
|
|
3304
|
+
})();
|
|
3305
|
+
return;
|
|
3306
|
+
}
|
|
3307
|
+
if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
|
|
3308
|
+
}
|
|
3309
|
+
if (R.current.wakeAsk) { // "<slot> hasn't replied ... Wake it now? y/N/c": y pokes it (/poke = an urgent page that wakes it)
|
|
3310
|
+
const a = R.current.wakeAsk; R.current.wakeAsk = null;
|
|
3311
|
+
if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { echoCmd(`/poke ${a.slot}`); command(`/poke ${a.slot}`); return; }
|
|
3312
|
+
if (!text.startsWith('/')) { say('Not woken.', 'dim'); return; }
|
|
3313
|
+
}
|
|
3314
|
+
if (R.current.profileAsk) { // PROFILE-SWITCH "Switch? y/N/c": only y switches
|
|
3315
|
+
const a = R.current.profileAsk; R.current.profileAsk = null;
|
|
3316
|
+
if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
|
|
3317
|
+
if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
|
|
3318
|
+
}
|
|
3319
|
+
if (R.current.slotCreateAsk) { // "No slot X. Create it? y/N/c" from a /slot near miss: y runs /slot X --create
|
|
3320
|
+
const a = R.current.slotCreateAsk; R.current.slotCreateAsk = null;
|
|
3321
|
+
if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { const l = `/slot ${a.nm} --create`; echoCmd(l); command(l); return; }
|
|
3322
|
+
if (!text.startsWith('/')) { say('No slot created.', 'dim'); return; }
|
|
3323
|
+
}
|
|
3324
|
+
if (R.current.slotAsk) { // "Take it over? y/N" from /slot
|
|
3325
|
+
const a = R.current.slotAsk; R.current.slotAsk = null;
|
|
3326
|
+
if (/^y(es)?$/i.test(text.trim())) { takeSlot(a.nm, true); return; }
|
|
3327
|
+
if (!text.startsWith('/')) { say('Left it alone.', 'dim'); return; }
|
|
3328
|
+
}
|
|
3329
|
+
if (CH.current.answer(text) || SPN.current.answer(text)) return; // /challenge: "Resolve these? y/N"
|
|
3330
|
+
if (R.current.updateAsk) { // /update: "Update now and restart? y/N/c"; y hands the terminal to the update, then restarts
|
|
3331
|
+
const a = R.current.updateAsk; R.current.updateAsk = null;
|
|
3332
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
|
|
3333
|
+
if (S.current.turn) { say('A turn is running. Wait for it, then /update.', 'warn'); return; }
|
|
3334
|
+
say(`Updating: ${a.plan.command.display} runs in this terminal, then quox restarts into this conversation.`, 'dim');
|
|
3335
|
+
doRestart({ update: a.plan }); return;
|
|
3336
|
+
}
|
|
3337
|
+
if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
|
|
3338
|
+
}
|
|
3339
|
+
if (R.current.cliAsk) { // QUOXON-FW: a slash word named a WRITE quox command: "Run it? y/N" (typing y is the authorization)
|
|
3340
|
+
const a = R.current.cliAsk; R.current.cliAsk = null;
|
|
3341
|
+
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; }
|
|
3342
|
+
if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
|
|
3343
|
+
}
|
|
3344
|
+
if (R.current.hatPending && !text.startsWith('/')) { // "Put on the NSOC hat? y/N" from /hat: y switches (/hat yes still works)
|
|
3345
|
+
const a = R.current.hatPending;
|
|
3346
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/hat yes'); return; }
|
|
3347
|
+
R.current.hatPending = null; say('Hat not changed.', 'dim'); return;
|
|
3348
|
+
}
|
|
3349
|
+
if (R.current.orgPending && !text.startsWith('/')) { // "Switch to X? y/N" from /org switch: y switches (/org yes still works)
|
|
3350
|
+
const a = R.current.orgPending;
|
|
3351
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/org yes'); return; }
|
|
3352
|
+
R.current.orgPending = null; say(`Not switched. Still in ${R.current.orgName ?? org ?? 'the same org'}.`, 'dim'); return;
|
|
3353
|
+
}
|
|
3354
|
+
if (R.current.suggestAsk) { // "Run /slot hitl? y/N" from terminal_suggest_command: y runs it as if typed (normal command path)
|
|
3355
|
+
const a = R.current.suggestAsk; R.current.suggestAsk = null;
|
|
3356
|
+
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
|
+
if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
|
|
3358
|
+
}
|
|
3359
|
+
if (R.current.handoffAsk) { // "Hand this question to CommanderQ?" (yes or no): yes asks it once, the attach stays
|
|
3360
|
+
const a = R.current.handoffAsk; R.current.handoffAsk = null;
|
|
3361
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runHandoff(a); return; }
|
|
3362
|
+
if (!text.startsWith('/')) { say('Not handed over.', 'dim'); return; }
|
|
3363
|
+
}
|
|
3364
|
+
if (R.current.planAsk) { // PLAN MODE: "Run this plan now? y/N/c": y switches back to ask and sends the go-ahead
|
|
3365
|
+
const a = R.current.planAsk; R.current.planAsk = null;
|
|
3366
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { MD.current = 'ask'; bump(n => n + 1); say('mode ask first: running the plan.', 'dim'); submit(PLAN_GO); return; }
|
|
3367
|
+
if (!text.startsWith('/')) { say('Not run. Still plan only (/mode ask to switch).', 'dim'); return; }
|
|
3368
|
+
}
|
|
3369
|
+
if (R.current.strayAsk) { // TUI-INPUT: "Did you mean /help? y/N/c" before a queued "n/help" is sent: y runs the command, n sends it as typed
|
|
3370
|
+
const a = R.current.strayAsk; R.current.strayAsk = null;
|
|
3371
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { echoCmd(a.command); command(a.command); return; }
|
|
3372
|
+
if (/^no?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runQueued(a.text, { checked: true }); return; }
|
|
3373
|
+
}
|
|
3374
|
+
if (R.current.askQuox) { // an unknown "/words ..." offered to ask Quox: "y/N"
|
|
3375
|
+
const a = R.current.askQuox; R.current.askQuox = null;
|
|
3376
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { submit(a.text); return; }
|
|
3377
|
+
if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
|
|
3378
|
+
}
|
|
3379
|
+
if (SC.current.pendingFile) { // /send found secret-shaped values: "y/N" (send redacted)
|
|
3380
|
+
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; }
|
|
3382
|
+
if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
|
|
3383
|
+
}
|
|
3384
|
+
if (SC.current.reattach) { // startup "Reattach to slot X? y/N"
|
|
3385
|
+
const ra = SC.current.reattach; SC.current.reattach = null;
|
|
3386
|
+
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; }
|
|
3388
|
+
}
|
|
3389
|
+
if (SC.current.runAsk) { // SLOT-RUN: "Run it? y/N/c" (or "run" for a dangerous one) on a slot sender's command
|
|
3390
|
+
const ra = SC.current.runAsk;
|
|
3391
|
+
const v = runAnswer(text, ra);
|
|
3392
|
+
if (runNow() >= ra.expiresAt) { // runNow: the run-ask clock (a prop so a test can move it 10 minutes without freezing Date.now)
|
|
3393
|
+
SC.current.runAsk = null;
|
|
3394
|
+
if (v === 'run' || v === 'needs-run' || v === 'no') { say(`That run request from ${ra.who} expired (run requests last 10 minutes). Not run; ask them to send it again.`, 'warn'); return; }
|
|
3395
|
+
} else if (v === 'run') { SC.current.runAsk = null; runSlotCommand(ra); return; }
|
|
3396
|
+
else if (v === 'needs-run') { say(`Not run yet. ${ra.danger}, so type run to run it, or n to skip.`, 'warn'); return; }
|
|
3397
|
+
else if (v === 'no') { SC.current.runAsk = null; transcript?.bang?.(ra.cmd, 'declined'); say('Not run.', 'dim'); return; }
|
|
3398
|
+
}
|
|
3399
|
+
if (text.startsWith('/') && !text.includes('\n')) { remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
|
|
3400
|
+
const roomSend = api.slotSay ? parseRoomSend(text) : null;
|
|
3401
|
+
if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
|
|
3402
|
+
remember(text); R.current.histIdx = -1;
|
|
3403
|
+
if (roomSend.text) postRoom(roomSend.room, roomSend.text); else setActiveRoom(roomSend.room);
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
if (SC.current.active && text.trim() === '#') { SC.current.active = null; say('Back to Quox: plain lines go to the model again.', 'ok'); return; }
|
|
3407
|
+
if (SC.current.active && api.slotSay && !text.startsWith('!') && !text.includes('\n')) { // default room target
|
|
3408
|
+
remember(text); R.current.histIdx = -1;
|
|
3409
|
+
postRoom(SC.current.active, text.trim()); return;
|
|
3410
|
+
}
|
|
3411
|
+
const atSend = parseAtSend(text);
|
|
3412
|
+
if (atSend && api.slotSay) { // QUOXON-MESH: @slot text goes straight to the slot, no model turn, even mid-turn
|
|
3413
|
+
remember(text); R.current.histIdx = -1;
|
|
3414
|
+
sendToSlot(atSend.slot, atSend.text); return;
|
|
3415
|
+
}
|
|
3416
|
+
if (R.current.askArmed && !s.turn) { // /dragon ask with no question: this one line goes to the dragon
|
|
3417
|
+
R.current.askArmed = false; R.current.dragonTalk = null; touchSeen(); dispatch({ type: 'user', text });
|
|
3418
|
+
remember(text); R.current.histIdx = -1; dragonTurn(DRAGON_ANSWERS, askPrompt(text)); return;
|
|
3419
|
+
}
|
|
3420
|
+
if (s.turn && alreadyQueued(text, { pending: s.pending, steering: s.steering, waiting: R.current.waitingServer })) { say('Already queued: that exact message is waiting to send.', 'dim'); return; } // TUI-INPUT: Enter again on the same queued text adds no copy
|
|
915
3421
|
if (s.turn && !R.current.caps.steer) { // steering already refused for this turn: queue, say so once
|
|
916
3422
|
dispatch({ type: 'queue.add', text });
|
|
917
3423
|
if (!R.current.steerNoted) { R.current.steerNoted = true; say(STEER_OFF_NOTE, 'warn'); }
|
|
@@ -921,28 +3427,157 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
921
3427
|
submit(text);
|
|
922
3428
|
}
|
|
923
3429
|
|
|
924
|
-
/**
|
|
3430
|
+
/**
|
|
3431
|
+
* QUOXON-FW15: y/n answer the shown card, but ONLY while the composer is empty (no draft, not mid-command). With a draft
|
|
3432
|
+
* they are ordinary text, which is what stops a stray keystroke approving. A privileged (or undescribable) card needs
|
|
3433
|
+
* two deliberate presses: y, then y again; any other key cancels. Returns true when the key was consumed.
|
|
3434
|
+
*/
|
|
3435
|
+
// ---- QUOXON-MOUSE ------------------------------------------------------------------------------
|
|
3436
|
+
/** Turn tracking on or off at the terminal (TTY only). On also arms an exit hook so a crash never leaves it on. */
|
|
3437
|
+
function mouseTerm(on) {
|
|
3438
|
+
const M = MO.current;
|
|
3439
|
+
if (!stdout?.isTTY || Boolean(on) === M.tty) return;
|
|
3440
|
+
try { stdout.write(on ? MOUSE_ON : MOUSE_OFF); } catch { /* closed */ }
|
|
3441
|
+
M.tty = Boolean(on);
|
|
3442
|
+
if (on && !M.exitFn) { M.exitFn = () => { try { writeSync(process.stdout.fd ?? 1, MOUSE_OFF); } catch { /* closed */ } }; process.once('exit', M.exitFn); }
|
|
3443
|
+
if (!on && M.exitFn) { process.removeListener('exit', M.exitFn); M.exitFn = null; }
|
|
3444
|
+
}
|
|
3445
|
+
/** What a run hand-over writes when it takes the terminal back (tracking was turned off for the child). */
|
|
3446
|
+
function mouseResume() { return MO.current.tty ? MOUSE_ON : ''; }
|
|
3447
|
+
/** The terminal's cursor row (DSR), or null when it does not answer in time. */
|
|
3448
|
+
function cursorRow() {
|
|
3449
|
+
return new Promise((res) => {
|
|
3450
|
+
const t = setTimeout(() => { MO.current.onCursor = null; res(null); }, DSR_TIMEOUT_MS);
|
|
3451
|
+
MO.current.onCursor = (row) => { clearTimeout(t); MO.current.onCursor = null; res(row); };
|
|
3452
|
+
try { stdout.write(DSR); } catch { clearTimeout(t); MO.current.onCursor = null; res(null); }
|
|
3453
|
+
});
|
|
3454
|
+
}
|
|
3455
|
+
/** -> what Ink drew under screen cell (x, y): { text, col, inFrame, frameRow, frameLines, rowsBelow } | null. */
|
|
3456
|
+
async function locate(x, y, row = null) {
|
|
3457
|
+
if (mouseScreen) return mouseScreen.at(x, y);
|
|
3458
|
+
const scr = stdout?.screen;
|
|
3459
|
+
if (!scr) return null;
|
|
3460
|
+
const cr = row ?? await cursorRow();
|
|
3461
|
+
if (cr == null) return null;
|
|
3462
|
+
const hit = screenHit(scr.snapshot(), cr, x, y);
|
|
3463
|
+
return hit ? { ...hit, cursorRow: cr } : null;
|
|
3464
|
+
}
|
|
3465
|
+
function onMouse(e) {
|
|
3466
|
+
if (!MO.current.on || e.button !== 'left') return;
|
|
3467
|
+
if (e.action !== 'press' && e.action !== 'drag' && e.action !== 'release') return;
|
|
3468
|
+
MO.current.chain = MO.current.chain.then(() => mouseEvent(e)).catch(() => {}); // in order: a drag never overtakes its press
|
|
3469
|
+
}
|
|
3470
|
+
async function mouseEvent(e) {
|
|
3471
|
+
const M = MO.current;
|
|
3472
|
+
if (R.current.closed || R.current.sleep) return;
|
|
3473
|
+
if (e.action === 'release') { M.drag = null; copySelection(); return; }
|
|
3474
|
+
if (e.action === 'drag') {
|
|
3475
|
+
if (!M.drag) return;
|
|
3476
|
+
const hit = await locate(e.x, e.y, M.drag.cursorRow);
|
|
3477
|
+
const idx = hit?.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
|
|
3478
|
+
if (idx != null) edit({ type: 'select', anchor: M.drag.anchor, cursor: idx });
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
// a modal card (PIN, sign-in, secure connect) takes keys only: a click never answers it
|
|
3482
|
+
if (SU.current.isOpen() || LI.current.isOpen() || CN.current.isOpen()) return;
|
|
3483
|
+
const hit = await locate(e.x, e.y);
|
|
3484
|
+
if (!hit) return;
|
|
3485
|
+
const now = Date.now();
|
|
3486
|
+
const idx = hit.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
|
|
3487
|
+
if (idx != null) {
|
|
3488
|
+
const dbl = M.last && now - M.last.at < DOUBLE_MS && M.last.x === e.x && M.last.y === e.y;
|
|
3489
|
+
M.last = dbl ? null : { at: now, x: e.x, y: e.y };
|
|
3490
|
+
if (dbl) { const w = wordAt(B.current.text, idx); edit({ type: 'select', anchor: w.from, cursor: w.to }); copySelection(); return; }
|
|
3491
|
+
edit({ type: 'place', index: idx }); K.current.composerClicked = true;
|
|
3492
|
+
M.drag = { anchor: B.current.cursor, cursorRow: hit.cursorRow ?? null };
|
|
3493
|
+
return;
|
|
3494
|
+
}
|
|
3495
|
+
M.last = null;
|
|
3496
|
+
const what = classifyClick(hit);
|
|
3497
|
+
if (!what) return;
|
|
3498
|
+
if (what.kind === 'sender') {
|
|
3499
|
+
if (!what.slot) return say(`${what.who} has no reply address (it did not send from a slot), so there is nothing to /say to.`, 'warn');
|
|
3500
|
+
const draft = B.current.text;
|
|
3501
|
+
const body = draft.startsWith('/say ') ? draft.replace(/^\/say\s+\S*\s?/, '') : draft;
|
|
3502
|
+
edit({ type: 'set', text: `/say ${what.slot} ${body}`, keepPastes: true });
|
|
3503
|
+
return;
|
|
3504
|
+
}
|
|
3505
|
+
if (what.kind === 'key') {
|
|
3506
|
+
if (B.current.text) return say('Your draft is not empty, so a click does not answer. Clear it, or press the key.', 'warn');
|
|
3507
|
+
if (what.source === 'approval') { handleEvents([{ type: 'text', text: what.key }], now, false); return; }
|
|
3508
|
+
if (!askOpen()) return;
|
|
3509
|
+
if (what.key === 'run' || (what.key === 'y' && runAskOpen())) return say('A click never runs a command. Press y or type run.', 'warn');
|
|
3510
|
+
handleEvents([{ type: 'text', text: what.key }, { type: 'key', name: 'enter' }], now, false);
|
|
3511
|
+
}
|
|
3512
|
+
return undefined;
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
/**
|
|
3516
|
+
* TUI-INPUT: a single y, n or c answers the open y/N/c ask (the "Reattach to slot X?" card and friends) exactly as
|
|
3517
|
+
* typing it and Enter does, and never reaches the composer. Only while the composer is empty and was not clicked;
|
|
3518
|
+
* a draft in progress keeps its keys (same rule as approval cards). Returns true when the key was consumed.
|
|
3519
|
+
*/
|
|
3520
|
+
function askKey(ch, now) {
|
|
3521
|
+
if (!ch || ch.length !== 1 || !/^[ync]$/i.test(ch)) return false;
|
|
3522
|
+
const k = K.current;
|
|
3523
|
+
if (B.current.text || k.composerClicked || R.current.sleep) return false;
|
|
3524
|
+
if (k.walkEndAt && now - k.walkEndAt < ASK_ECHO_MS && !askOpen() && !R.current.pendingWalk) { // a walk card just closed: a late y/n/c is its answer arriving after the walk, never a chat message
|
|
3525
|
+
B.current = { ...initialBuffer(), text: ch, cursor: 1 };
|
|
3526
|
+
k.askEcho = { ch, at: now }; clearTimeout(k.askEchoTimer);
|
|
3527
|
+
k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50); k.askEchoTimer.unref?.();
|
|
3528
|
+
bump(n => n + 1); return true;
|
|
3529
|
+
}
|
|
3530
|
+
if (!askOpen() || R.current.pendingWalk || R.current.denyReason || HW.current.active?.()) return false;
|
|
3531
|
+
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
|
+
B.current = { ...initialBuffer(), text: ch, cursor: 1 };
|
|
3533
|
+
onEnter(now);
|
|
3534
|
+
// The answer is already given. The key stays on screen for ASK_ECHO_MS as its echo; an Enter in that window is the
|
|
3535
|
+
// old "type y, press Enter" habit and is swallowed, and ANY other key wipes the echo first (so "n" + "/help" is never "n/help").
|
|
3536
|
+
B.current = { ...initialBuffer(), text: ch, cursor: 1 };
|
|
3537
|
+
k.askEcho = { ch, at: now };
|
|
3538
|
+
clearTimeout(k.askEchoTimer);
|
|
3539
|
+
k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50);
|
|
3540
|
+
k.askEchoTimer.unref?.();
|
|
3541
|
+
bump(n => n + 1);
|
|
3542
|
+
return true;
|
|
3543
|
+
}
|
|
3544
|
+
function endAskEcho() {
|
|
3545
|
+
const k = K.current; const ae = k.askEcho;
|
|
3546
|
+
k.askEcho = null; clearTimeout(k.askEchoTimer);
|
|
3547
|
+
if (ae && B.current.text === ae.ch) B.current = initialBuffer();
|
|
3548
|
+
}
|
|
3549
|
+
|
|
925
3550
|
function approvalKey(name, text, now) {
|
|
926
|
-
const card = S.current
|
|
3551
|
+
const card = headCard(S.current);
|
|
927
3552
|
if (!card) return false;
|
|
928
3553
|
const ch = text || '';
|
|
929
|
-
const
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
if (B.current.text && !
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
return true;
|
|
3554
|
+
const k = K.current;
|
|
3555
|
+
const g = k.guard;
|
|
3556
|
+
if (R.current.denyReason && Date.now() - R.current.denyReason.at < 120000) return false; // typing the deny reason: y/n are text
|
|
3557
|
+
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
|
+
const armedAt = Math.max(card.displayedAt ?? card.shownAt ?? 0, g?.since ?? 0);
|
|
3559
|
+
const ok = () => approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: k.lastKeyAt, heldKey: g?.heldKey, ch });
|
|
3560
|
+
if (k.confirm) { // second press of a two-press grant
|
|
3561
|
+
k.confirm = false;
|
|
3562
|
+
if (ch.toLowerCase() === 'y' && ok()) { answer(card, 'approve'); return true; }
|
|
3563
|
+
bump(n => n + 1);
|
|
3564
|
+
return true; // any other key cancels
|
|
3565
|
+
}
|
|
3566
|
+
const key = ch.toLowerCase();
|
|
3567
|
+
if (key === 'c' && !S.current.turn && !noAiNow()) { // y/N/c: chat about the approval first; the card stays
|
|
3568
|
+
if (!ok()) return true;
|
|
3569
|
+
const what = card.what || [card.tool, card.target].filter(Boolean).join(' ') || 'this approval';
|
|
3570
|
+
const msg = `Tell me more about: approve ${what}, before I decide`;
|
|
3571
|
+
touchSeen(); dispatch({ type: 'user', text: msg }); runTurn(msg); bump(n => n + 1); return true;
|
|
940
3572
|
}
|
|
941
|
-
if (
|
|
942
|
-
if (!
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
|
|
3573
|
+
if (key !== 'y' && key !== 'n') return false; // ordinary typing: the composer keeps it
|
|
3574
|
+
if (!ok()) return true; // too early / key repeat: swallowed, never answered
|
|
3575
|
+
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.
|
|
3577
|
+
R.current.denyReason = { card, at: Date.now() }; say('Reason? (Enter to skip)', 'warn'); bump(n => n + 1); return true;
|
|
3578
|
+
}
|
|
3579
|
+
if (card.privileged || card.undescribable) { k.confirm = true; bump(n => n + 1); return true; }
|
|
3580
|
+
answer(card, 'approve');
|
|
946
3581
|
return true;
|
|
947
3582
|
}
|
|
948
3583
|
|
|
@@ -977,11 +3612,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
977
3612
|
return ro;
|
|
978
3613
|
}
|
|
979
3614
|
|
|
3615
|
+
/** COMPOSER-EDIT: cells the draft has per row (the composer box is `cols` wide, less the "› " prompt). */
|
|
3616
|
+
function composerWidth() { return Math.max(1, cols - 2); }
|
|
3617
|
+
/** COMPOSER-EDIT: copy the composer selection to the clipboard with OSC 52 (works over SSH; needs a terminal that allows it). */
|
|
3618
|
+
function copySelection() {
|
|
3619
|
+
const t = selectedText(B.current);
|
|
3620
|
+
if (!t || !stdout?.isTTY) return;
|
|
3621
|
+
try { stdout.write(osc52(t)); } catch { /* closed */ }
|
|
3622
|
+
}
|
|
980
3623
|
function onKey(e, now) {
|
|
981
3624
|
const s = S.current;
|
|
982
3625
|
const busy = Boolean(s.turn);
|
|
983
3626
|
const name = e.name;
|
|
984
3627
|
if (name === 'ctrl-c') {
|
|
3628
|
+
if (R.current.bang) { R.current.bang.run.kill(); return; } // QUOXON-FW8: stops the running ! command, never quits
|
|
985
3629
|
if (B.current.text) { edit({ type: 'clear' }); return; }
|
|
986
3630
|
if (busy) { interrupt(); return; }
|
|
987
3631
|
if (K.current.ctrlC) return exit();
|
|
@@ -992,17 +3636,23 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
992
3636
|
}
|
|
993
3637
|
if (name === 'ctrl-d') { if (!B.current.text) exit(); return; }
|
|
994
3638
|
if (R.current.roster.focus && !s.approvals.length && rosterKey(name)) return;
|
|
995
|
-
|
|
3639
|
+
// quick reply (owner 2026-10-15): Tab in an EMPTY composer pre-fills "/say <last sender> " (before the palette, which owns Tab otherwise)
|
|
3640
|
+
if (name === 'tab' && !B.current.text && R.current.lastSender && !headCard(s)) { edit({ type: 'set', text: `/say ${R.current.lastSender.toLowerCase()} ` }); return; }
|
|
3641
|
+
if (!headCard(s) && PL.current.onKey(name)) { bump(n => n + 1); return; } // gate on the card actually SHOWN: an approval put away with Tab (or still queued) must not swallow Tab/Up/Down while the palette is on screen
|
|
996
3642
|
if (name === 'escape') {
|
|
3643
|
+
if (selection(B.current)) { edit({ type: 'place', index: B.current.cursor }); return; } // COMPOSER-EDIT: Esc drops a selection, nothing else
|
|
3644
|
+
if (flyActive()) { R.current.fly = null; bump(n => n + 1); return; } // Esc ends the dragon's pass
|
|
997
3645
|
if (busy) { interrupt(); return; }
|
|
3646
|
+
if (R.current.askArmed) { R.current.askArmed = false; R.current.dragonTalk = null; say('Dragon ask cancelled.', 'dim'); return; }
|
|
3647
|
+
if (HW.current.cancel()) return; // CUSTOM-HATS: Esc ends the hat walk, nothing sent
|
|
3648
|
+
if (RW.current.walk.cancel()) return; // ROWS: Esc ends the row walk, nothing saved
|
|
3649
|
+
if (R.current.pendingWalk) { walkEnd('Esc'); K.current.lastEscAt = now; return; } // WALK-FIXES: Esc leaves the walkthrough with a summary; the draft stays
|
|
998
3650
|
if (now - K.current.lastEscAt < ESC_ESC_MS && B.current.text) {
|
|
999
3651
|
if (looksLikeKey(B.current.text)) { edit({ type: 'clear' }); say('Draft cleared. It looked like a key, so it was not kept.'); }
|
|
1000
|
-
else {
|
|
3652
|
+
else { remember(B.current.text); edit({ type: 'clear' }); say('Draft cleared (Up recalls it).'); }
|
|
1001
3653
|
}
|
|
1002
3654
|
K.current.lastEscAt = now; return;
|
|
1003
3655
|
}
|
|
1004
|
-
// with a card up only draft-editing keys work (so the draft can be cleared); nothing submits or recalls
|
|
1005
|
-
if (s.approvals.length && !CARD_EDIT_KEYS.includes(name)) return;
|
|
1006
3656
|
switch (name) {
|
|
1007
3657
|
case 'enter': return onEnter(now);
|
|
1008
3658
|
case 'shift-enter': case 'newline': return edit({ type: 'newline' });
|
|
@@ -1014,12 +3664,21 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1014
3664
|
case 'delete-word': return edit({ type: 'deleteWordBack' });
|
|
1015
3665
|
case 'kill-line': return edit({ type: 'killLineBack' });
|
|
1016
3666
|
case 'left': case 'right': case 'home': case 'end': return edit({ type: name });
|
|
1017
|
-
case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? '
|
|
3667
|
+
case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'wordLeft' : 'wordRight' });
|
|
3668
|
+
case 'kill-line-forward': return edit({ type: 'killLineForward' }); // Ctrl+K
|
|
3669
|
+
case 'delete-word-forward': return edit({ type: 'deleteWordForward' }); // Alt+D, Ctrl+Delete
|
|
3670
|
+
case 'select-left': case 'select-right': case 'select-up': case 'select-down': case 'select-home': case 'select-end':
|
|
3671
|
+
case 'select-word-left': case 'select-word-right': { // COMPOSER-EDIT: Shift selection, copied to the clipboard (OSC 52)
|
|
3672
|
+
const move = { 'select-word-left': 'wordLeft', 'select-word-right': 'wordRight' }[name] || name.slice(7);
|
|
3673
|
+
edit({ type: 'extend', move, width: composerWidth() });
|
|
3674
|
+
copySelection();
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
1018
3677
|
case 'undo': return edit({ type: 'undo' });
|
|
1019
3678
|
case 'redo': return edit({ type: 'redo' });
|
|
1020
3679
|
case 'up': {
|
|
1021
3680
|
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; }
|
|
1022
|
-
if (
|
|
3681
|
+
if (atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
|
|
1023
3682
|
const hist = R.current.history;
|
|
1024
3683
|
if (B.current.text === '' || R.current.histIdx >= 0) {
|
|
1025
3684
|
const i = R.current.histIdx < 0 ? hist.length - 1 : Math.max(0, R.current.histIdx - 1);
|
|
@@ -1027,22 +3686,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1027
3686
|
}
|
|
1028
3687
|
return;
|
|
1029
3688
|
}
|
|
1030
|
-
return edit({ type: 'up' });
|
|
3689
|
+
return edit({ type: 'up', width: composerWidth() });
|
|
1031
3690
|
}
|
|
1032
3691
|
case 'down': {
|
|
1033
3692
|
if (B.current.text === '' && R.current.histIdx < 0 && rosterOn()) { focusRoster(); return; }
|
|
1034
|
-
if (
|
|
3693
|
+
if (atVisualEdge(B.current, 'down', composerWidth()) && R.current.histIdx >= 0) {
|
|
1035
3694
|
const i = R.current.histIdx + 1;
|
|
1036
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] }); }
|
|
1037
3696
|
return;
|
|
1038
3697
|
}
|
|
1039
|
-
return edit({ type: 'down' });
|
|
3698
|
+
return edit({ type: 'down', width: composerWidth() });
|
|
1040
3699
|
}
|
|
1041
3700
|
case 'ctrl-g': {
|
|
1042
3701
|
const full = sanitizeForSend({ ...B.current }).text ?? B.current.text;
|
|
3702
|
+
mouseTerm(false); // the editor gets a plain terminal (QUOXON-MOUSE), and tracking comes back after
|
|
1043
3703
|
if (isRawModeSupported) setRawMode(false);
|
|
1044
3704
|
const out = editor(full);
|
|
1045
3705
|
if (isRawModeSupported) setRawMode(true);
|
|
3706
|
+
mouseTerm(MO.current.on);
|
|
1046
3707
|
if (out !== null && out !== undefined) edit({ type: 'set', text: out });
|
|
1047
3708
|
return;
|
|
1048
3709
|
}
|
|
@@ -1059,24 +3720,52 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1059
3720
|
const handleEvents = (events, now, burst) => {
|
|
1060
3721
|
for (const e of events) {
|
|
1061
3722
|
if (e.type === 'focus') { SND.current.focus(e.focused); continue; } // CSI I / CSI O, never a key
|
|
3723
|
+
if (e.type === 'cursor') { MO.current.onCursor?.(e.row); continue; } // the answer to a click's DSR, never a key
|
|
3724
|
+
if (e.type === 'mouse') { onMouse(e); continue; } // QUOXON-MOUSE: never text, never a key
|
|
3725
|
+
if (K.current.askEcho) { // TUI-INPUT: see askKey
|
|
3726
|
+
const stale = now - K.current.askEcho.at > ASK_ECHO_MS;
|
|
3727
|
+
const enter = e.type === 'key' && e.name === 'enter';
|
|
3728
|
+
endAskEcho();
|
|
3729
|
+
if (enter && !stale) { bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // swallowed: no empty or stray message
|
|
3730
|
+
}
|
|
3731
|
+
if (AR.current.timer && e.type === 'key') { // the countdown owns two keys: Esc stays on this version, Enter restarts now; anything else is typing (a draft holds the restart)
|
|
3732
|
+
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
|
+
if (e.name === 'enter' && autoIdle()) { stopCountdown(); cdLog('restart-now'); doRestart(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
|
|
3734
|
+
}
|
|
3735
|
+
if (R.current.sleep) { // the sleep screen eats the waking key (nothing is lost), except "/": slash commands always work asleep
|
|
3736
|
+
if (e.type === 'key' && e.name === 'ctrl-c') { exit(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
|
|
3737
|
+
if (R.current.sleep.wakeAt != null) { /* already waking: later keys are typed as normal, so "/back" typed in one go is not cut short */ }
|
|
3738
|
+
else {
|
|
3739
|
+
wakeDragon();
|
|
3740
|
+
if (e.type === 'text' && e.text === '/') edit({ type: 'insert', text: '/' });
|
|
3741
|
+
else if (e.type === 'text') R.current.wokeSwallowed = { at: now };
|
|
3742
|
+
K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue;
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
if (SU.current.isOpen()) { SU.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the PIN card takes every key: nothing reaches the composer, history or undo
|
|
3746
|
+
if (LI.current.isOpen()) { LI.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the sign-in card takes every key
|
|
1062
3747
|
if (CN.current.isOpen()) { CN.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the secure card takes every key
|
|
1063
|
-
if (PK.current.isOpen() && !S.current.approvals.length
|
|
1064
|
-
const card = S.current
|
|
3748
|
+
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)
|
|
3749
|
+
const card = headCard(S.current);
|
|
1065
3750
|
const g = K.current.guard;
|
|
1066
3751
|
// any different key ends the "this key was already down" suppression
|
|
1067
3752
|
if (g?.heldKey && !(e.type === 'text' && e.text === g.heldKey)) g.heldKey = null;
|
|
1068
|
-
|
|
1069
|
-
|
|
3753
|
+
// Tab (composer empty) puts the card away into the status line until a NEW approval arrives
|
|
3754
|
+
// RESTORE-TUI: Tab with a draft focuses the card (the draft is kept, never queued or sent); Esc returns to the draft
|
|
3755
|
+
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; }
|
|
3756
|
+
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
|
+
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
|
+
if (card && K.current.confirm && e.type === 'key') { K.current.confirm = false; bump(n => n + 1); } // any other key cancels a pending privileged confirm
|
|
1070
3759
|
if (e.type === 'paste') {
|
|
1071
3760
|
K.current.lastPasteAt = now;
|
|
1072
3761
|
edit({ type: 'paste', text: e.text }); // a card never discards a paste
|
|
1073
3762
|
} else if (e.type === 'text') {
|
|
1074
3763
|
if (card && approvalKey('text', e.text, now)) { /* consumed by the approval dialog */ }
|
|
1075
3764
|
else if (!card && R.current.roster.focus && rosterKey(null, e.text)) { /* consumed by the roster */ }
|
|
3765
|
+
else if (!card && askKey(e.text, now)) { /* consumed by the open ask */ }
|
|
1076
3766
|
else edit({ type: 'insert', text: e.text });
|
|
1077
3767
|
} else if (e.type === 'key') {
|
|
1078
|
-
if (
|
|
1079
|
-
else if (e.name === 'enter' && burst) edit({ type: 'newline' }); // startup keys only: never submitted
|
|
3768
|
+
if (e.name === 'enter' && burst) edit({ type: 'newline' }); // startup keys only: never submitted
|
|
1080
3769
|
else onKey(e, now);
|
|
1081
3770
|
}
|
|
1082
3771
|
K.current.lastKeyAt = now;
|
|
@@ -1090,7 +3779,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1090
3779
|
// Its timing relative to the previous chunk says nothing: tmux, expect and SSH batch segments.
|
|
1091
3780
|
const burst = false;
|
|
1092
3781
|
K.current.lastDataAt = now;
|
|
1093
|
-
const parsed = parseChunk(typeof chunk === 'string' ? chunk : chunk.toString('utf8'), K.current.keys);
|
|
3782
|
+
const parsed = parseChunk(typeof chunk === 'string' ? chunk : chunk.toString('utf8'), K.current.keys, B.current.text);
|
|
1094
3783
|
K.current.keys = parsed.state;
|
|
1095
3784
|
clearTimeout(K.current.danglingTimer);
|
|
1096
3785
|
if (hasDangling(parsed.state)) {
|
|
@@ -1108,7 +3797,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1108
3797
|
React.useEffect(() => {
|
|
1109
3798
|
if (!resumeConversation || !api.conversationTurns) return undefined;
|
|
1110
3799
|
let live = true;
|
|
1111
|
-
api.conversationTurns(resumeConversation).then((turns) => {
|
|
3800
|
+
reconnecting(() => api.conversationTurns(resumeConversation)).then((turns) => {
|
|
1112
3801
|
if (!live) return;
|
|
1113
3802
|
if (!turns.length) {
|
|
1114
3803
|
// C5: the conversation's first turn may still be running (nothing is stored until it ends).
|
|
@@ -1120,14 +3809,66 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1120
3809
|
}
|
|
1121
3810
|
dispatch({ type: 'resume', conversationId: resumeConversation, turns });
|
|
1122
3811
|
return reattachIfRunning(resumeConversation);
|
|
1123
|
-
}, (e) => { if (live) dispatch({ type: 'error', text: `could not load conversation ${resumeConversation}: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); });
|
|
3812
|
+
}, (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'}` }); });
|
|
1124
3813
|
return () => { live = false; };
|
|
1125
3814
|
}, []);
|
|
1126
3815
|
React.useEffect(() => {
|
|
1127
3816
|
if (!startup) return undefined;
|
|
1128
3817
|
let live = true;
|
|
1129
|
-
|
|
1130
|
-
|
|
3818
|
+
let timer = null;
|
|
3819
|
+
const slow = (note) => /is not answering/.test(note);
|
|
3820
|
+
// A slow collector keeps being probed (capped backoff, one timer, none once it answers) so the notice can settle itself.
|
|
3821
|
+
const recheck = (i) => {
|
|
3822
|
+
const delays = healthRecheckDelays; if (!healthRecheck || !delays?.length) return;
|
|
3823
|
+
timer = setTimeout(async () => {
|
|
3824
|
+
timer = null;
|
|
3825
|
+
let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
|
|
3826
|
+
if (!live) return;
|
|
3827
|
+
if (hl?.ok) { say('✔ QuoxCORE is answering again', 'accent'); return; }
|
|
3828
|
+
recheck(Math.min(i + 1, delays.length - 1));
|
|
3829
|
+
}, delays[Math.min(i, delays.length - 1)]);
|
|
3830
|
+
};
|
|
3831
|
+
Promise.resolve().then(startup).then((note) => {
|
|
3832
|
+
if (!live || !note) return;
|
|
3833
|
+
if (slow(note)) { if (startup.said) return; startup.said = true; } // once per session, even if the probe is mounted twice
|
|
3834
|
+
say(note, 'warn');
|
|
3835
|
+
if (slow(note)) recheck(0);
|
|
3836
|
+
}, () => {});
|
|
3837
|
+
return () => { live = false; clearTimeout(timer); };
|
|
3838
|
+
}, []);
|
|
3839
|
+
React.useEffect(() => { // E5: "While you were away" under the intro, after more than 2 h away
|
|
3840
|
+
if (!away) return undefined;
|
|
3841
|
+
let live = true;
|
|
3842
|
+
try { AWAY.current.last = away.read(profile); } catch { AWAY.current.last = null; }
|
|
3843
|
+
const last = AWAY.current.last;
|
|
3844
|
+
touchSeen(true);
|
|
3845
|
+
if (!BL.current && api.away && awayDue(last)) {
|
|
3846
|
+
api.away(new Date(last).toISOString()).then((d) => { if (live) dispatch({ type: 'object', obj: { type: 'away', data: { ...d, compact: true } } }); },
|
|
3847
|
+
(e) => { if (live && !BL.current) say(`While you were away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). /away to retry.`, 'warn'); });
|
|
3848
|
+
}
|
|
3849
|
+
return () => { live = false; touchSeen(true); };
|
|
3850
|
+
}, []);
|
|
3851
|
+
React.useEffect(() => { // --demo --autoplay: the whole journey with no input, on a fixed script (demo.autoplay.js)
|
|
3852
|
+
if (!demoAuto) return undefined;
|
|
3853
|
+
const P = { ...AUTOPLAY, ...demoAuto };
|
|
3854
|
+
const k = demoAuto.scale || 1; // tests shrink every wait by the same factor; the order never changes
|
|
3855
|
+
const wait = (ms) => new Promise((res) => setTimeout(res, ms / k));
|
|
3856
|
+
const until = async (pred, limit = 120000) => { for (let t = 0; t < limit && !R.current.closed; t += 25) { if (pred()) return true; await new Promise((res) => setTimeout(res, 25)); } return false; };
|
|
3857
|
+
const type = async (text, seed) => { for (const [i, d] of typingPlan(text, seed, { min: P.typeMinMs, max: P.typeMaxMs }).entries()) { if (R.current.closed) return; onData.current([...text][i]); await wait(d); } };
|
|
3858
|
+
(async () => {
|
|
3859
|
+
await wait(P.startMs);
|
|
3860
|
+
await type(AUTOPLAY_REQUEST, P.seed); await wait(P.afterTypeMs); onData.current('\r');
|
|
3861
|
+
if (!await until(() => S.current.approvals[0]?.displayedAt != null)) return;
|
|
3862
|
+
await wait(Math.max(P.approvalHoldMs, 450 * k)); // never shorter than the card's arming window + key-repeat guard
|
|
3863
|
+
say('autoplay presses y: approve', 'ok');
|
|
3864
|
+
onData.current('y'); await wait(P.afterKeyMs);
|
|
3865
|
+
if (!await until(() => !S.current.turn && !S.current.approvals.length && !S.current.running.length)) return;
|
|
3866
|
+
await wait(Math.max(P.afterTurnMs, 450 * k)); // never shorter than CARD_GONE_MS: an Enter right after the card closes is swallowed (matters only when tests shrink the waits)
|
|
3867
|
+
await type(AUTOPLAY_PROOF, P.seed + 1); await wait(P.afterTypeMs); onData.current('\r');
|
|
3868
|
+
await wait(P.finalHoldMs);
|
|
3869
|
+
if (P.exitAfterMs != null) { await wait(P.exitAfterMs); if (!R.current.closed) exit(); }
|
|
3870
|
+
})();
|
|
3871
|
+
return undefined;
|
|
1131
3872
|
}, []);
|
|
1132
3873
|
React.useEffect(() => { // --demo plays by itself
|
|
1133
3874
|
if (!autoplay) return undefined;
|
|
@@ -1138,14 +3879,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1138
3879
|
if (!stdin) return undefined;
|
|
1139
3880
|
if (isRawModeSupported) setRawMode(true);
|
|
1140
3881
|
if (stdout?.isTTY) stdout.write(PASTE_ON);
|
|
3882
|
+
if (MO.current.on) mouseTerm(true); // /mouse on, saved for this profile
|
|
1141
3883
|
const fn = (d) => onData.current(d);
|
|
1142
|
-
if (earlyInput) { // keys typed during startup:
|
|
3884
|
+
if (earlyInput) { // keys typed during startup: replayed exactly as if typed now (R3 D4: an Enter submits, it is not dropped)
|
|
1143
3885
|
earlyInput.detach();
|
|
1144
|
-
for (const chunk of earlyInput.chunks.splice(0))
|
|
1145
|
-
const parsed = parseChunk(chunk, K.current.keys);
|
|
1146
|
-
K.current.keys = parsed.state;
|
|
1147
|
-
handleEvents(parsed.events, Date.now(), true);
|
|
1148
|
-
}
|
|
3886
|
+
for (const chunk of earlyInput.chunks.splice(0)) onData.current(chunk);
|
|
1149
3887
|
}
|
|
1150
3888
|
stdin.on('data', fn);
|
|
1151
3889
|
return () => {
|
|
@@ -1158,6 +3896,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1158
3896
|
R.current.pollWake?.();
|
|
1159
3897
|
stdin.off('data', fn);
|
|
1160
3898
|
if (stdout?.isTTY) stdout.write(PASTE_OFF);
|
|
3899
|
+
mouseTerm(false); // mouse tracking never outlives the client (or leaks into a /restart child)
|
|
1161
3900
|
SND.current?.dispose?.(); // focus reporting off: it must not outlive the client (or leak into a /restart child)
|
|
1162
3901
|
if (isRawModeSupported) setRawMode(false);
|
|
1163
3902
|
};
|
|
@@ -1177,28 +3916,33 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1177
3916
|
// Reveals: register what just landed, then give <Static> every leading item whose reveal has ended.
|
|
1178
3917
|
const nowR = Date.now();
|
|
1179
3918
|
let pendingHist = [];
|
|
1180
|
-
|
|
1181
|
-
|
|
3919
|
+
const sleeping = Boolean(R.current.sleep); // asleep: nothing is committed to scrollback, so waking loses nothing
|
|
3920
|
+
if (introDone.current && !sleeping) {
|
|
3921
|
+
const lim = commitLimit(s); // ASK-SETTLE: an armed run card stays live until it settles
|
|
3922
|
+
if (!motionOn) committedRef.current = Math.max(committedRef.current, lim);
|
|
1182
3923
|
else {
|
|
1183
3924
|
for (let i = committedRef.current; i < s.history.length; i++) registerFx(s.history[i], nowR);
|
|
1184
3925
|
for (const b of s.blocks) registerFx(b, nowR);
|
|
1185
3926
|
// The newest protocol stage stays live (its icon pulses) while it is the last thing that happened in the turn.
|
|
1186
3927
|
const activeStage = (i) => isActiveStage(s, i);
|
|
1187
|
-
while (committedRef.current <
|
|
1188
|
-
pendingHist = s.history.slice(committedRef.current);
|
|
3928
|
+
while (committedRef.current < lim && !fxActive(s.history[committedRef.current], nowR) && !activeStage(committedRef.current)) fxRef.current.delete(s.history[committedRef.current++].id);
|
|
1189
3929
|
for (const r of R.current.roster.rows) if (!rosterSeen.current.has(r.key)) rosterSeen.current.set(r.key, nowR);
|
|
1190
3930
|
}
|
|
3931
|
+
pendingHist = s.history.slice(committedRef.current);
|
|
1191
3932
|
}
|
|
1192
3933
|
lastAppRender.current = nowR;
|
|
1193
3934
|
// Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
|
|
1194
3935
|
const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
|
|
1195
|
-
const
|
|
1196
|
-
const
|
|
3936
|
+
const queue = queueOf(s);
|
|
3937
|
+
const card = queue[0] || null;
|
|
3938
|
+
const armed = card ? Date.now() - Math.max(card.displayedAt ?? card.shownAt ?? 0, K.current.guard?.since ?? 0) >= 300 : false;
|
|
3939
|
+
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
|
+
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)
|
|
1197
3941
|
R.current.armedShown = armed;
|
|
1198
|
-
const ctxParts = [profile,
|
|
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
|
|
1199
3943
|
const caps = R.current.caps;
|
|
1200
|
-
const hint = busy ? `${caps.steer ? 'Enter steer' : 'Enter queue'} · Tab queue · ${caps.interrupt ? 'Esc stop' : 'Esc disconnects'}` : 'Enter send · /help
|
|
1201
|
-
const placeholder = busy ? (caps.steer ? 'Type to steer (Enter) or queue (Tab)' : 'Type to queue (Enter or Tab)') : 'Ask Quox anything…';
|
|
3944
|
+
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
|
+
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…';
|
|
1202
3946
|
|
|
1203
3947
|
// The live region (unsettled blocks + chrome) must stay SHORTER than the screen: log-update can only
|
|
1204
3948
|
// erase rows that are still on screen, and Ink's own tall-frame path loses a line of history (term.js).
|
|
@@ -1215,9 +3959,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1215
3959
|
];
|
|
1216
3960
|
}
|
|
1217
3961
|
|
|
3962
|
+
// The ⏸ approval row under the status line already says how many wait: the "▶ N approvals waiting" line is then a duplicate.
|
|
3963
|
+
function approvalRowShown() {
|
|
3964
|
+
const ro = R.current.roster;
|
|
3965
|
+
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() });
|
|
3967
|
+
return v.mode === 'full' && v.lines.some(l => l.kind === 'approval');
|
|
3968
|
+
}
|
|
3969
|
+
|
|
1218
3970
|
function rosterNode() {
|
|
1219
3971
|
const ro = R.current.roster;
|
|
1220
|
-
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() });
|
|
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() });
|
|
1221
3973
|
const row = ro.rows[ro.sel];
|
|
1222
3974
|
return h(Roster, {
|
|
1223
3975
|
view, cols,
|
|
@@ -1228,35 +3980,53 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1228
3980
|
});
|
|
1229
3981
|
}
|
|
1230
3982
|
|
|
3983
|
+
if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
|
|
3984
|
+
const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
|
|
3985
|
+
return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
|
|
3986
|
+
h(Static, { key: `static-${epoch}`, items: s.history.slice(0, committedRef.current) }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(s.history[i - 1], it, termRows(stdout)) }))),
|
|
3987
|
+
h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
|
|
3988
|
+
}
|
|
3989
|
+
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());
|
|
1231
3990
|
return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
|
|
1232
3991
|
// Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
|
|
1233
3992
|
// already-printed items would drop it (and reprint the rest).
|
|
1234
|
-
h(Static, { key: `static-${epoch}`, items: introDone.current ? s.history.slice(0, committedRef.current) : [] }, (it) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols }))),
|
|
1235
|
-
introDone.current ? null : h(Box, { key: 'intro-live', flexDirection: 'column' }, h(Intro, { item: introItem, cols,
|
|
3993
|
+
h(Static, { key: `static-${epoch}`, items: introDone.current ? s.history.slice(0, committedRef.current) : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(s.history[i - 1], it, termRows(stdout)) }))),
|
|
3994
|
+
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) })),
|
|
1236
3995
|
liveRegion([
|
|
1237
|
-
...pendingHist.map((it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(FxItem, { item: it, cols, fxFor, active: busy && it.kind === 'trace' && i === pendingHist.length - 1 && !s.blocks.length }))),
|
|
1238
|
-
...s.blocks.map(b => h(Box, { key: b.id, flexDirection: 'column' }, motionOn && revealTimingFor(b) ? h(FxItem, { item: b, cols, live: true, fxFor }) : h(HistoryItem, { item: b, cols, live: true })))]),
|
|
1239
|
-
h(
|
|
3996
|
+
...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
|
+
...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
|
+
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,
|
|
4000
|
+
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,
|
|
1240
4001
|
h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
|
|
1241
|
-
busy ? h(Working, { key: 'working', motion: motionOn, 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,
|
|
1242
|
-
card ? h(ApprovalCard, { card, org:
|
|
4002
|
+
busy ? h(Working, { key: 'working', motion: motionOn && !R.current.exiting, handoff: feats().handoff, tokens: turnTokens(s.turn), act: s.turn.act, activity: describeActivity(s), since: s.turn.startedAt, interrupting: s.turn.status === 'interrupting', escLabel: caps.interrupt ? null : 'Esc disconnects' }) : null,
|
|
4003
|
+
card ? h(ApprovalCard, { card, org: orgShown() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, draftHint: K.current.cardFocus ? 'focus' : (B.current.text ? 'draft' : null), confirm: K.current.confirm, position: { i: 1, n: queue.length } }) : null,
|
|
4004
|
+
...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
|
|
1243
4005
|
// TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
|
|
1244
4006
|
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,
|
|
1245
4007
|
h(Pending, { pending: s.pending, steering: s.steering, cols }),
|
|
1246
|
-
h(Box, {
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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,
|
|
4009
|
+
ruleAbove ? null : h(Box, { marginTop: 1 }),
|
|
4010
|
+
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 }),
|
|
1250
4013
|
!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,
|
|
1251
|
-
h(
|
|
1252
|
-
h(
|
|
4014
|
+
h(RowsBar, { rows: RW.current.view(), cols }),
|
|
4015
|
+
h(PresentBar, { stepUp: SU.current, cols }),
|
|
4016
|
+
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
|
+
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)] }),
|
|
1253
4019
|
rosterNode())));
|
|
1254
4020
|
}
|
|
1255
4021
|
|
|
1256
4022
|
/** A live item that may be revealing: re-renders on every clock frame by itself (not the app). */
|
|
1257
|
-
function FxItem({ item, cols, live = false, fxFor, active = false }) {
|
|
4023
|
+
function FxItem({ item, cols, live = false, fxFor, active = false, ignition = false, gap }) {
|
|
1258
4024
|
const now = useFrame();
|
|
1259
|
-
const
|
|
1260
|
-
|
|
4025
|
+
const running = item.kind === 'tool' && item.status === 'running';
|
|
4026
|
+
const pulse = running || active ? now : null;
|
|
4027
|
+
const fx = fxFor(item, now);
|
|
4028
|
+
// expressive: a running tool row ignites as it lands (a short warm flare, motion/level.js ignitionGlow)
|
|
4029
|
+
const ignite = ignition && running && fx ? ignitionGlow(fx.e * 1000) : 0;
|
|
4030
|
+
return h(HistoryItem, { item, cols, live, fx, pulse, ignite, gap });
|
|
1261
4031
|
}
|
|
1262
4032
|
export { COLORS };
|