quoxcli 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +20 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/index.js +12 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +173 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/v1.js +34 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +46 -7
- package/lib/commands/approvals.js +5 -0
- package/lib/commands/ask.js +3 -1
- package/lib/commands/blog.js +110 -0
- package/lib/commands/chat.js +127 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +3 -32
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +154 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/kuma.js +1050 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/loop.js +291 -0
- package/lib/commands/objective.js +48 -1
- package/lib/commands/org.js +21 -4
- package/lib/commands/provider.js +58 -0
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +18 -1
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +113 -0
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +20 -4
- package/lib/commands/vm.js +2 -2
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/client.js +10 -2
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +1 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +1 -3
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +36 -7
- package/lib/core/output.js +7 -7
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +23 -0
- package/lib/quoxon/api.js +192 -6
- package/lib/quoxon/app.js +655 -89
- package/lib/quoxon/approvalFacts.js +8 -6
- package/lib/quoxon/approvalSentence.js +44 -0
- package/lib/quoxon/approvalsView.js +63 -4
- package/lib/quoxon/commandShort.js +15 -3
- package/lib/quoxon/commands.js +75 -10
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dragonCompanion.js +255 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +82 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/events.js +24 -3
- package/lib/quoxon/extensions.js +276 -0
- package/lib/quoxon/inboxView.js +79 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +456 -0
- package/lib/quoxon/loopCards.js +180 -0
- package/lib/quoxon/loopStream.js +87 -0
- package/lib/quoxon/main.js +12 -3
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/orgSwitch.js +27 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +6 -3
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +130 -33
- package/lib/quoxon/roster.js +26 -12
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +76 -1
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/palette.js +1 -1
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +25 -1
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/suggestCommand.js +33 -2
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/whatsnew.jsonl +45 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/package.json +2 -1
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
package/lib/quoxon/app.js
CHANGED
|
@@ -11,15 +11,15 @@ import { mkdtempSync, writeFileSync, writeSync, readFileSync, unlinkSync } from
|
|
|
11
11
|
import { tmpdir, hostname as osHostname } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { bufferReducer, initialBuffer, sanitizeForSend, wordAt, selection, selectedText, atVisualEdge } from './textBuffer.js';
|
|
14
|
-
import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, screenHit, classifyClick, composerIndexAt } from './mouse.js';
|
|
14
|
+
import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, lineAt, MOUSE_HINT, screenHit, classifyClick, composerIndexAt } from './mouse.js';
|
|
15
15
|
import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
|
|
16
16
|
import { isHeldFromBulk, heldReason } from './bulkRisk.js';
|
|
17
|
-
import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, parseAnswer, parsePairs } from './approvalsView.js';
|
|
17
|
+
import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, approvalAsWalkItem, parseAnswer, parsePairs } from './approvalsView.js';
|
|
18
18
|
import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
|
|
19
|
-
import { reduce, initialState, approvalKeyAllowed, groupApprovals, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
|
|
19
|
+
import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
|
|
20
20
|
import { turnTokens } from './indicator.js';
|
|
21
21
|
import {
|
|
22
|
-
Composer, RingComposer, Pending, ApprovalCard, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
|
|
22
|
+
Composer, RingComposer, Pending, ApprovalCard, LoopCardView, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
|
|
23
23
|
, endsInRule } from './render.js';
|
|
24
24
|
import { c, mono } from './theme.js';
|
|
25
25
|
import { strayCardAnswer, strayQuestion, alreadyQueued } from './stray.js';
|
|
@@ -34,18 +34,22 @@ import { nextTip, normaliseMode, tipSettingsReply, tipsReply, DEFAULT_MODE, TIPS
|
|
|
34
34
|
import { findSkill, skillRows, skillDetail, skillPrompt } from './skills.js';
|
|
35
35
|
import { createScreenContext } from './screenContext.js';
|
|
36
36
|
import { askText, askQuestion, askAnswer, askChatMessage, settledAskLine, settledAskTone, askOutcomeOf, isAskClarification, askClock } from './ask.js';
|
|
37
|
+
import { objectiveFromEvent, objectiveAskText, objectiveStartedNote, objectiveWatchLine, objectiveFailedNote, watchObjective } from './objectiveOffer.js';
|
|
37
38
|
import { createHandoffStreak, handoffBiggerAskText, handoffFromEvent, handoffAskText, handoffAnsweredNote, handoffSupersededNote, handoffSkippedNote } from './handoffOffer.js';
|
|
38
39
|
import * as selfUpdate from '../core/selfUpdate.js';
|
|
40
|
+
import { parseDone, todoDoneLine, parseTodo, todoAddedLine, todoBoardText, numberBoard, resolveRef, todoMovedLine } from './slash/todoCmd.js';
|
|
39
41
|
import { runRequest, runAnswer, handOverRun, runFooter } from './slotRun.js';
|
|
40
42
|
import { profileChooserRows, liveApi, switchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
|
|
41
43
|
import { rosterView, peekLines, selectRows, backgroundAlerts, isExpired, applyProgress, SPIN_MS } from './roster.js';
|
|
42
44
|
import { runObjectCommand } from './objects/index.js';
|
|
45
|
+
import { runEvidenceCommand } from './slash/evidenceCmd.js';
|
|
43
46
|
import { peekLines as peekIdLines } from '../core/peek.js';
|
|
44
47
|
import { turnsFromItems, asideNoteText } from './asideView.js';
|
|
45
48
|
import { createMentionCache, loadMentions, receiptMentions } from './mentions.js';
|
|
46
49
|
const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile', '/fleet', '/prove', '/replay', '/health', '/core']);
|
|
47
50
|
import { intentHint } from './intent.js';
|
|
48
51
|
import { observerLines } from './observers.js';
|
|
52
|
+
import { inboxBarText, arrivals, arrivalLine, inboxTable, inboxDetail, parseInbox, INBOX_USAGE, newestFirst, isApprovalItem } from './inboxView.js';
|
|
49
53
|
import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
|
|
50
54
|
import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
|
|
51
55
|
import { packageVersion } from './welcome.js';
|
|
@@ -53,7 +57,7 @@ import { feedbackPayload, feedbackPreview } from './feedback.js';
|
|
|
53
57
|
import { vetSuggestion, namedCommand } from './suggestCommand.js';
|
|
54
58
|
import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // PLAN MODE (owner 2026-10-16)
|
|
55
59
|
import { echoLine } from './echo.js';
|
|
56
|
-
import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
|
|
60
|
+
import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, looksLikeAddress, sayWhoText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
|
|
57
61
|
import { pushHistory } from './promptHistory.js';
|
|
58
62
|
import { termRows } from './term.js';
|
|
59
63
|
import { cardTallerThanScreen } from './objects/view.js';
|
|
@@ -70,9 +74,9 @@ import { humanSessionLines } from '../core/sessionsOverview.js';
|
|
|
70
74
|
import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
|
|
71
75
|
import { capabilityLines, offerFor, findCapability } from './capabilities.js';
|
|
72
76
|
import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
|
|
73
|
-
import { DragonLive, playDragon, dragonActive, resetDragon } from './qmotion.js';
|
|
77
|
+
import { DragonLive, playDragon, dragonActive, resetDragon, clipRoom, flyRoom } from './qmotion.js';
|
|
74
78
|
import { AUTOPLAY, AUTOPLAY_REQUEST, AUTOPLAY_PROOF, typingPlan } from './demo.autoplay.js';
|
|
75
|
-
import { motionLevel, levelFeatures,
|
|
79
|
+
import { motionLevel, levelFeatures, ignitionGlow, normaliseLevel } from './motion/level.js';
|
|
76
80
|
import { CLIPS as DRAGON_CLIPS } from './qdragonScenes.js';
|
|
77
81
|
import { DRAGON_USAGE, DRAGON_EXPLAINS, DRAGON_ANSWERS, NOTHING_TO_EXPLAIN, ASK_PROMPT, WAKE_MS, explainPrompt, askPrompt, lastOutput, parseDragonArg, sourceLines } from './dragonModes.js';
|
|
78
82
|
import { SleepScreen, DragonTalk, FlyLive } from './dragonView.js';
|
|
@@ -85,6 +89,7 @@ import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
|
|
|
85
89
|
import { parseBroadcastText } from './slash/broadcast.js';
|
|
86
90
|
import { isOwnBroadcast } from './ownBroadcast.js';
|
|
87
91
|
import { orgLabel, isOrgId } from './orgLabel.js';
|
|
92
|
+
import { enrichPlanItem } from './planSteps.js';
|
|
88
93
|
import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine } from './slotChat.js';
|
|
89
94
|
import { homedir as osHome } from 'node:os';
|
|
90
95
|
import { createRowCmd } from './slash/rowCmd.js';
|
|
@@ -92,6 +97,13 @@ import { createKillerCmds } from './slash/killer.js';
|
|
|
92
97
|
import { createUndoWish } from './slash/undoWish.js';
|
|
93
98
|
import { createHatWalk } from './slash/hatWalk.js';
|
|
94
99
|
import { createRowEngine, runShell } from './rows.js';
|
|
100
|
+
import { qpeCommand, qpeRowsSetting, QPE_GRID } from './slash/qpeCmd.js';
|
|
101
|
+
import { createExtensionHost, pickDraft as deckPickDraft, readExtList, extSetEnabled, extAdd } from './extensions.js';
|
|
102
|
+
import { createLoopCards, rowsWithoutLoopCards } from './loopCards.js';
|
|
103
|
+
import { initLoop, applySnapshot as applyLoopSnapshot, cardLines } from './loopCard.js';
|
|
104
|
+
import { createCompanionController, companionCommand, DragonCompanionLive, takeGreeting, detachGreeting } from './dragonCompanion.js';
|
|
105
|
+
import { dragonForAgent } from '../dragon/select.js';
|
|
106
|
+
import { createDragonJobs } from '../dragon/jobs.js';
|
|
95
107
|
import { createModelCmd } from './slash/modelCmd.js';
|
|
96
108
|
import { createChallengeCmd } from './slash/challenge.js';
|
|
97
109
|
import { createSpawnCmd } from './slash/spawn.js';
|
|
@@ -101,10 +113,10 @@ import { createLedger, costModel } from './slash/ledger.js';
|
|
|
101
113
|
import { statusModel, contextModel, doctorModel, chatChecks, modelText } from './slash/panels.js';
|
|
102
114
|
import { parseAgentsArgs, filterAgents, agentPickerRow, toolLines, whichReply, looseCommand } from './slash/agents.js';
|
|
103
115
|
import { createPicker } from './slash/picker.js';
|
|
104
|
-
import { resumeRows, pickerRow, matchConversation, turnsToHistory } from './slash/resume.js';
|
|
116
|
+
import { resumeRows, pickerRow, matchConversation, turnsToHistory, cleanTitle, titleFromTurns } from './slash/resume.js';
|
|
105
117
|
import { settingsList, applySettings, configRows, cycle } from './slash/settings.js';
|
|
106
118
|
import { countdownLine } from './countdownLine.js';
|
|
107
|
-
import { startBuildWatch, DIFFERENT_NOTICE } from './slash/restart.js';
|
|
119
|
+
import { startBuildWatch, DIFFERENT_NOTICE, waitForHealthy, SERVER_WAIT_EVERY_MS, SERVER_WAIT_MAX_MS } from './slash/restart.js';
|
|
108
120
|
import { dashboardLines } from './slash/dashboard.js';
|
|
109
121
|
import { supportsHyperlinks } from './markdown.js';
|
|
110
122
|
import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
|
|
@@ -119,6 +131,7 @@ import { FrameContext, useFrame } from './motion/frame.js';
|
|
|
119
131
|
import { ringMode, ringAnimating } from './motion/ring.js';
|
|
120
132
|
import { TIMING, revealSpan } from './motion/reveal.js';
|
|
121
133
|
import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
|
|
134
|
+
import { INSTALL_TOOLS, SUGGEST_LINE, planLine, offerQuestion, runInstall, missingTools } from './installCli.js';
|
|
122
135
|
import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
|
|
123
136
|
import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
|
|
124
137
|
|
|
@@ -157,7 +170,7 @@ const byId = (id) => CHOICES.find(x => x.id === id) || null;
|
|
|
157
170
|
// the status line shows the new ask until the server answers
|
|
158
171
|
const NO_RAN = { provider: null, model: null, endpoint: null };
|
|
159
172
|
|
|
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 }) {
|
|
173
|
+
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, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, 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
174
|
// Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
|
|
162
175
|
// one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
|
|
163
176
|
// PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
|
|
@@ -183,6 +196,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
183
196
|
const exit = (afterMs = 0) => {
|
|
184
197
|
const R0 = R.current;
|
|
185
198
|
if (R0.exiting) return undefined;
|
|
199
|
+
try { DK.current?.unloadAll(); } catch { /* closing the deck never blocks exit */ }
|
|
186
200
|
R0.exiting = true; R0.sleep = null; R0.fly = null;
|
|
187
201
|
R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
|
|
188
202
|
resetDragon();
|
|
@@ -232,6 +246,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
232
246
|
// Drop-in reveals: history items enter <Static> only once their reveal has ended (at most ~1 s); until then
|
|
233
247
|
// they render in the live region. committedRef = how many history items <Static> has been given.
|
|
234
248
|
const committedRef = React.useRef(introAnimating ? 0 : S.current.history.length);
|
|
249
|
+
const staticRef = React.useRef({ list: [], seen: 0, deferred: [] }); // what <Static> was given: committed items in order, minus loop cards still running (they join when they settle)
|
|
235
250
|
const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
|
|
236
251
|
const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
|
|
237
252
|
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 });
|
|
@@ -252,7 +267,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
252
267
|
|
|
253
268
|
const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
|
|
254
269
|
/** 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)); };
|
|
270
|
+
const queueOf = (st) => { const all = orderApprovals([...st.approvals, ...st.waiting], LC.current.all().map((c) => c.id)); const live = new Set(all.map(x => x.callId)); for (const id of K.current.dismissed) if (!live.has(id)) K.current.dismissed.delete(id); return all.filter(x => !K.current.dismissed.has(x.callId)); };
|
|
256
271
|
const headCard = (st) => queueOf(st)[0] || null;
|
|
257
272
|
const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
|
|
258
273
|
const dispatch = (a) => {
|
|
@@ -264,7 +279,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
264
279
|
}
|
|
265
280
|
transcript?.action(a.redact !== undefined ? { ...a, text: a.redact } : a); // dev transcript capture (off by default; see transcript.js); `redact` is what the capture sees instead of a secret-bearing text
|
|
266
281
|
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 =
|
|
282
|
+
if (motionOn && !mono() && feats().moods) { const m = DC.current.moodFor(prevS, a, S.current); if (m) playDragon(m); } // cinematic: the dragon reacts (one moment engine: an attached companion owns the moment instead, dragonMoments.js)
|
|
268
283
|
// Per shown card: remember the key that was already down when it appeared, and restart the typed word.
|
|
269
284
|
const c0 = headCard(S.current);
|
|
270
285
|
if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
|
|
@@ -292,6 +307,33 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
292
307
|
};
|
|
293
308
|
// QUOXON-ASK: every question for the human is ONE shape (▶ reason. question? keys), highlighted. Never say a raw y/N.
|
|
294
309
|
const ask = (question, opts) => say(askText(question, opts), 'ask');
|
|
310
|
+
// LAZY-AI-CLI (owner 2026-10-19): when something needs Claude Code or Codex and this server lacks it, offer to
|
|
311
|
+
// install it THEN, as one y/N card (never "type this command"). The server installs the vendor's own npm package.
|
|
312
|
+
const offerInstall = async (tool) => {
|
|
313
|
+
const t = INSTALL_TOOLS[tool];
|
|
314
|
+
const plan = api.providerCliPlan ? await api.providerCliPlan(tool) : null;
|
|
315
|
+
if (!plan?.ok) {
|
|
316
|
+
if (plan?.status === 403) say(`${t.name} isn't installed on this server. An instance admin can install it from the Quox terminal.`, 'warn');
|
|
317
|
+
else if (plan?.status === 404 || plan?.status === 405 || !api.providerCliPlan) say(`${t.name} isn't installed on this server, and this server is too old to install it for you. Update the server first.`, 'warn');
|
|
318
|
+
else say(`${t.name} isn't installed on this server, and Quox could not check npm right now (${plan?.error || 'no answer'}).`, 'warn');
|
|
319
|
+
return nextInstallOffer();
|
|
320
|
+
}
|
|
321
|
+
say(planLine(plan), 'dim');
|
|
322
|
+
R.current.installAsk = { tool, plan, at: Date.now() };
|
|
323
|
+
return ask(offerQuestion(tool, plan), { action: `install ${t.name}`, reason: `${t.vendor}'s own package, downloaded from npm onto this server.` });
|
|
324
|
+
};
|
|
325
|
+
function nextInstallOffer() {
|
|
326
|
+
const q = R.current.installQueue;
|
|
327
|
+
if (!q || !q.length) return undefined;
|
|
328
|
+
return offerInstall(q.shift());
|
|
329
|
+
}
|
|
330
|
+
async function installNow(a) {
|
|
331
|
+
say(`◐ installing ${INSTALL_TOOLS[a.tool].name} ${a.plan?.version || ''} on the server (a minute or two)`.trim(), 'dim');
|
|
332
|
+
const r = await runInstall(api, a.tool, { onProgress: (l) => say(l, 'dim') });
|
|
333
|
+
for (const l of r.lines) say(l, r.ok ? 'ok' : 'warn');
|
|
334
|
+
if (r.ok) bump(n => n + 1);
|
|
335
|
+
return nextInstallOffer();
|
|
336
|
+
}
|
|
295
337
|
// PLAN MODE: this window's mode ('ask' | 'plan'). Window-scoped: rides /restart, never the shared profile.
|
|
296
338
|
const MD = React.useRef(null); MD.current ??= normMode(mode);
|
|
297
339
|
const askerName = () => String(SN.current?.agent || where.current?.agent || 'Quox').toUpperCase(); // a model-suggested command: who asks
|
|
@@ -365,6 +407,56 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
365
407
|
const rowsLoaded = React.useRef(false);
|
|
366
408
|
React.useEffect(() => { if (!blocked && !demoLabel && !rowsLoaded.current) { rowsLoaded.current = true; RW.current.load(); } }, [blocked]);
|
|
367
409
|
React.useEffect(() => () => RW.current.stopEngine(), []);
|
|
410
|
+
// EXTENSIONS (extensions.js): optional npm packages listed in ~/.quox/extensions.json. They can open an approval or
|
|
411
|
+
// preselect an answer; the user confirms here. Core ships no extension-specific code.
|
|
412
|
+
const DK = React.useRef(null);
|
|
413
|
+
DK.current ??= createExtensionHost({
|
|
414
|
+
env,
|
|
415
|
+
getSettings: () => api.settings?.() || {},
|
|
416
|
+
handlers: {
|
|
417
|
+
openApproval: (n) => { command(`/approvals ${n}`); },
|
|
418
|
+
preselect: (n, option) => {
|
|
419
|
+
const it = (R.current.deckItems || [])[n - 1];
|
|
420
|
+
const draft = deckPickDraft(it, n, option);
|
|
421
|
+
if (!draft) return say(`Extension: no such option "${option}" for [${n}].`, 'warn');
|
|
422
|
+
edit({ type: 'set', text: draft }); // preselected only: the answer goes when the user presses Enter
|
|
423
|
+
say(`Preselected: ${draft.trim()}. Press Enter to confirm.`, 'accent');
|
|
424
|
+
},
|
|
425
|
+
changed: () => bump(n => n + 1)
|
|
426
|
+
},
|
|
427
|
+
log: () => {}
|
|
428
|
+
});
|
|
429
|
+
const extDispatch = async (cmd, restArg) => { // a loaded extension's own slash command; true when one handled it
|
|
430
|
+
const xr = await DK.current.runCommand(cmd, restArg, { say: (t, tone) => say(t, tone), org: orgShown(), host: DK.current });
|
|
431
|
+
if (!xr.handled) return false;
|
|
432
|
+
if (xr.error) say(`${cmd}: ${xr.error}`, 'warn'); else if (typeof xr.result === 'string') say(xr.result); else if (xr.result?.text) say(xr.result.text, xr.result.tone);
|
|
433
|
+
return true;
|
|
434
|
+
};
|
|
435
|
+
const extLoad = async (name, quiet) => { const r = await DK.current.load(name); if (!quiet || r.ok) say(r.text, r.ok ? 'ok' : 'warn'); return r; };
|
|
436
|
+
React.useEffect(() => {
|
|
437
|
+
if (!blocked && !demoLabel) for (const x of readExtList()) if (x.enabled) extLoad(x.name, true).catch(() => {});
|
|
438
|
+
return () => { DK.current.unloadAll(); };
|
|
439
|
+
}, []);
|
|
440
|
+
React.useEffect(() => { // fleet for extensions: a light 30 s poll of the existing host list, only while an extension is loaded
|
|
441
|
+
if (!DK.current.armed() || !api.mentionHosts) return undefined;
|
|
442
|
+
let dead = false;
|
|
443
|
+
const poll = () => api.mentionHosts().then((h) => { if (!dead) { R.current.fleetHosts = h; bump(n => n + 1); } }, () => { if (!dead) { R.current.fleetHosts = null; } });
|
|
444
|
+
poll(); const id = setInterval(poll, 30000);
|
|
445
|
+
return () => { dead = true; clearInterval(id); };
|
|
446
|
+
}, [DK.current.armed()]);
|
|
447
|
+
React.useEffect(() => { // push what this window already holds; the host dedupes and throttles to 1/s
|
|
448
|
+
if (!DK.current.armed()) return;
|
|
449
|
+
const rows = R.current.roster?.allRows || [];
|
|
450
|
+
DK.current.push({
|
|
451
|
+
org: { id: R.current.orgId ?? api.orgId?.() ?? null, name: orgShown() },
|
|
452
|
+
items: R.current.deckItems || [],
|
|
453
|
+
health: { collector: R.current.notify?.fails > 0 || S.current.notify?.error ? 'down' : 'ok', rows: rows.some(r => r.state === 'failed') ? 'warn' : 'ok' },
|
|
454
|
+
loops: LC.current.all().map(c => ({ id: c.state.id, title: c.state.title, turn: c.state.iteration ?? c.state.turn, max: c.state.maxTurns, status: c.state.done ? (c.state.state === 'failed' ? 'failed' : 'done') : 'running' })),
|
|
455
|
+
fleet: R.current.fleetHosts || null,
|
|
456
|
+
evidence: api.evidenceChain?.() || null, // quoxon holds no chain status today: 'unknown' unless the api provides one
|
|
457
|
+
sessions: { active: rows.filter(r => r.state === 'running').length }
|
|
458
|
+
});
|
|
459
|
+
});
|
|
368
460
|
PL.current ??= createPalette({
|
|
369
461
|
getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
|
|
370
462
|
run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
|
|
@@ -378,13 +470,47 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
378
470
|
flagged: modelChoice != null, off: Boolean(blocked || demoLabel),
|
|
379
471
|
dispatchCtx: () => dispatch({ type: 'ctx', ctx: NO_RAN })
|
|
380
472
|
}), []);
|
|
473
|
+
// TUI-STATUS: the registered server this runs on, read once at start (a restart re-reads); null = show nothing
|
|
474
|
+
const SV = React.useRef(null); SV.current ??= { chip: null };
|
|
475
|
+
React.useEffect(() => { // retry every 3 min (max 20) until the lookup succeeds: a collector restart at startup must not hide the chip for the whole session; failing shows nothing
|
|
476
|
+
if (blocked || demoLabel || !api.serverChip) return undefined;
|
|
477
|
+
let stopped = false, timer = null, tries = 0;
|
|
478
|
+
const attempt = () => { tries++; Promise.resolve().then(() => api.serverChip()).then((c) => { if (stopped) return; if (c?.name) { SV.current.chip = c; dispatch({ type: 'notify', patch: {} }); } else if (tries < 20) timer = setTimeout(attempt, 180000); }).catch(() => { if (!stopped && tries < 20) timer = setTimeout(attempt, 180000); }); };
|
|
479
|
+
attempt();
|
|
480
|
+
return () => { stopped = true; if (timer) clearTimeout(timer); };
|
|
481
|
+
}, []);
|
|
381
482
|
const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
|
|
382
483
|
// /restart auto: saved per profile (profile.quoxon.restartAuto). `newId` = the newest installed build seen; `cancelledId` = the build whose countdown a key cancelled.
|
|
383
484
|
const AR = React.useRef(null); AR.current ??= { on: (api.settings?.() || {}).restartAuto === true, newId: null, cancelledId: null, timer: null, endsAt: 0 };
|
|
384
485
|
// Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
|
|
385
486
|
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
487
|
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; };
|
|
488
|
+
const stopCountdown = () => { clearTimeout(AR.current.timer); AR.current.timer = null; AR.current.endsAt = 0; AR.current.waiting = null; };
|
|
489
|
+
// A land refreshes the CLI and redeploys the collector together: an AUTO restart waits for the server's /health (every
|
|
490
|
+
// serverPollMs, up to serverWaitMs), then restarts anyway. Manual /restart and Enter never wait.
|
|
491
|
+
const serverUp = serverProbe || (api.health ? () => api.health(3000) : null);
|
|
492
|
+
const autoFire = () => {
|
|
493
|
+
const a = AR.current;
|
|
494
|
+
if (!serverUp) { cdLog('timed-out'); doRestart(); return; }
|
|
495
|
+
const token = {}; a.waiting = token; // set first: cancels and the idle check see a restart in flight
|
|
496
|
+
Promise.resolve().then(() => serverUp()).catch(() => null).then((first) => {
|
|
497
|
+
if (AR.current.waiting !== token || R.current.closed) return;
|
|
498
|
+
if (first?.ok === true) { stopCountdown(); cdLog('timed-out'); doRestart(); return; } // healthy: no waiting line, as before
|
|
499
|
+
beginWait(token);
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
const beginWait = (token) => {
|
|
503
|
+
const a = AR.current;
|
|
504
|
+
a.endsAt = Date.now() || 1;
|
|
505
|
+
a.timer = setTimeout(() => {}, serverWaitMs + 60000); // holds the countdown slot (keys, held-by-draft check); cleared by stopCountdown
|
|
506
|
+
cdLog('waiting-for-server');
|
|
507
|
+
bump(n => n + 1);
|
|
508
|
+
waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => AR.current.waiting !== token || R.current.closed }).then((up) => {
|
|
509
|
+
if (AR.current.waiting !== token || R.current.closed) return; // cancelled (Esc, Enter, a draft) or closed
|
|
510
|
+
stopCountdown();
|
|
511
|
+
if (AR.current.on && autoIdle()) { cdLog(up ? 'server-back' : 'wait-timed-out'); doRestart(); } else { cdLog('held'); bump(n => n + 1); }
|
|
512
|
+
});
|
|
513
|
+
};
|
|
388
514
|
React.useEffect(() => (startSha ? buildWatch({ startSha, repeat: true, onNotice: (t, id) => {
|
|
389
515
|
AR.current.newId = id || t;
|
|
390
516
|
if (!AR.current.on) say(t === DIFFERENT_NOTICE ? t : 'a newer quox is installed: /restart (or /restart auto on)', 'dim');
|
|
@@ -397,14 +523,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
397
523
|
React.useEffect(() => {
|
|
398
524
|
const id = setInterval(() => {
|
|
399
525
|
const a = AR.current;
|
|
400
|
-
if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting) return;
|
|
526
|
+
if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting || (a.waiting && !a.timer)) return;
|
|
401
527
|
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
528
|
if (!autoIdle()) return;
|
|
403
529
|
const ms = countdownMs();
|
|
404
|
-
if (ms <= 0) { cdLog('timed-out', 0);
|
|
530
|
+
if (ms <= 0) { cdLog('timed-out', 0); autoFire(); return; }
|
|
405
531
|
a.endsAt = Date.now() + ms;
|
|
406
532
|
cdLog('started', Math.round(ms / 1000));
|
|
407
|
-
a.timer = setTimeout(() => {
|
|
533
|
+
a.timer = setTimeout(() => { if (AR.current.on && autoIdle()) { clearTimeout(a.timer); a.timer = null; a.endsAt = 0; autoFire(); } else { stopCountdown(); cdLog('held'); bump(n => n + 1); } }, ms);
|
|
408
534
|
bump(n => n + 1);
|
|
409
535
|
}, autoTickMs);
|
|
410
536
|
return () => { clearInterval(id); stopCountdown(); };
|
|
@@ -412,18 +538,51 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
412
538
|
// /restart carried a slot: re-check it out under this window's label before the old one is gone. /restart auto tends to
|
|
413
539
|
// fire just as a new build lands, which is often while the collector redeploys, so a timeout or 5xx is retried; if it
|
|
414
540
|
// 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); }, []);
|
|
541
|
+
React.useEffect(() => { if (restartSlot && !blocked && api.checkoutSlot) waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => R.current.closed }).then(() => { if (!R.current.closed) carrySlot(restartSlot); }); }, []);
|
|
416
542
|
// /restart carried an /attach (agent or slot): re-attach once the window can act (owner 2026-10-15 "why dont u reattach").
|
|
417
543
|
const attachCarried = React.useRef(false);
|
|
418
544
|
React.useEffect(() => {
|
|
419
545
|
if (!restartAttach || blocked || attachCarried.current) return;
|
|
420
546
|
attachCarried.current = true;
|
|
421
547
|
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
|
-
|
|
548
|
+
waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => R.current.closed }).then(() => { // the server may still be redeploying: attach only once it answers
|
|
549
|
+
if (R.current.closed) return;
|
|
550
|
+
if (S.current.turn) { R.current.pendingAttach = restartAttach; return; }
|
|
551
|
+
R.current.noGreeting = true; command(`/attach ${restartAttach}`);
|
|
552
|
+
});
|
|
423
553
|
}, [blocked]);
|
|
424
554
|
React.useEffect(() => { // any other way the resumed turn ends (reattach path, Esc): carry the attach then
|
|
425
555
|
if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
|
|
426
556
|
});
|
|
557
|
+
/**
|
|
558
|
+
* Move THIS window to org `t` ({ id, name }) now: new conversation, status bar, agent re-attach as on /restart.
|
|
559
|
+
* `quick` (picker / `/org <name>`): the pick is the authorization, so one short line, and `makeDefault` also
|
|
560
|
+
* changes the profile-wide default (the same change as `quox org switch` in a shell).
|
|
561
|
+
*/
|
|
562
|
+
async function switchOrgNow(t, { quick = false, makeDefault = false } = {}) {
|
|
563
|
+
const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
|
|
564
|
+
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
565
|
+
if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
|
|
566
|
+
if (t.id === curId && !makeDefault) return say(`Already in ${t.name}.`);
|
|
567
|
+
let done;
|
|
568
|
+
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'); }
|
|
569
|
+
const att = SN.current.agent ? `agent:${SN.current.agent}` : null;
|
|
570
|
+
if (t.id !== curId) R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
|
|
571
|
+
R0.orgId = done.id; R0.orgName = done.name;
|
|
572
|
+
dispatch({ type: 'ctx', ctx: { conversationId: null } });
|
|
573
|
+
SN.current.name = null; SN.current.saved = null;
|
|
574
|
+
dispatch({ type: 'history.reset' });
|
|
575
|
+
if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
576
|
+
setEpoch(e => e + 1); bump(n => n + 1);
|
|
577
|
+
let scope = 'this window only; the profile default is unchanged';
|
|
578
|
+
let tone = 'ok';
|
|
579
|
+
if (makeDefault) {
|
|
580
|
+
try { await api.makeOrgDefault(done.id); scope = 'this window and the profile default'; }
|
|
581
|
+
catch (e) { scope = `this window only; could not change the profile default (${e?.message || 'refused'})`; tone = 'warn'; }
|
|
582
|
+
}
|
|
583
|
+
if (att) { R.current.noGreeting = true; command(`/attach ${att}`); }
|
|
584
|
+
return say(quick ? `now in ${done.name} (${scope})` : `Switched to ${done.name}. New conversation in that org.`, tone);
|
|
585
|
+
}
|
|
427
586
|
async function carrySlot(nm) {
|
|
428
587
|
for (let i = 0; ; i++) {
|
|
429
588
|
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
|
|
@@ -447,6 +606,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
447
606
|
return () => clearInterval(id);
|
|
448
607
|
}, []);
|
|
449
608
|
// ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
|
|
609
|
+
// Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher): attach state + real stream events; art and reducer live in dragonlab.
|
|
610
|
+
const DC = React.useRef(null); DC.current ??= createCompanionController({ settings: () => api.settings?.() || {}, isTTY: Boolean(stdout?.isTTY), typing: () => B.current.text !== '', jobs: createDragonJobs({ reads: api.dragonReads || {}, context: () => ({ envelopeId: receiptMentions([...S.current.history, ...S.current.blocks])[0]?.value || null }) }) }); // each dragon's one job: a real read (dragon/jobs.js)
|
|
611
|
+
const dispatchEv = (a) => { DC.current.tap(a); dispatch(a); };
|
|
612
|
+
// QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
|
|
613
|
+
const LC = React.useRef(null); LC.current ??= createLoopCards({ api, say: (t, tone) => say(t, tone), addItem: (it) => dispatch({ type: 'loopcard', card: it.card }), bump: () => { if (!R.current.closed) bump(n => n + 1); }, dragon: { summon: (d, cap) => DC.current.summon(d, cap) } });
|
|
450
614
|
const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
|
|
451
615
|
const realCwd = () => (cwd ? String(cwd).replace(/^~(?=\/|$)/, osHome()) : process.cwd());
|
|
452
616
|
const myDisplay = () => { try { return api.displayName?.() || null; } catch { return null; } };
|
|
@@ -502,6 +666,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
502
666
|
const runAskOpen = () => Boolean(SC.current.runAsk && runNow() < SC.current.runAsk.expiresAt); // one clock: the run-ask clock decides open and expired alike
|
|
503
667
|
/** ASK-SETTLE: history may go to <Static> (scrollback, immutable) only up to the first armed run card. */
|
|
504
668
|
function commitLimit(s0) {
|
|
669
|
+
// QLOOP-LIVE: a running loop's card is drawn pinned above the composer, so it never holds the history after it live (that re-laid-out every later row on every card tick); syncStatic defers the card itself until it settles
|
|
505
670
|
const held = SC.current.runCards;
|
|
506
671
|
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
672
|
return s0.history.length;
|
|
@@ -627,6 +792,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
627
792
|
const r = await api.slotSay(slot, text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
|
|
628
793
|
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
794
|
if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
|
|
795
|
+
R.current.lastSay = { slot, at: Date.now() }; // SAY-LAST-TARGET
|
|
796
|
+
if (r.viaOrgId) say(`${slot} lives in another of your orgs; sent there.`, 'dim');
|
|
630
797
|
const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
|
|
631
798
|
dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
|
|
632
799
|
announceDelivery(slot, r);
|
|
@@ -765,12 +932,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
765
932
|
}
|
|
766
933
|
}).catch(() => { /* no ask when the server cannot say */ });
|
|
767
934
|
}, []);
|
|
768
|
-
React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume)); }, []);
|
|
935
|
+
React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume), { fromRestart: Boolean(restartAttach) }); }, []);
|
|
769
936
|
React.useEffect(() => { if (!blocked && !demoLabel) aiConnected().then((st) => { R.current.noAi = st === false ? true : st === true ? false : undefined; bump(n => n + 1); }); }, []);
|
|
770
937
|
// 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.
|
|
938
|
+
// QUOXON-MOUSE: optional mouse (default off, saved per profile; selection-safe.test.jsx forbids tracking by default). `tty` = tracking is on at the terminal right now.
|
|
772
939
|
const MO = React.useRef(null);
|
|
773
940
|
MO.current ??= { on: (api.settings?.() || {}).mouse === 'on', tty: false, chain: Promise.resolve(), drag: null, last: null, onCursor: null, exitFn: null };
|
|
941
|
+
const TODO_MAP = React.useRef(null); // /todos short ids (td1, do2, dn3) -> real item ids, as last shown
|
|
774
942
|
const TP = React.useRef(null);
|
|
775
943
|
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
944
|
const showTip = async ({ startup: atLaunch = false } = {}) => {
|
|
@@ -807,6 +975,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
807
975
|
const shown = showWhatsNew();
|
|
808
976
|
if (tipStartup && !welcome && !shown) showTip({ startup: true });
|
|
809
977
|
};
|
|
978
|
+
React.useEffect(() => { // LAZY-AI-CLI: first launch after setup: one line, then a y/N card per missing CLI (admins only; never a wall)
|
|
979
|
+
if (blocked || !welcome || !api.providerCliStatus || !api.providerCliPlan) return;
|
|
980
|
+
api.providerCliStatus().then(async (st) => {
|
|
981
|
+
const missing = missingTools(st);
|
|
982
|
+
if (!missing.length) return;
|
|
983
|
+
const first = await api.providerCliPlan(missing[0]);
|
|
984
|
+
if (!first?.ok) return; // not an instance admin (or npm unreachable): no suggestion they cannot act on
|
|
985
|
+
say(SUGGEST_LINE);
|
|
986
|
+
R.current.installQueue = missing.slice(1);
|
|
987
|
+
say(planLine(first), 'dim');
|
|
988
|
+
R.current.installAsk = { tool: missing[0], plan: first, at: Date.now() };
|
|
989
|
+
ask(offerQuestion(missing[0], first), { action: `install ${INSTALL_TOOLS[missing[0]].name}`, reason: `${INSTALL_TOOLS[missing[0]].vendor}'s own package, downloaded from npm onto this server.` });
|
|
990
|
+
}, () => {});
|
|
991
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
810
992
|
React.useEffect(() => {
|
|
811
993
|
if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); startupTip(); }, () => startupTip());
|
|
812
994
|
else startupTip();
|
|
@@ -846,6 +1028,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
846
1028
|
if (nm && !isOrgId(nm)) { R.current.orgName = nm; dispatch({ type: 'notify', patch: {} }); }
|
|
847
1029
|
} catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
|
|
848
1030
|
};
|
|
1031
|
+
// HITL-INBOX-BAR: unread inbox items on the status area + one stream line per new arrival. A failed read keeps the last bar.
|
|
1032
|
+
const refreshInboxBar = async () => {
|
|
1033
|
+
if (!api.inboxItems) return;
|
|
1034
|
+
try {
|
|
1035
|
+
const items = await api.inboxItems({ limit: 30 });
|
|
1036
|
+
const a = arrivals(items, R.current.inboxSeen || null);
|
|
1037
|
+
R.current.inboxSeen = a.ids;
|
|
1038
|
+
R.current.inboxList = newestFirst(items).slice(0, 10);
|
|
1039
|
+
for (const it of a.fresh.slice(0, 3)) say(arrivalLine(it), 'warn');
|
|
1040
|
+
if (a.fresh.length > 3) say(`📨 ${a.fresh.length - 3} more · /inbox`, 'warn');
|
|
1041
|
+
dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols) } });
|
|
1042
|
+
} catch { /* last known bar stays; the approvals poll reports the outage */ }
|
|
1043
|
+
};
|
|
849
1044
|
const refreshNotify = async () => {
|
|
850
1045
|
if (BL.current) return;
|
|
851
1046
|
const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
|
|
@@ -854,12 +1049,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
854
1049
|
if (N.authLost) say('Signed in again: approvals are back.', 'ok');
|
|
855
1050
|
else if (N.slow) say('Quox server is back.', 'ok');
|
|
856
1051
|
N.fails = 0; N.authLost = false; N.slow = false;
|
|
1052
|
+
R.current.deckItems = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); // QUOXDECK: numbered as /approvals N
|
|
857
1053
|
dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
|
|
1054
|
+
refreshInboxBar(); // HITL-INBOX-BAR: rides the same poll, never blocks it
|
|
858
1055
|
retryOrgName();
|
|
859
1056
|
dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
|
|
860
1057
|
R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
|
|
861
1058
|
// Provider paths that emit no approval.requested still surface here, as the same card.
|
|
862
|
-
for (const
|
|
1059
|
+
for (const raw of items) {
|
|
1060
|
+
const item = await enrichPlanItem(raw, { objectiveGet: api.objectiveGet, agents: api.agents, orgs: api.orgs, cache: (R.current.planCache ||= new Map()) });
|
|
1061
|
+
dispatchEv({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
|
|
1062
|
+
}
|
|
863
1063
|
} catch (e) {
|
|
864
1064
|
N.fails++; // counted apart from an empty inbox
|
|
865
1065
|
const msg = e?.message || 'failed';
|
|
@@ -926,6 +1126,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
926
1126
|
R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
|
|
927
1127
|
r = { ...r, rows: selectRows(r.rows, { all: R0.all }) };
|
|
928
1128
|
R0.rows = r.rows;
|
|
1129
|
+
DC.current.watchRoster(rowsWithoutLoopCards(R0.allRows, (id) => LC.current.has(id))).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); // an objective or loop that just finished or failed summons its dragon (companion mode on, not while typing)
|
|
929
1130
|
const at = r.rows.findIndex(x => x.key === prevKey);
|
|
930
1131
|
R0.sel = at >= 0 ? at : Math.min(R0.sel, Math.max(0, r.rows.length - 1));
|
|
931
1132
|
if (!r.rows.length) { R0.focus = false; R0.peek = false; R0.confirm = null; }
|
|
@@ -1008,6 +1209,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1008
1209
|
d.push({ every: FRAME_MS });
|
|
1009
1210
|
if (s0.divider.endedAt != null) d.push({ at: s0.divider.endedAt + (s0.divider.phase === 'done' ? GREEN_MS : RED_MS) + 1 });
|
|
1010
1211
|
}
|
|
1212
|
+
if (DC.current.animating()) d.push({ every: motionOn && !mono() ? FRAME_MS : 500 }); // companion dragon: paced like the classic one, frames come from the cache (dragon/frames.js)
|
|
1011
1213
|
if (dragonActive(now, !motionOn || mono())) d.push({ every: motionOn && !mono() ? 40 : 500 }); // /dragon and the cinematic moods (qmotion DragonLive)
|
|
1012
1214
|
if (motionOn && fxBusy(now)) d.push({ every: FRAME_MS }); // reveals in flight
|
|
1013
1215
|
const sl = R.current.sleep;
|
|
@@ -1017,6 +1219,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1017
1219
|
return d;
|
|
1018
1220
|
};
|
|
1019
1221
|
React.useEffect(() => { if (!R.current.closed) clockRef.current.schedule(demands()); });
|
|
1222
|
+
// The companion dragon appears and leaves outside any App render (its own leaf and timers): re-arm the clock when it does, or it would get no frames until something else rendered.
|
|
1223
|
+
const rearm = React.useRef(null); rearm.current = () => { if (!R.current.closed) clockRef.current.schedule(demands()); };
|
|
1224
|
+
React.useEffect(() => DC.current.subscribe(() => rearm.current()), []);
|
|
1020
1225
|
|
|
1021
1226
|
const fxEnd = (e) => Math.max(e.at + revealSpan(e.timing) * 1000, e.settleAt != null ? e.settleAt + revealSpan(TIMING.settle) * 1000 : 0);
|
|
1022
1227
|
const fxActive = (item, now) => { const e = fxRef.current.get(item.id); return Boolean(e) && now < fxEnd(e); };
|
|
@@ -1153,6 +1358,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1153
1358
|
queueMicrotask(() => reconcileAsks());
|
|
1154
1359
|
return true;
|
|
1155
1360
|
};
|
|
1361
|
+
// TUI-WALK (owner 2026-10-19: he always runs /approvals again to confirm it is empty): the walk ends with a FRESH server
|
|
1362
|
+
// read, never the local count. Empty is only ever claimed after a successful fetch.
|
|
1363
|
+
const walkRecheck = async (W) => {
|
|
1364
|
+
let items;
|
|
1365
|
+
try { items = await api.approvals(); }
|
|
1366
|
+
catch (e) { say(`⚠ couldn't re-check approvals (${clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80)}); /approvals to look again`, 'warn'); return; }
|
|
1367
|
+
const gone = walkAnsweredIds();
|
|
1368
|
+
const open = (Array.isArray(items) ? items : []).filter(x => x && !gone.has(x.id));
|
|
1369
|
+
if (!open.length) { say('✔ Nothing else is currently waiting for you.', 'ok'); return; }
|
|
1370
|
+
const fresh = open.filter(x => !W.ids.includes(x.id)).length;
|
|
1371
|
+
if (fresh) say(`⏸ ${fresh} more arrived while you were answering · /approvals`, 'warn');
|
|
1372
|
+
else say(`⏸ ${open.length} still waiting (skipped) · /approvals`, 'warn');
|
|
1373
|
+
};
|
|
1156
1374
|
const walkShow = () => {
|
|
1157
1375
|
endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
|
|
1158
1376
|
const W = R.current.pendingWalk; if (!W) return;
|
|
@@ -1166,7 +1384,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1166
1384
|
if (walkGoneElsewhere(W, id)) { W.done.add(id); say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
|
|
1167
1385
|
nextId = id; break;
|
|
1168
1386
|
}
|
|
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; }
|
|
1387
|
+
if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); walkRecheck(W); queueMicrotask(() => reconcileAsks()); return; }
|
|
1170
1388
|
const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
|
|
1171
1389
|
const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
|
|
1172
1390
|
W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
|
|
@@ -1237,7 +1455,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1237
1455
|
}
|
|
1238
1456
|
R.current.lastAnswered = null;
|
|
1239
1457
|
R.current.walkSend = true;
|
|
1240
|
-
try { await command(`/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
|
|
1458
|
+
try { await command(it.__approval ? (rep.key === 'y' ? `/approve ${n}` : `/deny ${n}${rep.note ? ` ${rep.note}` : ''}`) : `/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
|
|
1241
1459
|
if (R.current.lastAnswered === it.id) {
|
|
1242
1460
|
walkAnsweredIds().add(it.id);
|
|
1243
1461
|
W.done.add(it.id); W.answered.push({ title: it.title, word: `${it.payload.options.find(o => o.key === rep.key).label}${rep.note ? ` (note: ${rep.note.length} characters)` : ''}` });
|
|
@@ -1257,7 +1475,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1257
1475
|
}
|
|
1258
1476
|
await respondWithStepUp(card, action, { proactive, reason, bulk });
|
|
1259
1477
|
// 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 });
|
|
1478
|
+
dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
|
|
1261
1479
|
if (demoLabel) { B.current = initialBuffer(); dispatch({ type: 'notify', patch: { approvals: 0, error: null } }); } // (a stray early keystroke is not left in the composer)
|
|
1262
1480
|
if (action === 'approve' && card.tool !== 'hat.switch' && !card.foreign && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
|
|
1263
1481
|
return { ok: true };
|
|
@@ -1432,10 +1650,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1432
1650
|
say(handoffAnsweredNote(a.h), 'dim');
|
|
1433
1651
|
bump(n => n + 1);
|
|
1434
1652
|
}
|
|
1653
|
+
// QUOXON objective offer: the same path as `quox objective create <goal> --checkpoint` + start. Offered, never auto-run.
|
|
1654
|
+
async function runObjective(a) {
|
|
1655
|
+
say('Making the objective…', 'dim');
|
|
1656
|
+
try {
|
|
1657
|
+
if (typeof api.startObjective !== 'function') throw new Error('this build cannot create objectives from here');
|
|
1658
|
+
const o = await api.startObjective(a.o.goal, a.o.loop || null);
|
|
1659
|
+
say(objectiveStartedNote(o.id), 'dim');
|
|
1660
|
+
say(objectiveWatchLine(o.id), 'dim');
|
|
1661
|
+
if (a.o.loop && api.loopEvents) LC.current.watch(o.id, { title: a.o.goal }); // QLOOP-LIVE: the loop's live card appears in the chat stream
|
|
1662
|
+
// Report back into this conversation when a plan objective ends (the owner inbox item covers a closed window).
|
|
1663
|
+
if (!a.o.loop && typeof api.objectiveGet === 'function') {
|
|
1664
|
+
watchObjective({ get: () => api.objectiveGet(o.id), say: (t) => { say(t, 'dim'); bump(n => n + 1); }, stopped: () => R.current.closed === true }).catch(() => {});
|
|
1665
|
+
}
|
|
1666
|
+
} catch (e) { say(objectiveFailedNote(a.o.goal, e), 'dim'); }
|
|
1667
|
+
bump(n => n + 1);
|
|
1668
|
+
}
|
|
1435
1669
|
async function consumeTurn(turnId, open, { keepOnDown = false } = {}) {
|
|
1436
1670
|
R.current.caps.steer = true; R.current.steerNoted = false; // steer support is learned per turn (provider paths differ)
|
|
1437
1671
|
R.current.durable = false; // learned from the server: v1.1 events carry a seq
|
|
1438
|
-
|
|
1672
|
+
dispatchEv({ type: 'turn.begin', turnId });
|
|
1439
1673
|
const ctrl = new AbortController();
|
|
1440
1674
|
R.current.abort = ctrl;
|
|
1441
1675
|
let offered = false, sawEvent = false, down = false;
|
|
@@ -1446,8 +1680,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1446
1680
|
sawEvent = true;
|
|
1447
1681
|
if (typeof ev?.seq === 'number') R.current.durable = true;
|
|
1448
1682
|
LG.current.observe(ev);
|
|
1449
|
-
|
|
1683
|
+
dispatchEv({ type: 'event', ev });
|
|
1450
1684
|
if (ev.type === 'tool.finished' && ev.suggest_command) { suggested = true; offerSuggestion(ev); }
|
|
1685
|
+
// QLOOP-LIVE: the chat's objective tool made a LOOP: open its live card in this conversation (the tool answer names the title, not the id)
|
|
1686
|
+
if (ev.type === 'tool.finished' && /objective/i.test(String(ev.tool || '')) && ev.status === 'ok' && /^\s*Loop "/.test(String(ev.result_summary || '')) && api.loopEvents && !(ev.call_id && SC.current.loopSeen?.has(ev.call_id))) {
|
|
1687
|
+
(SC.current.loopSeen ??= new Set()).add(ev.call_id);
|
|
1688
|
+
LC.current.discover({ title: (/^\s*Loop "(.*?)(?:"|$)/.exec(String(ev.result_summary)) || [])[1] || '' }).catch(() => {});
|
|
1689
|
+
}
|
|
1690
|
+
if (ev.type === 'turn.objective_suggested') { const ob = objectiveFromEvent(ev, SN.current.agent || (ev.objective_suggested?.mode === 'loop' && ev.objective_suggested?.from === 'quox' ? 'quox' : null)); if (ob) R.current.objectivePending = { o: ob, text: R.current.lastSent || '' }; } // QUOXON objective offer; unattached chat is Quox itself, so its keep-going offer still shows
|
|
1451
1691
|
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
1692
|
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
1693
|
// Z2H-3B: a server default that is not connected must not linger in the status bar.
|
|
@@ -1468,7 +1708,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1468
1708
|
if (S.current.turn?.id === turnId) {
|
|
1469
1709
|
// stream closed without a terminal event
|
|
1470
1710
|
// never claim completion the server did not confirm
|
|
1471
|
-
|
|
1711
|
+
dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
|
|
1472
1712
|
}
|
|
1473
1713
|
} catch (e) {
|
|
1474
1714
|
if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
|
|
@@ -1479,15 +1719,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1479
1719
|
say(`Blocked by policy (${e.body.block_id}). A person can override this one action: /override last <reason>`, 'warn');
|
|
1480
1720
|
}
|
|
1481
1721
|
// 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)
|
|
1722
|
+
if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers) dispatchEv({ type: 'event', ev: { type: 'aocl.layer', ...l } });
|
|
1483
1723
|
const why = sawEvent ? explainTermination(e) : null;
|
|
1484
|
-
if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running')
|
|
1724
|
+
if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running') dispatchEv({ type: 'event', ev: { type: 'tool.finished', call_id: b.callId, tool: b.tool, status: 'interrupted', result_summary: 'connection lost before a result; verify before retrying' } });
|
|
1485
1725
|
const dead = !why && (e?.status === 401 || isSessionDead(e));
|
|
1486
|
-
|
|
1726
|
+
dispatchEv({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
|
|
1487
1727
|
if (dead) markSignedOut(e?.reason || null);
|
|
1488
1728
|
if (why) say(RETRY_HINT, 'warn');
|
|
1489
1729
|
// A lost reattach is not a failed turn: the server may still be finishing it (C5).
|
|
1490
|
-
if (S.current.turn?.id === turnId)
|
|
1730
|
+
if (S.current.turn?.id === turnId) dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
|
|
1491
1731
|
}
|
|
1492
1732
|
} finally {
|
|
1493
1733
|
clearTimeout(R.current.fallbackTimer);
|
|
@@ -1499,6 +1739,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1499
1739
|
// QUOXON handoff offer: the attached agent lacks the tools for this ask. The handoff card answers the actual question,
|
|
1500
1740
|
// so it wins over the agent's own suggested command (owner live 22:42: "Run /vm list?" took the slot and the handoff
|
|
1501
1741
|
// card was dropped silently). Nothing is dropped silently: a superseded or skipped card says so in one line.
|
|
1742
|
+
const op = R.current.objectivePending; R.current.objectivePending = null;
|
|
1743
|
+
const opOk = Boolean(op && !down && !ctrl.signal.aborted && !planDone);
|
|
1744
|
+
if (opOk) { R.current.handoffPending = null; R.current.suggestLine = null; R.current.suggestAsk = null; } // one ask, not two: the objective card replaces the handoff and the agent's own command
|
|
1502
1745
|
const hp = R.current.handoffPending; R.current.handoffPending = null;
|
|
1503
1746
|
const hpOk = Boolean(hp && !down && !ctrl.signal.aborted && hp.text);
|
|
1504
1747
|
if (hpOk && !planDone && R.current.suggestLine) {
|
|
@@ -1506,8 +1749,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1506
1749
|
R.current.suggestLine = null; R.current.suggestAsk = null;
|
|
1507
1750
|
say(handoffSupersededNote(hp.h, line), 'dim');
|
|
1508
1751
|
}
|
|
1509
|
-
const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone);
|
|
1752
|
+
const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone) || opOk;
|
|
1510
1753
|
flushSuggestion();
|
|
1754
|
+
if (opOk) { R.current.objectiveAsk = { ...op, at: Date.now() }; say(objectiveAskText(op.o, cols), 'ask'); }
|
|
1511
1755
|
if (hpOk && planDone) say(handoffSkippedNote(hp.h), 'dim');
|
|
1512
1756
|
else if (hpOk) {
|
|
1513
1757
|
const hs = (R.current.handoffStreak ??= createHandoffStreak()).offered(hp.h.from);
|
|
@@ -1616,6 +1860,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1616
1860
|
R.current.fallbackTimer = setTimeout(local, INTERRUPT_FALLBACK_MS);
|
|
1617
1861
|
}
|
|
1618
1862
|
|
|
1863
|
+
// Stop the turn now, locally and for good: tell the server (best effort), abort the stream, and always leave busy cleared.
|
|
1864
|
+
function stopTurnNow() {
|
|
1865
|
+
const s = S.current, turn = s.turn;
|
|
1866
|
+
try {
|
|
1867
|
+
if (R.current.waitingServer) { R.current.abort?.abort(); return; }
|
|
1868
|
+
if (!turn) return;
|
|
1869
|
+
clearTimeout(R.current.fallbackTimer);
|
|
1870
|
+
try { Promise.resolve(api.interrupt?.(s.ctx.conversationId, turn.id)).catch(() => {}); } catch { /* best effort */ }
|
|
1871
|
+
R.current.abort?.abort();
|
|
1872
|
+
} finally {
|
|
1873
|
+
if (turn && S.current.turn?.id === turn.id) dispatch({ type: 'stopped.local' });
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1619
1877
|
// ---- L1 slash commands (models in slash/*.js) ------------------------------------------------
|
|
1620
1878
|
const help = (model) => dispatch({ type: 'help', model });
|
|
1621
1879
|
const leaveArmed = (kind) => { // a running turn would be disconnected: ask once, a second ask within 5 s goes ahead
|
|
@@ -1700,12 +1958,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1700
1958
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
1701
1959
|
let turns;
|
|
1702
1960
|
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'}` }); }
|
|
1961
|
+
// RESUME: the attachment and the conversation must name the same agent. After a /restart the replayed attach is what the
|
|
1962
|
+
// window held when it was saved; if the conversation was with someone else, warn once and start fresh rather than mix.
|
|
1963
|
+
const wasAgent = st.fromRestart && /^agent:/.test(restartAttach || '') ? restartAttach.slice(6) : null;
|
|
1964
|
+
if (st.fromRestart && wasAgent && row.agent && row.agent !== wasAgent) return say(`That conversation was with ${row.agent}, but this window was attached to ${wasAgent}. Starting a fresh conversation with ${wasAgent}; /resume brings the ${row.agent} one back.`, 'warn');
|
|
1703
1965
|
const t = turnsToHistory(turns);
|
|
1966
|
+
const shownTitle = cleanTitle(row.title) && row.title !== '(untitled)' ? cleanTitle(row.title) : (titleFromTurns(turns) || '(untitled)'); // never rendered UI
|
|
1704
1967
|
dispatch({ type: 'ctx', ctx: { conversationId: row.id } });
|
|
1705
1968
|
SN.current.name = loadNames(namesPath)[row.id]?.name || null; SN.current.saved = null;
|
|
1706
1969
|
if (!t.total && SN.current.name) SN.current.saved = `${row.id}|${SN.current.name}`; // nothing found: do not re-stamp the saved name
|
|
1707
|
-
say(`Resumed "${clip(
|
|
1970
|
+
say(`Resumed "${clip(shownTitle, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
|
|
1708
1971
|
dispatch({ type: 'history.add', items: t.items, answers: t.answers });
|
|
1972
|
+
if (!st.fromRestart && row.agent && row.agent !== SN.current.agent) { R.current.noGreeting = true; await command(`/attach agent:${row.agent}`); } // next turns go to the agent this conversation was with
|
|
1709
1973
|
if (!t.total && agentLogin && row.byName) {
|
|
1710
1974
|
const h = humanProfiles.find(x => x !== profile);
|
|
1711
1975
|
return say(`${row.byName} was saved by another profile. Start with quox -p ${h || '<human profile>'} to resume it.`, 'warn');
|
|
@@ -1713,9 +1977,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1713
1977
|
say(t.total ? `Showing the last ${t.shown} of ${t.total} stored messages. Your next message continues this conversation.` : 'The server has no stored messages for it (it keeps them 7 days). Your next message continues it anyway.', 'dim');
|
|
1714
1978
|
return reattachIfRunning(row.id);
|
|
1715
1979
|
}
|
|
1716
|
-
async function resumeCmd(q) {
|
|
1980
|
+
async function resumeCmd(q, { fromRestart = false } = {}) {
|
|
1717
1981
|
if (!api.conversations || !api.conversationTurns) return say('Resume is not available in this mode.', 'warn');
|
|
1718
|
-
let rows; const st = {};
|
|
1982
|
+
let rows; const st = { fromRestart };
|
|
1719
1983
|
try {
|
|
1720
1984
|
const [list, index] = await reconnecting(() => Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]), st);
|
|
1721
1985
|
rows = resumeRows(list, index, loadNames(namesPath));
|
|
@@ -1770,6 +2034,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1770
2034
|
bump(n => n + 1); return true;
|
|
1771
2035
|
}
|
|
1772
2036
|
function dragonFly() { // one pass of the dragon in flight in the live region; Esc stops it, a new call restarts it
|
|
2037
|
+
const room = flyRoom(termRows(stdout), chromeH.current ?? 10); if (!room.ok) return say(room.note, 'warn'); // too small: say so, never silently nothing
|
|
1773
2038
|
R.current.fly = { startedAt: Date.now(), still: !motionOn || mono() };
|
|
1774
2039
|
bump(n => n + 1); return true;
|
|
1775
2040
|
}
|
|
@@ -1882,6 +2147,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1882
2147
|
}
|
|
1883
2148
|
|
|
1884
2149
|
function cliSlashRun(hit) {
|
|
2150
|
+
if (hit.error) return say(`${hit.error}`, 'warn');
|
|
1885
2151
|
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
2152
|
const bang = { kind: 'quox', args: hit.args, cmd: hit.display };
|
|
1887
2153
|
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.' }); }
|
|
@@ -1980,12 +2246,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1980
2246
|
}
|
|
1981
2247
|
|
|
1982
2248
|
async function command(line) {
|
|
1983
|
-
if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/
|
|
2249
|
+
if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/devnote');
|
|
1984
2250
|
const [cmd0, arg] = line.trim().split(/\s+/);
|
|
1985
2251
|
const cmd = cmd0 === '/agent' ? '/agents' : cmd0; // /agent is the same command
|
|
1986
2252
|
if (!['/afk', '/sleep', '/back', '/away'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
|
|
1987
|
-
if (cmd !== '/
|
|
1988
|
-
if (cmd !== '/
|
|
2253
|
+
if (cmd !== '/devnote' && cmd !== '/context') SCX.current.command(line.trim());
|
|
2254
|
+
if (cmd !== '/devnote') transcript?.command(/^\/(authenticate|authorise|authorize)$/.test(cmd) && !/^[\d,\s-]*$/.test(line.trim().replace(/^\S+\s*/, '')) ? cmd : line); // QUOXON-STEPUP: never record a stray argument that might be a PIN
|
|
1989
2255
|
const restArg = line.trim().replace(/^\S+\s*/, '');
|
|
1990
2256
|
touchSeen();
|
|
1991
2257
|
if (TIPS.some(x => tryCommand(x) === cmd) && !TP.current.used.includes(cmd)) { TP.current.used = [...TP.current.used, cmd]; api.saveSetting?.('tipsUsed', TP.current.used); } // using a thing retires its tip
|
|
@@ -1998,8 +2264,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1998
2264
|
objArg = p.name;
|
|
1999
2265
|
}
|
|
2000
2266
|
if (OBJECT_CMDS.has(cmd) && !(cmd === '/rules' && arg)) { // inline object views (objects/): a card into history, or an honest note
|
|
2001
|
-
const r = await runObjectCommand(cmd, objArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks], conversationId: S.current.ctx?.conversationId });
|
|
2002
|
-
if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : say(
|
|
2267
|
+
const r = await runObjectCommand(cmd, objArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks], conversationId: S.current.ctx?.conversationId, cols });
|
|
2268
|
+
if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : r.lines ? r.lines.forEach(l => say(l, r.tone)) : say(r.note, r.tone); // wrap, never clip: notes carry ids and hints
|
|
2003
2269
|
}
|
|
2004
2270
|
switch (cmd) {
|
|
2005
2271
|
case '/copy': {
|
|
@@ -2009,13 +2275,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2009
2275
|
return say(`copied ${r.text.length.toLocaleString('en-US')} chars to the clipboard (needs a terminal that allows OSC 52)`, 'dim');
|
|
2010
2276
|
}
|
|
2011
2277
|
case '/share': return shareCommand(arg);
|
|
2012
|
-
case '/
|
|
2278
|
+
case '/devnote': {
|
|
2013
2279
|
if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
|
|
2014
|
-
if (!restArg) return say('Usage: /
|
|
2280
|
+
if (!restArg) return say('Usage: /devnote <text> (or // text). Nothing to note.', 'warn');
|
|
2015
2281
|
if (!transcript?.note) return say('notes need dev transcripts on (touch ~/.config/quox/dev-transcripts)', 'warn');
|
|
2016
2282
|
transcript.note(restArg.slice(0, 2000));
|
|
2017
2283
|
return say('noted for the observer', 'dim');
|
|
2018
2284
|
}
|
|
2285
|
+
case '/note': { // TUI-NOTE: a NotesQ note, same backend as `quox notes new`
|
|
2286
|
+
if (!restArg) return say('Usage: /note <text> (saves a NotesQ note; /notes lists them).', 'warn');
|
|
2287
|
+
if (!api.createNote) return say('NotesQ is not available in this window. Run quox notes new from a signed-in terminal, or enable NotesQ for your org in the dashboard.', 'warn');
|
|
2288
|
+
const r = await api.createNote(restArg);
|
|
2289
|
+
if (!r.ok) return say(r.message, 'warn');
|
|
2290
|
+
return say(`Saved note "${r.title}" (${r.id}). /notes lists your notes (or quox notes ls).`, 'ok');
|
|
2291
|
+
}
|
|
2292
|
+
case '/notes': return cliSlashRun(resolveCliSlash('/notes', 'ls'));
|
|
2293
|
+
case '/contact': return cliSlashRun(resolveCliSlash('/contact', restArg)); // registered command; runs as the quox contacts leaf
|
|
2019
2294
|
case '/rename': {
|
|
2020
2295
|
if (!restArg) return say(SN.current.name ? `Session name: ${SN.current.name}` : 'This session has no name. Use /rename <name>.');
|
|
2021
2296
|
const n = cleanName(restArg);
|
|
@@ -2033,7 +2308,21 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2033
2308
|
const m = /^(\S+)\s+([\s\S]*\S[\s\S]*)$/.exec(restArg || '');
|
|
2034
2309
|
if (!m) return say('Usage: /say <slot>[,<slot>...] <text>', 'warn');
|
|
2035
2310
|
const to = [...new Set(m[1].split(',').map(x => x.trim().toLowerCase()).filter(Boolean))];
|
|
2036
|
-
if (to.length < 2)
|
|
2311
|
+
if (to.length < 2) { // SAY-LAST-TARGET: a first word that is not a slot is part of the message when a recent target exists
|
|
2312
|
+
const first = to[0] || m[1].toLowerCase();
|
|
2313
|
+
let rows = [];
|
|
2314
|
+
if (api.slots) { try { rows = await api.slots(); } catch { rows = []; } }
|
|
2315
|
+
const known = !looksLikeAddress(m[1]) && !!resolveSayTarget(first, Array.isArray(rows) ? rows : [], SC.current.senders);
|
|
2316
|
+
const last = R.current.lastSay;
|
|
2317
|
+
if (!known && (looksLikeAddress(m[1]) || last)) {
|
|
2318
|
+
if (last && Date.now() - last.at < 2 * 3600000) {
|
|
2319
|
+
R.current.sayLastAsk = { slot: last.slot, text: restArg.trim(), at: Date.now() };
|
|
2320
|
+
return say(askText(`Send to ${last.slot}?`, { keys: 'y send · n cancel', action: `send the whole message to ${last.slot}` }), 'ask');
|
|
2321
|
+
}
|
|
2322
|
+
return say(sayWhoText(rows), 'warn');
|
|
2323
|
+
}
|
|
2324
|
+
return sendToSlot(first, m[2].trim());
|
|
2325
|
+
}
|
|
2037
2326
|
// QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
|
|
2038
2327
|
const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
|
|
2039
2328
|
const head = `(to ${slots.join(', ')}) `;
|
|
@@ -2078,6 +2367,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2078
2367
|
return takeSlot(nm, take);
|
|
2079
2368
|
}
|
|
2080
2369
|
case '/attach': {
|
|
2370
|
+
const greetThis = takeGreeting(R.current); // read and clear first: an early return below must not leave a quiet flag armed for the next typed /attach
|
|
2081
2371
|
if (!restArg) return say(SN.current.agent ? `Attached to ${SN.current.agent}. /detach returns to CommanderQ.` : 'Talking to CommanderQ. Use /attach <agent> to pick one.');
|
|
2082
2372
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2083
2373
|
if (!api.agents) return say('Agents are not available in this mode.', 'warn');
|
|
@@ -2094,7 +2384,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2094
2384
|
const m = pt.kind === 'slot' ? { matches: [] } : matchAgent(pt.name, list);
|
|
2095
2385
|
const slotLive = !!(slotRow && slotRow.holder_session);
|
|
2096
2386
|
let choice = null; // 'slot' | 'agent'
|
|
2097
|
-
if (slotRow && m.agent) choice = pt.kind === 'slot'
|
|
2387
|
+
if (slotRow && m.agent) choice = pt.kind === 'slot' ? 'slot' : 'agent'; // a bare name that is both defaults to the AGENT; slot: is the explicit override
|
|
2098
2388
|
else if (slotRow) choice = 'slot';
|
|
2099
2389
|
else if (m.agent) choice = 'agent';
|
|
2100
2390
|
if (!choice) {
|
|
@@ -2107,7 +2397,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2107
2397
|
const both = !!(slotRow && m.agent);
|
|
2108
2398
|
if (choice === 'slot') {
|
|
2109
2399
|
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;
|
|
2400
|
+
SN.current.agent = null; SN.current.slotTarget = slotRow.name; DC.current.detach();
|
|
2111
2401
|
where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
|
|
2112
2402
|
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
2403
|
const lbl = `slot ${slotRow.name}${host ? ` (${host})` : ''}`;
|
|
@@ -2119,11 +2409,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2119
2409
|
}
|
|
2120
2410
|
SN.current.slotTarget = null;
|
|
2121
2411
|
SN.current.agent = m.agent.id; where.current = { ...where.current, agent: m.agent.id }; bump(x => x + 1);
|
|
2122
|
-
|
|
2123
|
-
say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ
|
|
2412
|
+
DC.current.attach(m.agent.id, { greet: greetThis }).then(() => { const n = DC.current.takeArtNotice(); if (n) say(n, 'warn'); });
|
|
2413
|
+
say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.`, 'ok');
|
|
2414
|
+
if (both && pt.kind !== 'agent') say(`${pt.name} is also a slot: /attach slot:${pt.name} for that`, 'warn');
|
|
2124
2415
|
if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
|
|
2125
2416
|
return undefined;
|
|
2126
2417
|
}
|
|
2418
|
+
case '/evidence': case '/ward': case '/volt': {
|
|
2419
|
+
for (const l of await runEvidenceCommand({ cmd, api, conversationId: S.current.ctx?.conversationId, cols })) say(l.text, l.tone);
|
|
2420
|
+
return undefined;
|
|
2421
|
+
}
|
|
2422
|
+
case '/envelope': case '/envelopes': case '/aee': { // pointers to /proof (the envelope ids) and /evidence (the bundle)
|
|
2423
|
+
say('Envelope ids are what /proof lists; /evidence exports them all as a signed bundle.', 'dim');
|
|
2424
|
+
return command(`/proof${restArg ? ` ${restArg}` : ''}`);
|
|
2425
|
+
}
|
|
2127
2426
|
case '/peek': { // PEEK: the same lookup as `quox peek <id>`, in this window's org
|
|
2128
2427
|
const pid = restArg.trim().split(/\s+/)[0] || '';
|
|
2129
2428
|
if (!api.peek) return say('Peek is not available in this mode. In a terminal: quox peek <id>', 'warn');
|
|
@@ -2180,6 +2479,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2180
2479
|
if (!api.connections) return say('Connections are not available in this mode.', 'warn');
|
|
2181
2480
|
const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
|
|
2182
2481
|
for (const [t, tone] of connectionLines(vault, caps)) say(clip(t, cols - 4), tone);
|
|
2482
|
+
// LAZY-AI-CLI: no Claude Code on this server = the Claude sign-in cannot work yet; offer it as a y/N card here
|
|
2483
|
+
if (api.providerCliStatus && missingTools(await api.providerCliStatus()).includes('claude-code')) { R.current.installQueue = []; return offerInstall('claude-code'); }
|
|
2183
2484
|
return undefined;
|
|
2184
2485
|
}
|
|
2185
2486
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
@@ -2229,6 +2530,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2229
2530
|
return undefined;
|
|
2230
2531
|
}
|
|
2231
2532
|
let r = selectModel(res, restArg, caps);
|
|
2533
|
+
// LAZY-AI-CLI: the Claude sign-in runs through the claude binary; no binary on this server = offer to install it now
|
|
2534
|
+
if ((r.choice?.via === 'subscription' || /^(claude-cli|claude-subscription|subscription|claude-code)\b/i.test(String(restArg).trim())) && api.providerCliStatus) {
|
|
2535
|
+
const st = await api.providerCliStatus();
|
|
2536
|
+
if (missingTools(st).includes('claude-code')) { R.current.installQueue = []; return offerInstall('claude-code'); }
|
|
2537
|
+
}
|
|
2232
2538
|
let installed = null;
|
|
2233
2539
|
if (r.ok && r.choice?.provider === 'ollama' && api.ollamaModels) { // B3: check against what that Ollama has installed
|
|
2234
2540
|
r = selectOllamaModel(await api.ollamaModels(), r.choice.model || null);
|
|
@@ -2256,6 +2562,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2256
2562
|
return undefined;
|
|
2257
2563
|
}
|
|
2258
2564
|
case '/row': return RW.current.handle(restArg);
|
|
2565
|
+
case '/qpe': { // TUI-QPE: how many proof engine rows show (a CLI setting)
|
|
2566
|
+
const r = qpeCommand(restArg, api.settings?.() || {});
|
|
2567
|
+
if (r.save != null) { api.saveSetting?.('qpeRows', r.save); bump(n => n + 1); }
|
|
2568
|
+
return say(r.text, r.tone);
|
|
2569
|
+
}
|
|
2570
|
+
case '/deck': { // provided by the quoxdeck extension when loaded; otherwise says where it comes from (the registry entry carries ext)
|
|
2571
|
+
if (await extDispatch(cmd, restArg)) return undefined;
|
|
2572
|
+
return say('/deck comes from the quoxdeck extension, which is not loaded. npm i -g quoxdeck, then /ext on quoxdeck.', 'warn');
|
|
2573
|
+
}
|
|
2574
|
+
case '/ext': { // EXTENSIONS: /ext list|on|off <name>
|
|
2575
|
+
const [w, nm] = restArg.trim().split(/\s+/);
|
|
2576
|
+
const list = readExtList();
|
|
2577
|
+
if (!w || w === 'list') {
|
|
2578
|
+
if (!list.length) return say('No extensions listed. Install one (npm i -g <pkg>) and list it: quox ext add <pkg>.');
|
|
2579
|
+
return say(list.map(x => `${x.name}: ${DK.current.isLoaded(x.name) ? `on (found: ${DK.current.where(x.name)})` : x.enabled ? 'listed, not loaded' : 'off'}`).join('\n'));
|
|
2580
|
+
}
|
|
2581
|
+
if ((w === 'on' || w === 'off') && nm) {
|
|
2582
|
+
if (w === 'on') { if (!list.some(x => x.name === nm)) extAdd(nm); else extSetEnabled(nm, true); await extLoad(nm, false); return undefined; }
|
|
2583
|
+
extSetEnabled(nm, false); const r = DK.current.unload(nm); return say(r.text, 'ok');
|
|
2584
|
+
}
|
|
2585
|
+
return say('Usage: /ext list|on|off <name>', 'warn');
|
|
2586
|
+
}
|
|
2259
2587
|
case '/watch': return KC.current.watch(restArg);
|
|
2260
2588
|
case '/automate': return KC.current.automate(restArg);
|
|
2261
2589
|
case '/crystallize': return KC.current.crystallize(restArg);
|
|
@@ -2277,9 +2605,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2277
2605
|
case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': case '/fleet': case '/prove': case '/replay': return undefined; // answered above (runObjectCommand)
|
|
2278
2606
|
case '/agent': case '/agents': return undefined; // answered above (picker, tools, card)
|
|
2279
2607
|
case '/which': return showWhich(arg);
|
|
2280
|
-
case '/detach':
|
|
2608
|
+
case '/detach': {
|
|
2609
|
+
const hadAgent = Boolean(SN.current.agent);
|
|
2281
2610
|
SN.current.agent = null; SN.current.slotTarget = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
|
|
2282
|
-
|
|
2611
|
+
DC.current.detach();
|
|
2612
|
+
say('Back to CommanderQ.');
|
|
2613
|
+
const g = detachGreeting({ settings: api.settings?.() || {}, env, rows: termRows(stdout), chromeH: chromeH.current ?? 10, hadAgent }); // dragons on: the Q dragon greets the return
|
|
2614
|
+
if (g?.clip) { playDragon(g.clip, undefined); bump(n => n + 1); } else if (g?.note) say(g.note, 'dim');
|
|
2615
|
+
return true;
|
|
2616
|
+
}
|
|
2283
2617
|
case '/sessions': {
|
|
2284
2618
|
// SESSIONS (owner 2026-10-15): name and where first; ids only with `refs`.
|
|
2285
2619
|
const flags = new Set(String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean));
|
|
@@ -2444,8 +2778,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2444
2778
|
if (!m) return say(DRAGON_USAGE);
|
|
2445
2779
|
if (m.mode === 'explain') return dragonExplain();
|
|
2446
2780
|
if (m.mode === 'ask') return dragonAsk(m.question);
|
|
2781
|
+
if (m.mode === 'companion') {
|
|
2782
|
+
const r = companionCommand(m.arg, { settings: api.settings?.() || {}, plan: DC.current.plan(), artProblem: DC.current.artProblem() });
|
|
2783
|
+
if (r.save != null) { api.saveSetting?.('dragonCompanion', r.save); DC.current.refresh(); }
|
|
2784
|
+
say(r.text, r.tone);
|
|
2785
|
+
if (r.invoke && await DC.current.invoke()) bump(n => n + 1); // asking about the companion is also calling it
|
|
2786
|
+
return true;
|
|
2787
|
+
}
|
|
2447
2788
|
if (m.mode === 'sleep') return dragonSleep();
|
|
2448
2789
|
if (m.mode === 'fly') return dragonFly();
|
|
2790
|
+
if (m.mode === 'pick') { // plain /dragon: the ATTACHED agent's own dragon when it has one, else the classic Quox dragon
|
|
2791
|
+
const shown = await DC.current.invoke();
|
|
2792
|
+
if (shown) { bump(n => n + 1); return true; }
|
|
2793
|
+
const who = SN.current.agent;
|
|
2794
|
+
if (who && !dragonForAgent(who)) say(`${who[0].toUpperCase()}${who.slice(1)} has no dragon of its own; here's Quox.`, 'dim');
|
|
2795
|
+
}
|
|
2796
|
+
const room = clipRoom(m.mode === 'idle' ? 'classic' : m.mode, termRows(stdout), chromeH.current ?? 10); if (!room.ok) return say(room.note, 'warn');
|
|
2449
2797
|
playDragon(m.mode, undefined); bump(n => n + 1); return true;
|
|
2450
2798
|
}
|
|
2451
2799
|
case '/dragon-fly': return arg ? say('Usage: /dragon-fly', 'warn') : dragonFly();
|
|
@@ -2484,7 +2832,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2484
2832
|
case '/about': return dispatch({ type: 'welcome', version: packageVersion() });
|
|
2485
2833
|
case '/help': return dispatch({ type: 'help', model: helpModel(arg, R.current.caps) });
|
|
2486
2834
|
case '/clear': case '/new': case '/reset': {
|
|
2487
|
-
if (S.current.turn)
|
|
2835
|
+
if (S.current.turn || R.current.waitingServer) stopTurnNow(); // cancel it exactly as Esc does, so busy and the divider reset
|
|
2488
2836
|
const prev = S.current.ctx.conversationId;
|
|
2489
2837
|
dispatch({ type: 'ctx', ctx: { conversationId: null } });
|
|
2490
2838
|
SN.current.name = null; SN.current.saved = null;
|
|
@@ -2493,6 +2841,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2493
2841
|
setEpoch(e => e + 1);
|
|
2494
2842
|
return say(prev ? 'Previous conversation saved. /resume to go back.' : 'New conversation.');
|
|
2495
2843
|
}
|
|
2844
|
+
case '/stop': case '/cancel': { // same as Esc
|
|
2845
|
+
if (!S.current.turn && !R.current.waitingServer) return say('Nothing is running.', 'dim');
|
|
2846
|
+
stopTurnNow();
|
|
2847
|
+
return say('Stopped.', 'ok');
|
|
2848
|
+
}
|
|
2496
2849
|
case '/aocl': return dispatch({ type: 'aocl.table' });
|
|
2497
2850
|
case '/verbose':
|
|
2498
2851
|
if (!['off', 'on', 'full'].includes(arg)) return say(`verbose is ${S.current.verbose}. Use /verbose off|on|full`);
|
|
@@ -2556,11 +2909,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2556
2909
|
const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
|
|
2557
2910
|
let list;
|
|
2558
2911
|
try { list = await api.orgs(); } catch (e) { return say(`Couldn't list your orgs: ${listFailure(e, profile)}`, 'warn'); }
|
|
2912
|
+
const openOrgPicker = (rowsIn, title, { confirm = false } = {}) => { // confirm: the explicit `/org switch` chooser keeps its y/N
|
|
2913
|
+
const orows = orgRows(rowsIn);
|
|
2914
|
+
const ow = Math.min(30, Math.max(4, ...orows.map(x => [...x.name].length)));
|
|
2915
|
+
PK.current.open({ title, rows: orows.map(x => ({ key: `${x.id === curId ? '* ' : ' '}${x.name.padEnd(ow)}`, text: `${x.slug || x.id}${x.role ? ` (${x.role})` : ''}` })), sel: Math.max(0, orows.findIndex(x => x.id === curId)),
|
|
2916
|
+
hint: confirm ? '↑↓ select · Enter switch (you confirm first) · Esc cancel' : 'Enter: switch this window only · d: also make it the default · ↑↓ select · Esc cancel',
|
|
2917
|
+
onChar: confirm ? undefined : (ch, i) => { if (ch !== 'd' && ch !== 'D') return false; switchOrgNow(orows[i], { quick: true, makeDefault: true }); PK.current.close(); return true; },
|
|
2918
|
+
onPick: (_, i) => (confirm ? (orows[i].id === curId ? say(`Already in ${orows[i].name}.`) : command(`/org switch ${orows[i].slug || orows[i].id}`)) : switchOrgNow(orows[i], { quick: true })) });
|
|
2919
|
+
};
|
|
2559
2920
|
if (!sub || sub === 'list' || (sub === 'switch' && !query)) { // a bare /org switch offers the chooser, never a Usage line
|
|
2560
2921
|
if (!list.length) return say('No orgs returned for this login.', 'warn');
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2922
|
+
openOrgPicker(list, 'Move this session to an org', { confirm: sub === 'switch' });
|
|
2923
|
+
return undefined;
|
|
2924
|
+
}
|
|
2925
|
+
if (!['yes', 'back', '-', 'switch'].includes(sub)) { // /org <name or slug fragment>: a unique match switches this window, else the picker opens filtered
|
|
2926
|
+
const q = restArg.trim();
|
|
2927
|
+
const m = matchOrg(q, list);
|
|
2928
|
+
if (m.org) return switchOrgNow(m.org, { quick: true });
|
|
2929
|
+
const words = q.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
2930
|
+
const part = orgRows(list).filter(o => words.every(w => `${o.name} ${o.slug || ''}`.toLowerCase().includes(w)));
|
|
2931
|
+
if (!part.length) return say(`No org of yours matches "${q}". /org lists them.`, 'warn');
|
|
2932
|
+
openOrgPicker(part, `Orgs matching "${q}"`);
|
|
2564
2933
|
return undefined;
|
|
2565
2934
|
}
|
|
2566
2935
|
if (sub === 'yes') {
|
|
@@ -2568,16 +2937,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2568
2937
|
if (!t) return say('Nothing to confirm. /org switch <name> first.', 'warn');
|
|
2569
2938
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
2570
2939
|
if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
|
|
2571
|
-
|
|
2572
|
-
try { done = await api.switchOrg(t.id); } catch (e) { return say(`Not switched. ${e?.message || 'The server refused.'} You are still in ${R0.orgName ?? org ?? 'the same org'}.`, 'warn'); }
|
|
2573
|
-
R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
|
|
2574
|
-
R0.orgId = done.id; R0.orgName = done.name;
|
|
2575
|
-
dispatch({ type: 'ctx', ctx: { conversationId: null } });
|
|
2576
|
-
SN.current.name = null; SN.current.saved = null;
|
|
2577
|
-
dispatch({ type: 'history.reset' });
|
|
2578
|
-
if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
2579
|
-
setEpoch(e => e + 1); bump(n => n + 1);
|
|
2580
|
-
return say(`Switched to ${done.name}. New conversation in that org.`, 'ok');
|
|
2940
|
+
return switchOrgNow(t);
|
|
2581
2941
|
}
|
|
2582
2942
|
if (sub === 'back' || sub === '-') { // owner 2026-10-15 "switch back": no "type /org switch <the org you were in>"
|
|
2583
2943
|
const p = R0.prevOrg;
|
|
@@ -2599,6 +2959,63 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2599
2959
|
if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
|
|
2600
2960
|
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
2961
|
}
|
|
2962
|
+
case '/todo': case '/todos': {
|
|
2963
|
+
if (!api.todoAdd || !api.todoList) return say('This server has no Action Tracker.', 'warn');
|
|
2964
|
+
const t = parseTodo(cmd, restArg);
|
|
2965
|
+
try {
|
|
2966
|
+
if (t.kind === 'usage') return say(t.text, 'warn');
|
|
2967
|
+
if (t.kind === 'add') return say(todoAddedLine(await api.todoAdd(t.text)), 'ok');
|
|
2968
|
+
if (t.kind === 'move') {
|
|
2969
|
+
if (!api.todoMove) return say('This server cannot move Action Tracker items.', 'warn');
|
|
2970
|
+
const board = await api.todoList();
|
|
2971
|
+
const many = (t.refs || [t.ref]).length > 1;
|
|
2972
|
+
for (const ref of (t.refs || [t.ref])) {
|
|
2973
|
+
const r = resolveRef(ref, TODO_MAP.current, board);
|
|
2974
|
+
if (r.error) { say(r.error, 'warn'); continue; }
|
|
2975
|
+
if (r.note) say(r.note);
|
|
2976
|
+
const moved = await api.todoMove(r.id, t.to);
|
|
2977
|
+
say(`${todoMovedLine({ ...moved, title: moved.title || r.title })}${many ? '' : ' /todos shows the new numbers.'}`, 'ok');
|
|
2978
|
+
}
|
|
2979
|
+
return undefined;
|
|
2980
|
+
}
|
|
2981
|
+
const board = await api.todoList();
|
|
2982
|
+
const numbered = numberBoard(board);
|
|
2983
|
+
TODO_MAP.current = numbered.map;
|
|
2984
|
+
return say(todoBoardText(board, numbered));
|
|
2985
|
+
} catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
|
|
2986
|
+
}
|
|
2987
|
+
case '/done': {
|
|
2988
|
+
if (!api.todoList || !api.todoMove) return say('This server cannot move Action Tracker items.', 'warn');
|
|
2989
|
+
const d = parseDone(restArg);
|
|
2990
|
+
if (d.kind === 'usage') return say(d.text, 'warn');
|
|
2991
|
+
try {
|
|
2992
|
+
const board = await api.todoList();
|
|
2993
|
+
for (const ref of d.refs) {
|
|
2994
|
+
const r = resolveRef(ref, TODO_MAP.current, board);
|
|
2995
|
+
if (r.error) { say(r.error, 'warn'); continue; }
|
|
2996
|
+
if (r.note) say(r.note);
|
|
2997
|
+
const moved = await api.todoMove(r.id, 'done');
|
|
2998
|
+
say(todoDoneLine(moved.title || r.title), 'ok');
|
|
2999
|
+
}
|
|
3000
|
+
return undefined;
|
|
3001
|
+
} catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
|
|
3002
|
+
}
|
|
3003
|
+
case '/plugin': case '/extensions': case '/extension': case '/addons': case '/apps': case '/plugins': {
|
|
3004
|
+
if (!api.pluginList) return say('This server has no plugin list.', 'warn');
|
|
3005
|
+
const want = /^(list|ls|all)?$/i.test(restArg.trim()) ? '' : restArg.trim().split(/\s+/)[0]; // "/extension list" is just the list
|
|
3006
|
+
try {
|
|
3007
|
+
if (want) {
|
|
3008
|
+
const one = (api.pluginGet ? await api.pluginGet(want) : null) || (await api.pluginList()).find(r => r.plugin_id === want || r.id === want);
|
|
3009
|
+
if (!one) return say(`No installed plugin named "${want}". /plugins lists them.`, 'warn');
|
|
3010
|
+
const on = one.is_enabled !== false && one.is_enabled !== 0;
|
|
3011
|
+
return say([`${one.plugin_id || want} ${on ? 'enabled' : 'disabled'} v${one.version || '?'}`, ...(one.installed_by ? [`installed by ${one.installed_by}${one.installed_at ? ` on ${String(one.installed_at).slice(0, 10)}` : ''}`] : []), ...(one.upgraded_at ? [`upgraded ${String(one.upgraded_at).slice(0, 10)}`] : [])].join('\n'));
|
|
3012
|
+
}
|
|
3013
|
+
const list = await api.pluginList();
|
|
3014
|
+
if (!list.length) return say('No plugins installed for this org.');
|
|
3015
|
+
const w = Math.max(...list.map(r => String(r.plugin_id || '').length));
|
|
3016
|
+
return say([`${list.length} plugin${list.length === 1 ? '' : 's'} (extensions). /plugins <name> for detail:`, ...list.map(r => ` ${String(r.plugin_id || '?').padEnd(w)} ${r.is_enabled === false || r.is_enabled === 0 ? 'disabled' : 'enabled '} v${r.version || '?'}`)].join('\n'));
|
|
3017
|
+
} catch (e) { return say(`Couldn't list plugins: ${e?.message || 'failed'}`, 'warn'); }
|
|
3018
|
+
}
|
|
2602
3019
|
case '/skills': {
|
|
2603
3020
|
if (!api.skills) return say('This server has no skills list.', 'warn');
|
|
2604
3021
|
try {
|
|
@@ -2631,6 +3048,36 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2631
3048
|
}
|
|
2632
3049
|
return submit(skillPrompt(arg, argRest));
|
|
2633
3050
|
}
|
|
3051
|
+
case '/loop': { // QLOOP-LIVE: list | watch <id> | peek <id> | focus
|
|
3052
|
+
const [sub0, ...restW] = String(restArg || '').trim().split(/\s+/).filter(Boolean);
|
|
3053
|
+
const lid = restW[0];
|
|
3054
|
+
if (!sub0 || sub0 === 'list') {
|
|
3055
|
+
const rows = (R.current.roster.allRows || []).filter(r => r.kind === 'loop');
|
|
3056
|
+
if (!rows.length) return say('No loops running or recent. Ask Quox to run something until it is done, and say yes to the loop offer.');
|
|
3057
|
+
for (const r of rows) say(`∞ ${r.id} │ ${r.name} │ ${r.state}${r.activity ? `, ${r.activity}` : ''}${LC.current.get(r.id) ? ' (card on screen)' : ''}`, r.state === 'failed' ? 'error' : undefined);
|
|
3058
|
+
return say('/loop watch <id> puts a live card in this chat. /loop peek <id> prints one snapshot.');
|
|
3059
|
+
}
|
|
3060
|
+
if (sub0 === 'focus') { const c = LC.current.focus(lid || null); return c ? say(`Keys on the loop card: Enter expands the output, s halts (y confirms), Esc leaves.`) : say('No loop card on screen. /loop watch <id> starts one.', 'warn'); }
|
|
3061
|
+
if (sub0 === 'watch') {
|
|
3062
|
+
if (!lid) return say('Usage: /loop watch <id>', 'warn');
|
|
3063
|
+
if (!api.loopEvents) return say('/loop watch is not available in this mode.', 'warn');
|
|
3064
|
+
const known = (R.current.roster.allRows || []).find(r => r.id === lid);
|
|
3065
|
+
const had = LC.current.get(lid);
|
|
3066
|
+
LC.current.watch(lid, { title: known?.name || '' });
|
|
3067
|
+
LC.current.focus(lid);
|
|
3068
|
+
return had ? say('That loop already has a card above; it has focus. Enter expands, s halts, Esc leaves.') : undefined; // the card's own last line lists the keys
|
|
3069
|
+
}
|
|
3070
|
+
if (sub0 === 'peek') {
|
|
3071
|
+
if (!lid) return say('Usage: /loop peek <id>', 'warn');
|
|
3072
|
+
try {
|
|
3073
|
+
const snap = await api.loopSnapshot(lid);
|
|
3074
|
+
const st = applyLoopSnapshot(initLoop({ id: lid, title: snap?.title || '' }), snap);
|
|
3075
|
+
for (const rs of cardLines(st, { cols: Math.max(40, cols - 4) })) say(rs.map(x => x.t).join(''));
|
|
3076
|
+
} catch (e) { say(`loop ${lid}: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}`, 'warn'); }
|
|
3077
|
+
return undefined;
|
|
3078
|
+
}
|
|
3079
|
+
return say('Usage: /loop list | watch <id> | peek <id> | focus', 'warn');
|
|
3080
|
+
}
|
|
2634
3081
|
case '/roster': {
|
|
2635
3082
|
const ro = R.current.roster;
|
|
2636
3083
|
if (arg === 'all') {
|
|
@@ -2720,6 +3167,39 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2720
3167
|
catch (e) { dispatch({ type: 'error', text: `observers: ${e.message}` }); }
|
|
2721
3168
|
return undefined;
|
|
2722
3169
|
}
|
|
3170
|
+
case '/inbox': { // HITL-INBOX-BAR: the owner's Quox inbox, in the chat
|
|
3171
|
+
if (!api.inboxItems) return say('The inbox is not available in this mode.', 'warn');
|
|
3172
|
+
const q = parseInbox(restArg);
|
|
3173
|
+
if (q.kind === 'usage') return say(INBOX_USAGE, 'warn');
|
|
3174
|
+
let items;
|
|
3175
|
+
try { items = await api.inboxItems({ limit: 30 }); }
|
|
3176
|
+
catch (e) { return dispatch({ type: 'error', text: `Could not read the inbox (${e.message}). That does not mean it is empty; try again or run: quox inbox list` }); }
|
|
3177
|
+
const top = newestFirst(items).slice(0, 10);
|
|
3178
|
+
R.current.inboxList = top; R.current.inboxSeen = new Set(items.map((i) => i?.id).filter(Boolean));
|
|
3179
|
+
dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols) } });
|
|
3180
|
+
const pick = (n) => { const it = top[n - 1]; if (!it) say(`No item ${n}: the list has ${top.length}. ${mark('/inbox')} shows it.`, 'warn'); return it; };
|
|
3181
|
+
if (q.kind === 'list') { for (const l of inboxTable(items, Date.now(), cols)) say(l.text, l.tone === 'accent' ? 'warn' : l.tone); return undefined; }
|
|
3182
|
+
if (q.kind === 'read') {
|
|
3183
|
+
const targets = q.all ? top.filter((i) => !i.is_read) : [pick(q.n)].filter(Boolean);
|
|
3184
|
+
if (!targets.length) return q.all ? say('Nothing unread.', 'dim') : undefined;
|
|
3185
|
+
let ok = 0; const bad = [];
|
|
3186
|
+
for (const it of targets) { try { await api.inboxMarkRead(it.id, true); ok++; } catch (e) { bad.push(e?.message || 'failed'); } }
|
|
3187
|
+
if (bad.length) say(`Marked ${ok} read; ${bad.length} failed (${clip(bad[0], 60)}).`, 'warn'); else say(`✔ marked ${ok} read`, 'ok');
|
|
3188
|
+
refreshInboxBar();
|
|
3189
|
+
return undefined;
|
|
3190
|
+
}
|
|
3191
|
+
const it = pick(q.n); if (!it) return undefined;
|
|
3192
|
+
if (q.kind === 'open' && isApprovalItem(it)) {
|
|
3193
|
+
let flat = []; try { flat = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [] }).flatMap((g) => g.items.map((x) => x.item.id)); } catch { /* falls to /approvals below */ }
|
|
3194
|
+
const at = flat.indexOf(it.id);
|
|
3195
|
+
return command(at >= 0 ? `/approvals ${at + 1}` : '/approvals');
|
|
3196
|
+
}
|
|
3197
|
+
let full = it; try { full = (await api.inboxItem(it.id)) || it; } catch (e) { say(`Could not load the full item (${clip(e?.message || 'failed', 60)}); showing the list row.`, 'warn'); }
|
|
3198
|
+
for (const l of inboxDetail(full)) say(l.text, l.tone === 'accent' ? 'ok' : l.tone === 'text' ? undefined : l.tone);
|
|
3199
|
+
if (isApprovalItem(full)) say(`This is an approval: ${mark(`/inbox open ${q.n}`)} takes you to it.`, 'dim');
|
|
3200
|
+
refreshInboxBar();
|
|
3201
|
+
return undefined;
|
|
3202
|
+
}
|
|
2723
3203
|
case '/approvals': {
|
|
2724
3204
|
if (/^help$/i.test(restArg.trim())) { // the syntax lives here, not above every list
|
|
2725
3205
|
say(`${mark('/approve N')} ${mark('/deny N <reason>')} ${mark('/approvals N')} detail ${mark('/approvals walkthrough')} one at a time ${mark('/approvals mine|agents|old|<agent>')} filter (privileged: ${mark('/approve N confirm')})`);
|
|
@@ -2739,7 +3219,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2739
3219
|
const hidden = stats ? Math.max(0, stats.awaiting - flat.length) : 0;
|
|
2740
3220
|
if (!flat.length && !hidden && !statsErr) return say('Nothing waiting for you.', 'ok');
|
|
2741
3221
|
const fArg = restArg.trim();
|
|
2742
|
-
if (/^walkthrough$/i.test(fArg))
|
|
3222
|
+
if (/^walkthrough$/i.test(fArg)) { // plain approvals walk as cards too, after any session questions
|
|
3223
|
+
const plain = flat.filter(x => !isDecision(x));
|
|
3224
|
+
if (plain.length && plain.length === flat.length) say(`No session questions; ${plain.length} approval${plain.length === 1 ? '' : 's'} waiting, starting with the first.`, 'warn');
|
|
3225
|
+
return walkStart(flat.map(approvalAsWalkItem), { fresh: true });
|
|
3226
|
+
}
|
|
2743
3227
|
if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
|
|
2744
3228
|
const it = flat[Number(fArg) - 1];
|
|
2745
3229
|
if (!it) return say(`No such item: [${fArg}] (${flat.length} listed).`, 'warn');
|
|
@@ -2766,6 +3250,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2766
3250
|
}
|
|
2767
3251
|
if (flat.length && !shown.size) say(`Nothing matches "${fArg}".`, 'warn');
|
|
2768
3252
|
if (flat.length) say(' ');
|
|
3253
|
+
if (shown.size && !(nDec && !fArg)) R.current.listShownAt = Date.now(); // a list, not a card: a bare y/n right after it has no target
|
|
2769
3254
|
if (nDec && !fArg) walkStart(flat, { fresh: true, quiet: true });
|
|
2770
3255
|
if (hidden) {
|
|
2771
3256
|
const others = humanProfiles.filter(h => h !== profile);
|
|
@@ -2786,7 +3271,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2786
3271
|
}
|
|
2787
3272
|
case '/mouse': {
|
|
2788
3273
|
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
|
|
3274
|
+
if (!v) return say(`Mouse is ${MO.current.on ? 'on' : 'off'}. /mouse ${MO.current.on ? 'off' : 'on'} to change it.${MO.current.on ? ` ${MOUSE_HINT}` : ''}`);
|
|
2790
3275
|
if (v !== 'on' && v !== 'off') return say('Usage: /mouse on|off', 'warn');
|
|
2791
3276
|
MO.current.on = v === 'on'; MO.current.drag = null;
|
|
2792
3277
|
api.saveSetting?.('mouse', v);
|
|
@@ -2820,6 +3305,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2820
3305
|
case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
|
|
2821
3306
|
case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
|
|
2822
3307
|
case '/huh': return command(`/dragon explain${restArg ? ` ${restArg}` : ''}`); // owner 2026-10-15: shortcut for /dragon explain
|
|
3308
|
+
case '/devservers': { // DEVFLEET: list the dev servers. Handing the terminal to a remote session from inside the TUI is not done here: print the exact command.
|
|
3309
|
+
if (!api.devServers) return say('/devservers is not available in this mode.', 'warn');
|
|
3310
|
+
let rows; try { rows = await api.devServers(); } catch (e) { return say(`Couldn't list servers: ${e?.message || 'failed'}`, 'warn'); }
|
|
3311
|
+
if (!rows.length) return say('No dev servers registered. In a terminal: quox server mark <host> --purpose "main dev"');
|
|
3312
|
+
const pick = restArg.trim();
|
|
3313
|
+
if (pick) {
|
|
3314
|
+
const r = rows.find(x => x.name === pick);
|
|
3315
|
+
if (!r) return say(`No dev server "${pick}". /devservers lists them.`, 'warn');
|
|
3316
|
+
return say(`To open a Quox window on ${r.name}, run this in a terminal (the TUI does not hand over the terminal yet):\nquox server go ${r.name}`);
|
|
3317
|
+
}
|
|
3318
|
+
const lines = rows.map(r => `${r.current ? '*' : ' '} ${r.name} ${r.purpose || '-'} ${r.project || '-'} ${r.reachable ? 'reachable' : 'unreachable'}`);
|
|
3319
|
+
return say(`${lines.join('\n')}\n* this server. /devservers <name> shows the command to open a Quox window there (quox server go <name>).`);
|
|
3320
|
+
}
|
|
2823
3321
|
case '/slots': return listSlots(/^all$/i.test(restArg.trim()));
|
|
2824
3322
|
case '/mirror': {
|
|
2825
3323
|
// The Mirror itself: this org's server-built Mirror, what CommanderQ is told every turn (not /mind).
|
|
@@ -2903,7 +3401,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2903
3401
|
try { await go(); }
|
|
2904
3402
|
catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
|
|
2905
3403
|
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');
|
|
3404
|
+
say(`✔ answered [${k}] ${x.payload.session || 'session'}: ${clip(decodeEntities(x.title || x.id), Math.max(20, cols - 40))} -> (${keyOf(k)})`, 'ok');
|
|
2907
3405
|
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
3406
|
} catch (e) { failedK.push(`[${k}] ${e?.status === 404 || e?.status === 400 ? 'already handled or expired' : e.message}`); if (e?.stepUpDeclined) break; }
|
|
2909
3407
|
}
|
|
@@ -2943,7 +3441,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2943
3441
|
return dispatch({ type: 'error', text: `answer: ${e.message}` });
|
|
2944
3442
|
}
|
|
2945
3443
|
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');
|
|
3444
|
+
say(`✔ answered [${pa.n}] ${it.payload.session || 'session'}: ${clip(decodeEntities(it.title || it.id), Math.max(20, cols - 40))} -> (${pa.key}) ${decodeEntities(it.payload.options.find(o => o.key === pa.key).label)}${pa.note ? ` (note: ${clip(pa.note, 40)})` : ''}`, 'ok');
|
|
2947
3445
|
say(' ');
|
|
2948
3446
|
R.current.lastAnswered = it.id; walkAnsweredIds().add(it.id);
|
|
2949
3447
|
if (it.payload.slot && api.slotSay) { // WALK-FIXES: the answer is already recorded; the slot post never holds up the next card
|
|
@@ -3011,7 +3509,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3011
3509
|
try {
|
|
3012
3510
|
const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
|
|
3013
3511
|
const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
|
|
3014
|
-
if (r?.gone) gone.push(k); else if (r?.ok) done.push(k);
|
|
3512
|
+
if (r?.gone) gone.push(k); else if (r?.ok) { done.push(k); R.current.lastAnswered = it.id; }
|
|
3015
3513
|
} catch (e) { failed.push(`[${k}] ${e.message}`); }
|
|
3016
3514
|
}
|
|
3017
3515
|
if (done.length || gone.length || failed.length || missing.length) say(' ');
|
|
@@ -3053,7 +3551,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3053
3551
|
return undefined;
|
|
3054
3552
|
}
|
|
3055
3553
|
default: {
|
|
3554
|
+
if (await extDispatch(cmd, restArg)) return undefined;
|
|
3056
3555
|
const e = findEntry(cmd);
|
|
3556
|
+
if (e?.ext) return say(`${e.usage.split(/\s/)[0]} comes from the ${e.ext} extension, which is not loaded. npm i -g ${e.ext}, then quox ext add ${e.ext}, or /ext on ${e.ext}.`, 'warn');
|
|
3057
3557
|
if (e && e.status === 'planned') return say(`${e.usage} is planned, not built yet. /help shows what works today.`, 'warn');
|
|
3058
3558
|
if (!e && await slotNameCommand(cmd, restArg)) return undefined;
|
|
3059
3559
|
const cliHit = resolveCliSlash(cmd, restArg); // QUOXON-FW: slash words that name a real quox CLI leaf
|
|
@@ -3066,7 +3566,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3066
3566
|
if (!near.length) return say(`Unknown command ${cmd}. Try /help`, 'warn');
|
|
3067
3567
|
// spelling-only hints keep the plain "/a or /b" line; keyword hints say what each command does
|
|
3068
3568
|
const byWords = near.some(x => x.kw);
|
|
3069
|
-
|
|
3569
|
+
const viaAlias = (x) => { const en = findEntry(x.name); return en && en.id !== x.name.slice(1) ? `${x.name} (${en.id})` : x.name; }; // an alias names the command it runs
|
|
3570
|
+
say(`Unknown command ${cmd}. Did you mean ${byWords ? near.map(x => (x.desc ? `${x.name} (${x.desc})` : x.name)).join(', ') : near.map(viaAlias).join(' or ')}? (/help lists them)`, 'warn');
|
|
3070
3571
|
if (near.length === 1) { // one strong match: offer it as a y/N card (vetted, never a human-typed command)
|
|
3071
3572
|
const v = vetSuggestion(near[0].name);
|
|
3072
3573
|
if (v.ok) { R.current.suggestAsk = { line: v.line, at: Date.now() }; ask(`Run ${v.line}?`, { action: v.line, reason: 'Nearest command to what you typed.' }); }
|
|
@@ -3098,7 +3599,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3098
3599
|
function openAskStates() {
|
|
3099
3600
|
const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
|
|
3100
3601
|
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);
|
|
3602
|
+
return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.sayLastAsk, R.current.profileAsk, R.current.cliAsk, R.current.installAsk, R.current.updateAsk, R.current.orgPending, R.current.hatPending, HW.current.card(), RW.current.walk.card(), R.current.suggestAsk && !R.current.suggestLine ? R.current.suggestAsk : null, R.current.objectiveAsk, R.current.handoffAsk, R.current.askQuox, R.current.strayAsk, R.current.planAsk, SC.current.pendingFile, SC.current.reattach, CH.current.pending, SPN.current.pending].map(fresh)].filter(Boolean);
|
|
3102
3603
|
}
|
|
3103
3604
|
const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || UW.current.pending?.() || runAskOpen();
|
|
3104
3605
|
function touchAsks() { for (const x of openAskStates()) x.at = Date.now(); KC.current.touch?.(); UW.current.touch?.(); }
|
|
@@ -3236,7 +3737,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3236
3737
|
let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
|
|
3237
3738
|
if (askOpen() && !R.current.noteAsk) {
|
|
3238
3739
|
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'];
|
|
3740
|
+
const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'sayLastAsk', 'profileAsk', 'cliAsk', 'installAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
|
|
3240
3741
|
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
3742
|
if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
|
|
3242
3743
|
else if (o) {
|
|
@@ -3316,6 +3817,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3316
3817
|
if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
|
|
3317
3818
|
if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
|
|
3318
3819
|
}
|
|
3820
|
+
if (R.current.sayLastAsk) { // SAY-LAST-TARGET "Send to <slot>? y send · n cancel": y sends the whole text
|
|
3821
|
+
const a = R.current.sayLastAsk; R.current.sayLastAsk = null;
|
|
3822
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { sendToSlot(a.slot, a.text); return; }
|
|
3823
|
+
if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
|
|
3824
|
+
}
|
|
3319
3825
|
if (R.current.slotCreateAsk) { // "No slot X. Create it? y/N/c" from a /slot near miss: y runs /slot X --create
|
|
3320
3826
|
const a = R.current.slotCreateAsk; R.current.slotCreateAsk = null;
|
|
3321
3827
|
if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { const l = `/slot ${a.nm} --create`; echoCmd(l); command(l); return; }
|
|
@@ -3336,6 +3842,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3336
3842
|
}
|
|
3337
3843
|
if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
|
|
3338
3844
|
}
|
|
3845
|
+
if (R.current.installAsk) { // LAZY-AI-CLI: "Claude Code isn't installed on this server. Install it now? y/N" (typing y is the authorization)
|
|
3846
|
+
const a = R.current.installAsk; R.current.installAsk = null;
|
|
3847
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { installNow(a); return; }
|
|
3848
|
+
if (!text.startsWith('/')) { say(`Not installed. Nothing was downloaded.`, 'dim'); nextInstallOffer(); return; }
|
|
3849
|
+
}
|
|
3339
3850
|
if (R.current.cliAsk) { // QUOXON-FW: a slash word named a WRITE quox command: "Run it? y/N" (typing y is the authorization)
|
|
3340
3851
|
const a = R.current.cliAsk; R.current.cliAsk = null;
|
|
3341
3852
|
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { if (R.current.bang) say('A command is still running. Ctrl+C stops it.', 'warn'); else bangCommand(a.bang); bump(n => n + 1); return; }
|
|
@@ -3356,6 +3867,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3356
3867
|
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
3868
|
if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
|
|
3358
3869
|
}
|
|
3870
|
+
if (R.current.objectiveAsk) { // "Make this an objective that runs until done?" (yes or no): yes creates + starts it, no does nothing
|
|
3871
|
+
const a = R.current.objectiveAsk; R.current.objectiveAsk = null;
|
|
3872
|
+
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runObjective(a); return; }
|
|
3873
|
+
if (!text.startsWith('/')) { say('No objective made.', 'dim'); return; }
|
|
3874
|
+
}
|
|
3359
3875
|
if (R.current.handoffAsk) { // "Hand this question to CommanderQ?" (yes or no): yes asks it once, the attach stays
|
|
3360
3876
|
const a = R.current.handoffAsk; R.current.handoffAsk = null;
|
|
3361
3877
|
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runHandoff(a); return; }
|
|
@@ -3396,6 +3912,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3396
3912
|
else if (v === 'needs-run') { say(`Not run yet. ${ra.danger}, so type run to run it, or n to skip.`, 'warn'); return; }
|
|
3397
3913
|
else if (v === 'no') { SC.current.runAsk = null; transcript?.bang?.(ra.cmd, 'declined'); say('Not run.', 'dim'); return; }
|
|
3398
3914
|
}
|
|
3915
|
+
{ const ls = R.current.listShownAt; R.current.listShownAt = null; // APPROVALS-UX: y/n right after a list (not a card) is not chat to the agent
|
|
3916
|
+
if (ls && Date.now() - ls < 600000 && /^(y(es)?|no?)$/i.test(text.trim())) { remember(text); R.current.histIdx = -1; say('Which one? /approve 1 or /approvals 1 to see it.', 'warn'); R.current.listShownAt = ls; return; } }
|
|
3399
3917
|
if (text.startsWith('/') && !text.includes('\n')) { remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
|
|
3400
3918
|
const roomSend = api.slotSay ? parseRoomSend(text) : null;
|
|
3401
3919
|
if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
|
|
@@ -3485,9 +4003,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3485
4003
|
const now = Date.now();
|
|
3486
4004
|
const idx = hit.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
|
|
3487
4005
|
if (idx != null) {
|
|
3488
|
-
const
|
|
3489
|
-
|
|
3490
|
-
|
|
4006
|
+
const same = M.last && now - M.last.at < DOUBLE_MS && M.last.x === e.x && M.last.y === e.y;
|
|
4007
|
+
const n = same ? M.last.n + 1 : 1;
|
|
4008
|
+
M.last = n >= 3 ? null : { at: now, x: e.x, y: e.y, n };
|
|
4009
|
+
if (n === 2) { const w = wordAt(B.current.text, idx); edit({ type: 'select', anchor: w.from, cursor: w.to }); copySelection(); return; }
|
|
4010
|
+
if (n >= 3) { const l = lineAt(B.current.text, idx); edit({ type: 'select', anchor: l.from, cursor: l.to }); copySelection(); return; }
|
|
3491
4011
|
edit({ type: 'place', index: idx }); K.current.composerClicked = true;
|
|
3492
4012
|
M.drag = { anchor: B.current.cursor, cursorRow: hit.cursorRow ?? null };
|
|
3493
4013
|
return;
|
|
@@ -3613,6 +4133,25 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3613
4133
|
}
|
|
3614
4134
|
|
|
3615
4135
|
/** COMPOSER-EDIT: cells the draft has per row (the composer box is `cols` wide, less the "› " prompt). */
|
|
4136
|
+
/** QUOXON-HISTORY-UP: still on the entry Up recalled (unedited): Up/Down jump whole entries, not rows of a long recalled message. */
|
|
4137
|
+
function browsingHistory() { return R.current.histIdx >= 0 && B.current.text === R.current.histText; }
|
|
4138
|
+
/** QUOXON-HISTORY-UP: Ctrl+R reverse search. The composer shows "(reverse-i-search) q: match"; Enter accepts, Esc cancels, Ctrl+R again = next older. */
|
|
4139
|
+
function rsShow() {
|
|
4140
|
+
const rs = R.current.rs; const hist = R.current.history; const q = rs.q.toLowerCase(); let n = rs.skip; rs.hit = null;
|
|
4141
|
+
for (let i = hist.length - 1; i >= 0; i--) if (hist[i].toLowerCase().includes(q) && n-- === 0) { rs.hit = hist[i]; break; }
|
|
4142
|
+
B.current = bufferReducer(initialBuffer(), { type: 'set', text: `(reverse-i-search) ${rs.q}: ${rs.hit == null ? '' : rs.hit.replace(/\s*\n\s*/g, ' ')}` }); bump(n2 => n2 + 1);
|
|
4143
|
+
}
|
|
4144
|
+
function rsEvent(e) {
|
|
4145
|
+
const rs = R.current.rs;
|
|
4146
|
+
if (e.type === 'key') {
|
|
4147
|
+
if (e.name === 'escape' || e.name === 'ctrl-c') { B.current = rs.saved; R.current.rs = null; bump(n => n + 1); return; }
|
|
4148
|
+
if (e.name === 'enter') { const hit = rs.hit; B.current = rs.saved; R.current.rs = null; if (hit != null) { R.current.histIdx = -1; edit({ type: 'set', text: hit }); } else bump(n => n + 1); return; }
|
|
4149
|
+
if (e.name === 'ctrl-r') { rs.skip++; return rsShow(); }
|
|
4150
|
+
if (e.name === 'backspace') { rs.q = rs.q.slice(0, -1); rs.skip = 0; return rsShow(); }
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
if (e.type === 'text' || e.type === 'paste') { rs.q += e.text.replace(/\s+/g, ' '); rs.skip = 0; rsShow(); }
|
|
4154
|
+
}
|
|
3616
4155
|
function composerWidth() { return Math.max(1, cols - 2); }
|
|
3617
4156
|
/** COMPOSER-EDIT: copy the composer selection to the clipboard with OSC 52 (works over SSH; needs a terminal that allows it). */
|
|
3618
4157
|
function copySelection() {
|
|
@@ -3635,6 +4174,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3635
4174
|
return;
|
|
3636
4175
|
}
|
|
3637
4176
|
if (name === 'ctrl-d') { if (!B.current.text) exit(); return; }
|
|
4177
|
+
if (LC.current.focused() && !s.approvals.length && !B.current.text && LC.current.key(name, e.text)) return; // QLOOP-LIVE: keys of a focused loop card
|
|
3638
4178
|
if (R.current.roster.focus && !s.approvals.length && rosterKey(name)) return;
|
|
3639
4179
|
// quick reply (owner 2026-10-15): Tab in an EMPTY composer pre-fills "/say <last sender> " (before the palette, which owns Tab otherwise)
|
|
3640
4180
|
if (name === 'tab' && !B.current.text && R.current.lastSender && !headCard(s)) { edit({ type: 'set', text: `/say ${R.current.lastSender.toLowerCase()} ` }); return; }
|
|
@@ -3663,7 +4203,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3663
4203
|
case 'delete': return edit({ type: 'delete' });
|
|
3664
4204
|
case 'delete-word': return edit({ type: 'deleteWordBack' });
|
|
3665
4205
|
case 'kill-line': return edit({ type: 'killLineBack' });
|
|
3666
|
-
case '
|
|
4206
|
+
case 'home': { // Ctrl+A stays line-start; a second Ctrl+A within 600 ms selects the whole draft (and copies it)
|
|
4207
|
+
if (e.via === 'ctrl-a') {
|
|
4208
|
+
const k = K.current;
|
|
4209
|
+
if (k.ctrlA && now - k.ctrlA.at < 600 && k.ctrlA.text === B.current.text && B.current.text) { k.ctrlA = 0; edit({ type: 'selectAll' }); copySelection(); return; }
|
|
4210
|
+
k.ctrlA = { at: now, text: B.current.text };
|
|
4211
|
+
}
|
|
4212
|
+
return edit({ type: 'home' });
|
|
4213
|
+
}
|
|
4214
|
+
case 'left': case 'right': case 'end': return edit({ type: name });
|
|
3667
4215
|
case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'wordLeft' : 'wordRight' });
|
|
3668
4216
|
case 'kill-line-forward': return edit({ type: 'killLineForward' }); // Ctrl+K
|
|
3669
4217
|
case 'delete-word-forward': return edit({ type: 'deleteWordForward' }); // Alt+D, Ctrl+Delete
|
|
@@ -3678,21 +4226,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3678
4226
|
case 'redo': return edit({ type: 'redo' });
|
|
3679
4227
|
case 'up': {
|
|
3680
4228
|
if (B.current.text === '' && s.pending.length) { const last = s.pending[s.pending.length - 1]; dispatch({ type: 'queue.pop' }); edit({ type: 'set', text: last.text }); return; }
|
|
3681
|
-
if (atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
|
|
4229
|
+
if (browsingHistory() || atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
|
|
3682
4230
|
const hist = R.current.history;
|
|
3683
4231
|
if (B.current.text === '' || R.current.histIdx >= 0) {
|
|
3684
4232
|
const i = R.current.histIdx < 0 ? hist.length - 1 : Math.max(0, R.current.histIdx - 1);
|
|
3685
|
-
if (hist[i] !== undefined) { R.current.histIdx = i; edit({ type: 'set', text: hist[i] }); }
|
|
4233
|
+
if (hist[i] !== undefined) { R.current.histIdx = i; R.current.histText = hist[i]; edit({ type: 'set', text: hist[i] }); }
|
|
3686
4234
|
}
|
|
3687
4235
|
return;
|
|
3688
4236
|
}
|
|
3689
4237
|
return edit({ type: 'up', width: composerWidth() });
|
|
3690
4238
|
}
|
|
3691
4239
|
case 'down': {
|
|
4240
|
+
if (B.current.text === '' && R.current.histIdx < 0 && !s.approvals.length && LC.current.anyRunning() && !LC.current.focused()) { LC.current.focus(); return; } // QLOOP-LIVE: Down on an empty composer reaches the newest running card (its hint line says so)
|
|
3692
4241
|
if (B.current.text === '' && R.current.histIdx < 0 && rosterOn()) { focusRoster(); return; }
|
|
3693
|
-
if (atVisualEdge(B.current, 'down', composerWidth()) && R.current.histIdx >= 0) {
|
|
4242
|
+
if ((browsingHistory() || atVisualEdge(B.current, 'down', composerWidth())) && R.current.histIdx >= 0) {
|
|
3694
4243
|
const i = R.current.histIdx + 1;
|
|
3695
|
-
if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; edit({ type: 'set', text: R.current.history[i] }); }
|
|
4244
|
+
if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; R.current.histText = R.current.history[i]; edit({ type: 'set', text: R.current.history[i] }); }
|
|
3696
4245
|
return;
|
|
3697
4246
|
}
|
|
3698
4247
|
return edit({ type: 'down', width: composerWidth() });
|
|
@@ -3746,6 +4295,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3746
4295
|
if (LI.current.isOpen()) { LI.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the sign-in card takes every key
|
|
3747
4296
|
if (CN.current.isOpen()) { CN.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the secure card takes every key
|
|
3748
4297
|
if (PK.current.isOpen() && !S.current.approvals.length && PK.current.onEvent(e) !== false) { K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // /resume, /config (typing closes it and falls through)
|
|
4298
|
+
if (R.current.rs && (e.type === 'key' || e.type === 'text' || e.type === 'paste')) { rsEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
|
|
4299
|
+
if (e.type === 'key' && e.name === 'ctrl-r' && !headCard(S.current) && R.current.history.length) { R.current.rs = { saved: B.current, q: '', skip: 0, hit: null }; rsShow(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
|
|
3749
4300
|
const card = headCard(S.current);
|
|
3750
4301
|
const g = K.current.guard;
|
|
3751
4302
|
// any different key ends the "this key was already down" suppression
|
|
@@ -3761,6 +4312,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3761
4312
|
edit({ type: 'paste', text: e.text }); // a card never discards a paste
|
|
3762
4313
|
} else if (e.type === 'text') {
|
|
3763
4314
|
if (card && approvalKey('text', e.text, now)) { /* consumed by the approval dialog */ }
|
|
4315
|
+
else if (!card && !B.current.text && LC.current.focused() && LC.current.key(null, e.text)) { /* consumed by the focused loop card (s halts, y confirms) */ }
|
|
3764
4316
|
else if (!card && R.current.roster.focus && rosterKey(null, e.text)) { /* consumed by the roster */ }
|
|
3765
4317
|
else if (!card && askKey(e.text, now)) { /* consumed by the open ask */ }
|
|
3766
4318
|
else edit({ type: 'insert', text: e.text });
|
|
@@ -3774,6 +4326,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3774
4326
|
};
|
|
3775
4327
|
onData.current = (chunk) => {
|
|
3776
4328
|
const now = Date.now();
|
|
4329
|
+
DC.current.keypress(); // a keypress sends the companion dragon away, but not in its first moments
|
|
3777
4330
|
finishIntroRef.current();
|
|
3778
4331
|
// Enter is a newline only inside a real paste (bracketed, or several lines in one chunk: keys.js).
|
|
3779
4332
|
// Its timing relative to the previous chunk says nothing: tmux, expect and SSH batch segments.
|
|
@@ -3888,6 +4441,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3888
4441
|
stdin.on('data', fn);
|
|
3889
4442
|
return () => {
|
|
3890
4443
|
R.current.closed = true;
|
|
4444
|
+
LC.current.dispose();
|
|
3891
4445
|
R.current.abort?.abort(); // closing quoxon cancels the active stream (and its HTTP body)
|
|
3892
4446
|
for (const k of ['fallbackTimer', 'ctrlCTimer']) clearTimeout(R.current[k]);
|
|
3893
4447
|
clearTimeout(K.current.danglingTimer);
|
|
@@ -3930,6 +4484,16 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3930
4484
|
}
|
|
3931
4485
|
pendingHist = s.history.slice(committedRef.current);
|
|
3932
4486
|
}
|
|
4487
|
+
const staticItems = (() => { // QLOOP-LIVE: the committed history for <Static>; append-only, a settled loop card joins at the end
|
|
4488
|
+
let st = staticRef.current;
|
|
4489
|
+
const upto = Math.min(committedRef.current, s.history.length);
|
|
4490
|
+
if (st.seen > s.history.length) st = staticRef.current = { list: [], seen: 0, deferred: [] };
|
|
4491
|
+
let list = st.list;
|
|
4492
|
+
for (; st.seen < upto; st.seen++) { const it = s.history[st.seen]; if (LC.current.held(it)) st.deferred.push(it); else list = [...list, it]; }
|
|
4493
|
+
if (st.deferred.length) { const done = st.deferred.filter((it) => !LC.current.held(it)); if (done.length) { st.deferred = st.deferred.filter((it) => !done.includes(it)); list = [...list, ...done]; } }
|
|
4494
|
+
st.list = list;
|
|
4495
|
+
return list;
|
|
4496
|
+
})();
|
|
3933
4497
|
lastAppRender.current = nowR;
|
|
3934
4498
|
// Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
|
|
3935
4499
|
const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
|
|
@@ -3939,7 +4503,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3939
4503
|
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
4504
|
if (motionStats && motionOn && card && card.displayedAt != null && !(motionStats.cards ||= []).some(x => x.card.callId === card.callId)) motionStats.cards.push({ card, org: orgShown() || null }); // the card took the draw-in path (a test renders it at fixed motionE)
|
|
3941
4505
|
R.current.armedShown = armed;
|
|
3942
|
-
const ctxParts = [demoLabel && profile === 'demo' ? null : profile, orgShown(), (R.current.noAi && !s.ctx?.model ? NO_AI_STATUS : modelLabel(s.ctx, MC.current)), demoLabel ? (demoAuto ? 'demo data, nothing real runs' : null) : MD.current, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules), AR.current.on ? '↻ auto' : null]; // [5]: auto-restart, context row of the status
|
|
4506
|
+
const ctxParts = [demoLabel && profile === 'demo' ? null : profile, orgShown(), (R.current.noAi && !s.ctx?.model ? NO_AI_STATUS : modelLabel(s.ctx, MC.current)), demoLabel ? (demoAuto ? 'demo data, nothing real runs' : null) : MD.current, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules), AR.current.on ? '↻ auto' : null, SV.current.chip ? { name: SV.current.chip.name, role: SV.current.chip.role } : null, SV.current.chip?.remote || null]; // [6],[7]: server chip, remote instance; [5]: auto-restart, context row of the status
|
|
3943
4507
|
const caps = R.current.caps;
|
|
3944
4508
|
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
4509
|
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…';
|
|
@@ -3983,39 +4547,41 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3983
4547
|
if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
|
|
3984
4548
|
const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
|
|
3985
4549
|
return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
|
|
3986
|
-
h(Static, { key: `static-${epoch}`, items:
|
|
4550
|
+
h(Static, { key: `static-${epoch}`, items: staticItems }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
|
|
3987
4551
|
h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
|
|
3988
4552
|
}
|
|
3989
4553
|
const ruleAbove = endsInRule({ card, asks: s.asks || [], background: s.background, pending: s.pending, steering: s.steering, countdown: Boolean(AR.current.timer && AR.current.endsAt) }) && !(SU.current.isOpen() || LI.current.isOpen() || CN.current.isOpen());
|
|
3990
4554
|
return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
|
|
3991
4555
|
// Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
|
|
3992
4556
|
// already-printed items would drop it (and reprint the rest).
|
|
3993
|
-
h(Static, { key: `static-${epoch}`, items: introDone.current ?
|
|
4557
|
+
h(Static, { key: `static-${epoch}`, items: introDone.current ? staticItems : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
|
|
3994
4558
|
introDone.current ? null : h(Box, { key: 'intro-live', flexDirection: 'column' }, h(Intro, { item: introItem, cols, dragon: introDragon, progress: Math.min(1, (Date.now() - introStart.current) / introMs) })),
|
|
3995
4559
|
liveRegion([
|
|
3996
4560
|
...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
4561
|
...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
4562
|
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,
|
|
4563
|
+
flyActive(nowR) ? h(FlyLive, { key: 'dragon-fly', cols, rows: termRows(stdout), startedAt: R.current.fly.startedAt, still: R.current.fly.still, chromeH: chromeH.current }) : null,
|
|
4000
4564
|
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,
|
|
4565
|
+
h(DragonCompanionLive, { key: 'dragon-companion', ctl: DC.current, cols, rows: termRows(stdout) ?? 24 }),
|
|
4001
4566
|
h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
|
|
4002
4567
|
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
|
-
|
|
4568
|
+
...LC.current.pinned().map((lc) => h(Box, { key: `loopcard-${lc.id}`, marginTop: 1 }, h(LoopCardView, { item: { card: lc }, cols, pinned: true }))), // QLOOP-LIVE: running loops are pinned above the composer
|
|
4569
|
+
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, others: otherSessionsCount(queue) } }) : null,
|
|
4004
4570
|
...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
|
|
4005
4571
|
// TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
|
|
4006
4572
|
s.background > 0 ? h(Box, { paddingLeft: 2 }, h(Text, { color: c('dim') }, `Waiting for ${s.background} background agent${s.background === 1 ? '' : 's'} to finish`)) : null,
|
|
4007
4573
|
h(Pending, { pending: s.pending, steering: s.steering, cols }),
|
|
4008
|
-
AR.current.timer && AR.current.endsAt ? h(Box, { key: 'restart-countdown', paddingLeft: 2 }, h(Text, { color: c('accent'), bold: true }, countdownLine(Math.max(1, Math.ceil((AR.current.endsAt - Date.now()) / 1000)), cols))) : null,
|
|
4574
|
+
AR.current.timer && AR.current.endsAt ? h(Box, { key: 'restart-countdown', paddingLeft: 2 }, h(Text, { color: c('accent'), bold: true }, countdownLine(Math.max(1, Math.ceil((AR.current.endsAt - Date.now()) / 1000)), cols, { waiting: Boolean(AR.current.waiting) }))) : null,
|
|
4009
4575
|
ruleAbove ? null : h(Box, { marginTop: 1 }),
|
|
4010
4576
|
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 }),
|
|
4577
|
+
: ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, signedOut: Boolean(blocked) }, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
|
|
4578
|
+
: h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, signedOut: Boolean(blocked) } }),
|
|
4013
4579
|
!CN.current.isOpen() && PK.current.isOpen() && !card ? h(ListView, { ...PK.current.view(), cols }) : !CN.current.isOpen() && PL.current.isOpen() && !card ? h(ListView, { ...PL.current.view(), cols }) : null,
|
|
4014
|
-
h(RowsBar, { rows: RW.current.view(), cols }),
|
|
4580
|
+
h(RowsBar, { rows: RW.current.view(), cols, limit: { grid: QPE_GRID, n: qpeRowsSetting(api.settings?.()) } }),
|
|
4015
4581
|
h(PresentBar, { stepUp: SU.current, cols }),
|
|
4016
4582
|
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
4583
|
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)] }),
|
|
4584
|
+
h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), inbox: s.notify.inboxBar || '', alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
|
|
4019
4585
|
rosterNode())));
|
|
4020
4586
|
}
|
|
4021
4587
|
|