quoxcli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -1
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +266 -5
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +471 -53
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +65 -9
- package/lib/commands/wazuhIntake.js +46 -5
- package/lib/core/commandRegistrars.js +1 -0
- package/lib/core/config.js +14 -1
- package/lib/core/offload.js +83 -0
- package/lib/core/output.js +29 -4
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/version.js +1 -1
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +257 -48
- package/lib/quoxon/approvalsView.js +3 -1
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/bang.js +7 -1
- package/lib/quoxon/commandShort.js +1 -1
- package/lib/quoxon/commands.js +4 -4
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- package/lib/quoxon/events.js +12 -1
- package/lib/quoxon/extensions.js +2 -0
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/inboxView.js +5 -5
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +2 -1
- package/lib/quoxon/main.js +8 -2
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/render.js +5 -2
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/secretShape.js +2 -0
- package/lib/quoxon/slash/broadcast.js +30 -6
- package/lib/quoxon/slash/broadcastRun.js +12 -6
- package/lib/quoxon/slash/cliLeaves.json +35 -0
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +41 -0
- package/lib/quoxon/slash/paletteController.js +6 -2
- package/lib/quoxon/slash/restart.js +2 -0
- package/lib/quoxon/slotChat.js +7 -3
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/whatsnew.jsonl +6 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
package/lib/quoxon/app.js
CHANGED
|
@@ -19,7 +19,7 @@ import { batchFor, batchModel, batchLabel, collapseArrivals } from './approvalBa
|
|
|
19
19
|
import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, approvalAsWalkItem, parseAnswer, parsePairs, needsPin } from './approvalsView.js';
|
|
20
20
|
import { createAutoOpen, noteArrivals, openDecision, takeQueue, strayAnswer, parseApprovalsArg, autoOn, APPROVALS_OPTIONS } from './autoOpen.js';
|
|
21
21
|
import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
|
|
22
|
-
import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
|
|
22
|
+
import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate, consentOf } from './events.js';
|
|
23
23
|
import { turnTokens } from './indicator.js';
|
|
24
24
|
import {
|
|
25
25
|
Composer, RingComposer, Pending, ApprovalCard, BatchCard, LoopCardView, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
|
|
@@ -58,6 +58,7 @@ import { runWithSpinner, spinnerVerb } from './slashSpinner.js';
|
|
|
58
58
|
import { parseMail, mailTable, mailDetail, noMailboxLines, mailNewestFirst, MAIL_USAGE } from './mailView.js';
|
|
59
59
|
import { inboxBarText, arrivals, loadSeen, withSeen, arrivalLine, inboxTable, announceFresh, activeMutes, mutesLines, inboxDetail, parseInbox, INBOX_USAGE, newestFirst, unreadItems, readItems, isApprovalItem, isMuted } from './inboxView.js';
|
|
60
60
|
import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
|
|
61
|
+
import { isWebPty, SHELL_REFUSED } from './webPty.js';
|
|
61
62
|
import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
|
|
62
63
|
import { packageVersion } from './welcome.js';
|
|
63
64
|
import { feedbackPayload, feedbackPreview } from './feedback.js';
|
|
@@ -80,6 +81,7 @@ import { createConfirm } from './slash/confirm.js';
|
|
|
80
81
|
import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
|
|
81
82
|
import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, limitedOffer, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
|
|
82
83
|
import { humanSessionLines } from '../core/sessionsOverview.js';
|
|
84
|
+
import { resolutionFromItem } from '../core/inboxResolution.js';
|
|
83
85
|
import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
|
|
84
86
|
import { capabilityLines, offerFor, findCapability } from './capabilities.js';
|
|
85
87
|
import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
|
|
@@ -94,7 +96,7 @@ import { createSound } from './sound/index.js';
|
|
|
94
96
|
import { createPalette } from './slash/paletteController.js';
|
|
95
97
|
import { capabilityMap } from './slash/palette.js';
|
|
96
98
|
import { createArgSources } from './slash/argSources.js';
|
|
97
|
-
import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
|
|
99
|
+
import { runBroadcast, runAck, parsePokeArgs, slotNames } from './slash/broadcastRun.js';
|
|
98
100
|
import { parseBroadcastText } from './slash/broadcast.js';
|
|
99
101
|
import { isOwnBroadcast } from './ownBroadcast.js';
|
|
100
102
|
import { orgLabel, isOrgId } from './orgLabel.js';
|
|
@@ -113,6 +115,7 @@ import { createLoopCards, rowsWithoutLoopCards } from './loopCards.js';
|
|
|
113
115
|
import { initLoop, applySnapshot as applyLoopSnapshot, cardLines } from './loopCard.js';
|
|
114
116
|
import { createCompanionController, companionCommand, DragonCompanionLive, takeGreeting, detachGreeting } from './dragonCompanion.js';
|
|
115
117
|
import { dragonForAgent } from '../dragon/select.js';
|
|
118
|
+
import { delegationDragonOn, markDelegationDragon } from './delegationDragon.js';
|
|
116
119
|
import { createDragonJobs } from '../dragon/jobs.js';
|
|
117
120
|
import { createModelCmd } from './slash/modelCmd.js';
|
|
118
121
|
import { createChallengeCmd } from './slash/challenge.js';
|
|
@@ -133,7 +136,7 @@ import { compactResult, compactError, contextFacts, compactShowLines } from './s
|
|
|
133
136
|
import { intentPanel } from './intent.js';
|
|
134
137
|
import { awayDue, parseSince } from './away.js';
|
|
135
138
|
import { parseSlashArgs as parseCallMeSlashArgs, statusLines as callMeStatusLines } from '../core/callMe.js';
|
|
136
|
-
import { parseDndArgs, untilEpoch, sleepIntent, afkMeansSleep, newCollected, morningLines, waitingCount, DND_NOTE } from './dnd.js';
|
|
139
|
+
import { parseDndArgs, untilEpoch, sleepIntent, afkMeansSleep, newCollected, morningLines, sinceLines, isIncident, waitingCount, DND_NOTE } from './dnd.js';
|
|
137
140
|
import { sitrepData } from './objects/sitrep.js';
|
|
138
141
|
import { mirrorErrorNote } from './objects/mirror.js';
|
|
139
142
|
import { launchEgg } from './egg.js';
|
|
@@ -146,7 +149,7 @@ import { matchOrg, orgLines, orgRows, orgCandidates } from './orgSwitch.js';
|
|
|
146
149
|
import { INSTALL_TOOLS, SUGGEST_LINE, planLine, offerQuestion, runInstall, missingTools } from './installCli.js';
|
|
147
150
|
import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
|
|
148
151
|
import { slotOutbox, isConnectivityFailure, queuedLine, sentLine, fullLine } from './slotOutbox.js';
|
|
149
|
-
import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
|
|
152
|
+
import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING, isTurnIdTaken, HELD_NOT_SHOWN } from './resend.js';
|
|
150
153
|
import { gripeFiledLine, gripeFailedLine, gripeQueuedFiledLine } from '../core/quoxglyph.js';
|
|
151
154
|
import { crossOrgTuiLines } from '../core/crossOrgApprovals.js';
|
|
152
155
|
const gripeLead = '📣 GRIPE │';
|
|
@@ -167,7 +170,8 @@ const CARD_GONE_MS = 400;
|
|
|
167
170
|
const STEER_OFF_NOTE = "This model can't take steering mid-turn; queued for next turn.";
|
|
168
171
|
const newTurnId = () => `QXN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
169
172
|
|
|
170
|
-
function openEditor(text) {
|
|
173
|
+
export function openEditor(text) {
|
|
174
|
+
if (isWebPty(process.env)) return null; // QUOXON web PTY: no $EDITOR on the server
|
|
171
175
|
const dir = mkdtempSync(join(tmpdir(), 'quoxon-'));
|
|
172
176
|
const file = join(dir, 'prompt.md');
|
|
173
177
|
writeFileSync(file, text, 'utf8');
|
|
@@ -191,9 +195,10 @@ const GRIPE_LINE = /^\/gripe!?(\s|$)/i;
|
|
|
191
195
|
const isOneLineCommand = (t) => t.startsWith('/') && (!t.includes('\n') || GRIPE_LINE.test(t));
|
|
192
196
|
|
|
193
197
|
export const INBOX_BAR_MAX_MS = 300000; // the bar never waits longer than this between reads
|
|
198
|
+
export const INBOX_BAR_READ = 10; // the bar counts the latest 10 items, in the poll AND in /inbox (one basis: no '7 new' then '25 new')
|
|
194
199
|
export const INBOX_BAR_COST_X = 20; // wait 20x the last read's duration (a 3s read -> 60s; a fast read keeps the poll cadence)
|
|
195
200
|
|
|
196
|
-
export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, restartDeclined = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], startupGraceMs = 60000, startupRetryMs = 3000, neverAnswered = false, onConnect = null, restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, restartCooloffMs = null, presenceRetryDelays = [2000, 5000, 10000, 20000, 30000, 60000], presencePollMs = 30000, autoIdleMs = null, autoTickMs = 1000, autoResumeMs = 3000, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, startupSwitchOffer = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, outboxPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined }) {
|
|
201
|
+
export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, restartDeclined = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], startupGraceMs = 60000, startupRetryMs = 3000, neverAnswered = false, onConnect = null, restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, restartCooloffMs = null, presenceRetryDelays = [2000, 5000, 10000, 20000, 30000, 60000], presencePollMs = 30000, autoIdleMs = null, autoTickMs = 1000, autoResumeMs = 3000, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, startupSwitchOffer = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, outboxPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined, walkTtlMs = 1800000 }) {
|
|
197
202
|
// Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
|
|
198
203
|
// one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
|
|
199
204
|
// PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
|
|
@@ -688,7 +693,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
688
693
|
// ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
|
|
689
694
|
// Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): attach state + real stream events; art and reducer live in dragonlab.
|
|
690
695
|
const DC = React.useRef(null); DC.current ??= createCompanionController({ settings: () => api.settings?.() || {}, isTTY: Boolean(stdout?.isTTY), typing: () => B.current.text !== '', jobs: createDragonJobs({ reads: api.dragonReads || {}, context: () => ({ envelopeId: receiptMentions([...S.current.history, ...S.current.blocks])[0]?.value || null }) }) }); // each dragon's one job: a real read (dragon/jobs.js)
|
|
691
|
-
const dispatchEv = (a) => { DC.current.tap(a); dispatch(a); };
|
|
696
|
+
const dispatchEv = (a) => { DC.current.tap(a); dispatch(markDelegationDragon(a, a?.ev?.tool === 'delegate_to_agent' && delegationDragonOn(api.settings?.() || {}, env))); }; // dragons on: CommanderQ's hand-off gets a static dragon
|
|
692
697
|
// QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
|
|
693
698
|
const LC = React.useRef(null); LC.current ??= createLoopCards({ api, say: (t, tone) => say(t, tone), addItem: (it) => dispatch({ type: 'loopcard', card: it.card }), bump: () => { if (!R.current.closed) bump(n => n + 1); }, dragon: { summon: (d, cap) => DC.current.summon(d, cap) } });
|
|
694
699
|
const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
|
|
@@ -1190,7 +1195,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1190
1195
|
R.current.inboxBusy = true; R.current.inboxAt = t;
|
|
1191
1196
|
const epoch = R.current.inboxEpoch || 0;
|
|
1192
1197
|
try {
|
|
1193
|
-
const items = await api.inboxItems({ limit:
|
|
1198
|
+
const items = await api.inboxItems({ limit: INBOX_BAR_READ });
|
|
1194
1199
|
R.current.inboxCost = Date.now() - t;
|
|
1195
1200
|
if (epoch !== (R.current.inboxEpoch || 0)) return; // an org switch landed while this read was in flight: it belongs to the old org
|
|
1196
1201
|
if (!R.current.inboxSeen && items.length) { try { R.current.inboxSeen = loadSeen(api.settings?.(), R.current.orgId ?? api.orgId?.() ?? null); } catch { /* no saved watermark: baseline from this read */ } }
|
|
@@ -1225,6 +1230,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1225
1230
|
if (BL.current) return;
|
|
1226
1231
|
const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
|
|
1227
1232
|
try {
|
|
1233
|
+
const readAt = Date.now();
|
|
1228
1234
|
const items = await api.approvals();
|
|
1229
1235
|
if (N.authLost) say('Signed in again: approvals are back.', 'ok');
|
|
1230
1236
|
else if (N.slow) say('Quox server is back.', 'ok');
|
|
@@ -1240,6 +1246,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1240
1246
|
retryOrgName();
|
|
1241
1247
|
dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
|
|
1242
1248
|
R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
|
|
1249
|
+
walkWatch(R.current.openIds.ids, readAt); // TUI-CARD-SYNC: the card on screen, answered in another window, closes here too (never awaited)
|
|
1243
1250
|
// Provider paths that emit no approval.requested still surface here, as the same card.
|
|
1244
1251
|
for (const raw of items) {
|
|
1245
1252
|
const item = await enrichPlanItem(raw, { objectiveGet: api.objectiveGet, agents: api.agents, orgs: api.orgs, cache: (R.current.planCache ||= new Map()) });
|
|
@@ -1456,7 +1463,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1456
1463
|
// After approving from the terminal the tool runs asynchronously: poll its stored result.
|
|
1457
1464
|
const pollResult = async (run) => {
|
|
1458
1465
|
if (!run.approvalId || !api.approvalResult) return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
|
|
1459
|
-
const t0 = Date.now();
|
|
1466
|
+
const t0 = Date.now(); let undecided = 0;
|
|
1460
1467
|
while (Date.now() - t0 < resultTimeoutMs) {
|
|
1461
1468
|
await new Promise(r => { const t = setTimeout(() => { R.current.pollTimers.delete(t); r(); }, resultPollMs); R.current.pollTimers.add(t); R.current.pollWake = r; });
|
|
1462
1469
|
if (R.current.closed) return undefined;
|
|
@@ -1464,6 +1471,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1464
1471
|
try {
|
|
1465
1472
|
const r = await api.approvalResult(run.approvalId);
|
|
1466
1473
|
if (r?.status === 'denied') return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'denied' });
|
|
1474
|
+
// Live 2026-11-08 (contacts_dial_batch): the inbox took the yes but the runner never got it, so the approval stayed
|
|
1475
|
+
// pending with nobody as the decider. The runner moves a row off pending the moment it receives a decision, so two
|
|
1476
|
+
// reads like that after our yes are proof nothing ran: say so, never "result not known yet".
|
|
1477
|
+
if (r && r.status === 'pending' && !r.decided_by) { if (++undecided >= 2) return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'unrecorded' }); }
|
|
1478
|
+
else undecided = 0;
|
|
1467
1479
|
if (r && (r.status === 'approved' || r.status === 'error') && r.executed_at) {
|
|
1468
1480
|
const text = r.result == null ? null : clip(typeof r.result === 'string' ? r.result : JSON.stringify(r.result), cols - 20);
|
|
1469
1481
|
// Command reached the host and exited >= 1 (not a connect/auth failure): say "ran, exit N", not "failed".
|
|
@@ -1543,30 +1555,114 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1543
1555
|
// server took it; a line typed while an answer is still on its way is never applied to the card just answered; Esc or any
|
|
1544
1556
|
// command ends the walk with a summary; an id answered in this window never comes back, and a list held from earlier
|
|
1545
1557
|
// (a bulk card, a range) is re-read from the server before its first card.
|
|
1546
|
-
const WALK_TTL =
|
|
1558
|
+
const WALK_TTL = walkTtlMs; // 30 minutes; a prop only so a test can lapse a card
|
|
1547
1559
|
const walkAnsweredIds = () => (R.current.walkAnswered ||= new Set());
|
|
1548
|
-
|
|
1560
|
+
// POPUP-DEDUPE (owner 2026-11-06, hit twice): what this window answered on the inline prompt, by inbox id and by approval id,
|
|
1561
|
+
// so the auto-popup never re-opens it a second later while the server still lists it, and a walk card says so plainly.
|
|
1562
|
+
const inlineAnswered = () => (R.current.inlineAnswered ||= { ids: new Map(), approvals: new Map() });
|
|
1563
|
+
const itemApprovalId = (it) => it?.payload?.approval_id || it?.approval_id || it?.approvalId || it?.aee_correlation_id || null;
|
|
1564
|
+
const answeredInline = (it) => { const m = inlineAnswered(); return m.ids.get(it?.id) || m.approvals.get(itemApprovalId(it)) || null; };
|
|
1565
|
+
const walkCloseCard = (W, why = 'next', settle = null) => { if (W?.card) { W.card.at = Date.now(); W.card.walkWhy = why; if (settle) { W.card.settleText = settle.text; W.card.settleTone = settle.tone; } W.card = null; } }; // why: next/done settle silently; stop = interrupted, gone = closed by the server (settle = the line it leaves)
|
|
1549
1566
|
const walkKeysText = (it) => { const ks = it.payload.options.map(o => o.key); return ks.length > 1 ? `${ks.slice(0, -1).join(', ')} or ${ks[ks.length - 1]}` : ks.join(''); };
|
|
1550
1567
|
// EXPIRED-ASK (owner 2026-10-17 21:23): a question whose time ran out is gone: say so plainly and move on, never "the question stays".
|
|
1551
1568
|
const askExpiry = (it) => { const t = Date.parse(it?.due_at ?? it?.payload?.expires_at); return Number.isFinite(t) ? t : null; };
|
|
1552
1569
|
const expiredText = (it) => { const t = askExpiry(it); return t ? `That question expired at ${askClock(t)}, so there's nothing to answer.` : "That question is no longer open, so there's nothing to answer."; };
|
|
1553
1570
|
const walkGoneElsewhere = (W, id) => { const o = R.current.openIds; return Boolean(o && o.at > W.startedAt && !o.ids.has(id)); };
|
|
1554
|
-
|
|
1571
|
+
// TUI-CARD-SYNC (owner 2026-11-08, two windows on one user: a card answered "a" in one window stayed open in the other and
|
|
1572
|
+
// later said "expired, not answered"): what the SERVER says became of a walk item that is no longer in the open list.
|
|
1573
|
+
// { kind: 'elsewhere', choice, at } | { kind: 'expired', at } | { kind: 'closed', at }. Read only for an item already gone.
|
|
1574
|
+
const walkFateOfGone = async (it) => {
|
|
1575
|
+
let item = null;
|
|
1576
|
+
if (api.inboxItem) { try { item = await api.inboxItem(it.id); } catch { item = null; } }
|
|
1577
|
+
const res = item && item.id ? resolutionFromItem(item) : null;
|
|
1578
|
+
const at = res?.resolved_at ? Date.parse(res.resolved_at) : NaN;
|
|
1579
|
+
if (!res) return { kind: 'closed', at };
|
|
1580
|
+
if (res.outcome === 'expired') return { kind: 'expired', at };
|
|
1581
|
+
const r = item.response || null;
|
|
1582
|
+
if (!r || r.housekeeping || ['withdrawn', 'cancelled'].includes(res.outcome)) return { kind: 'closed', at };
|
|
1583
|
+
const opt = typeof r.option === 'string' ? r.option : null;
|
|
1584
|
+
const label = opt ? it.payload?.options?.find(o => o.key === opt)?.label : null;
|
|
1585
|
+
const choice = opt ? (label && label !== opt ? `${opt}: ${decodeEntities(label)}` : opt) : res.outcome === 'approved' ? 'approved' : res.outcome === 'rejected' ? 'denied' : res.outcome;
|
|
1586
|
+
return { kind: 'elsewhere', choice, at };
|
|
1587
|
+
};
|
|
1588
|
+
const walkTitleQ = (it) => `"${clip(decodeEntities(it?.title || it?.id || 'the question'), Math.max(20, cols - 50))}"`;
|
|
1589
|
+
const walkGoneSettle = (it, fate) => ({ text: settledAskLine(walkTitleQ(it), fate, Number.isFinite(fate.at) ? fate.at : Date.now()), tone: settledAskTone(fate.kind) });
|
|
1590
|
+
// The poll's open list no longer has the card on screen: close it with what the server says, then on to the next card.
|
|
1591
|
+
const walkWatch = async (ids, readAt) => {
|
|
1592
|
+
const W = R.current.pendingWalk; const card = W?.card; const id = W?.current; const it = id ? W.items[id] : null;
|
|
1593
|
+
if (!card || !it || it.__orgId || W.done.has(id) || ids.has(id) || R.current.walkSend || R.current.walkWatching || readAt < (card.shownAt ?? card.at)) return;
|
|
1594
|
+
R.current.walkWatching = true;
|
|
1595
|
+
try {
|
|
1596
|
+
const fate = await walkFateOfGone(it);
|
|
1597
|
+
if (R.current.pendingWalk !== W || W.card !== card || W.current !== id || R.current.walkSend) return; // answered or stopped here meanwhile
|
|
1598
|
+
W.done.add(id); (W.dropped ||= new Set()).add(id);
|
|
1599
|
+
walkCloseCard(W, 'gone', walkGoneSettle(it, fate));
|
|
1600
|
+
walkShow();
|
|
1601
|
+
} finally { R.current.walkWatching = false; }
|
|
1602
|
+
};
|
|
1603
|
+
// A card left open past WALK_TTL: ask the server before saying anything (it may have been answered in another window).
|
|
1604
|
+
const walkLapsed = async (card) => {
|
|
1605
|
+
const W = R.current.pendingWalk; const it = W?.items?.[card.walk];
|
|
1606
|
+
if (!it) { card.settleText = ''; return; }
|
|
1607
|
+
if (it.__orgId) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered · /approvals`; card.settleTone = 'plain'; return; }
|
|
1608
|
+
const now = await openIdsNow();
|
|
1609
|
+
if (!now.ids) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered; couldn't check it with the server (${now.error}) · /approvals`; card.settleTone = 'plain'; return; }
|
|
1610
|
+
if (now.ids.has(it.id)) { card.settleText = `↷ ${walkTitleQ(it)}: this card timed out unanswered; the question is still open · /walkthrough`; card.settleTone = 'plain'; return; }
|
|
1611
|
+
const fate = await walkFateOfGone(it);
|
|
1612
|
+
if (R.current.pendingWalk === W) { W.done.add(it.id); (W.dropped ||= new Set()).add(it.id); }
|
|
1613
|
+
const st = walkGoneSettle(it, fate); card.settleText = st.text; card.settleTone = st.tone;
|
|
1614
|
+
};
|
|
1615
|
+
// fresh = the ids the server still has open (null: not re-checked), so an item answered elsewhere is never "still waiting"
|
|
1616
|
+
const walkSummaryLines = (W, head, fresh = null) => {
|
|
1555
1617
|
const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null;
|
|
1556
|
-
const
|
|
1557
|
-
const
|
|
1618
|
+
const stillOpen = (id) => !fresh || fresh.has(id);
|
|
1619
|
+
const left = W.ids.filter(id => !W.done.has(id) && id !== sending && stillOpen(id)).map(id => W.items[id].title || id);
|
|
1620
|
+
const skipped = W.skipped.filter(stillOpen);
|
|
1621
|
+
const lines = walkSummary({ answered: W.answered, skipped: skipped.map(id => W.items[id].title || id), left });
|
|
1558
1622
|
if (head) lines[0] = lines[0].replace(/^Walkthrough done/, head);
|
|
1559
1623
|
if (sending) lines.push(` … still sending: ${W.items[sending].title || sending}`);
|
|
1560
|
-
return { lines, open: left.length +
|
|
1624
|
+
return { lines, open: left.length + skipped.length };
|
|
1625
|
+
};
|
|
1626
|
+
// WALK-REVALIDATE (owner 2026-11-08 03:56: a window open since the day before listed 8 "still waiting", most answered
|
|
1627
|
+
// hours earlier in another window): the list a walk holds is re-checked with the server before it is shown; a failed
|
|
1628
|
+
// or slow re-check shows the held list, labelled as possibly out of date.
|
|
1629
|
+
const WALK_RECHECK_MS = 4000;
|
|
1630
|
+
const openIdsNow = async () => {
|
|
1631
|
+
let timer;
|
|
1632
|
+
try {
|
|
1633
|
+
const items = await Promise.race([api.approvals(), new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('timed out')), WALK_RECHECK_MS); })]);
|
|
1634
|
+
return { ids: new Set((Array.isArray(items) ? items : []).map(x => x?.id).filter(Boolean)), error: null };
|
|
1635
|
+
} catch (e) { return { ids: null, error: clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80) }; }
|
|
1636
|
+
finally { clearTimeout(timer); }
|
|
1561
1637
|
};
|
|
1562
1638
|
const walkEnd = (why) => { // Esc, a command, or a card left too long: the walk stops here and says where it stands
|
|
1563
1639
|
const W = R.current.pendingWalk; if (!W) return false;
|
|
1640
|
+
const card = W.card; const cur = W.current && !W.done.has(W.current) ? W.items[W.current] : null; // the card on screen: its line comes from the server
|
|
1564
1641
|
R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); walkCloseCard(W, 'stop');
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1642
|
+
const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null; // read now: the send settles on its own
|
|
1643
|
+
const held = W.ids.some(id => !W.done.has(id) && id !== sending) || W.skipped.length;
|
|
1644
|
+
// TUI-CARD-SYNC (owner 2026-11-08: /say during a card printed "The question closed before you answered." while it was
|
|
1645
|
+
// still open): the stopped card says closed only when the server's open list no longer has it.
|
|
1646
|
+
const settleCard = async ({ ids, error }) => {
|
|
1647
|
+
if (!card) return;
|
|
1648
|
+
if (!cur || sending || cur.__orgId) { card.settleText = cur && cur.__orgId && !sending ? `Not answered; it is still open in ${cur.__orgName || 'its org'}.` : ''; card.settleTone = 'plain'; return; }
|
|
1649
|
+
if (!ids) { card.settleText = `Not answered; couldn't check whether the question is still open${error ? ` (${error})` : ''}.`; card.settleTone = 'plain'; return; }
|
|
1650
|
+
if (ids.has(cur.id)) { card.settleText = 'Not answered; the question is still open.'; card.settleTone = 'plain'; return; }
|
|
1651
|
+
const fate = await walkFateOfGone(cur);
|
|
1652
|
+
if (fate.kind === 'elsewhere') { const st = walkGoneSettle(cur, fate); card.settleText = st.text; card.settleTone = st.tone; return; }
|
|
1653
|
+
card.settleText = 'The question closed before you answered.'; card.settleTone = 'plain';
|
|
1654
|
+
};
|
|
1655
|
+
const show = ({ ids, error }) => {
|
|
1656
|
+
settleCard({ ids, error }).catch(() => { if (card) card.settleText = ''; });
|
|
1657
|
+
const { lines, open } = walkSummaryLines(W, `Walkthrough stopped (${why})`, ids);
|
|
1658
|
+
say(' ');
|
|
1659
|
+
lines.forEach((l, k) => say(l, k === 0 ? 'ok' : undefined));
|
|
1660
|
+
if (error && held) say(`⚠ couldn't re-check with the server (${error}); this list may be out of date. /approvals to look again`, 'warn');
|
|
1661
|
+
if (open) say('Nothing else was sent. /walkthrough picks up what is still waiting.', 'dim');
|
|
1662
|
+
queueMicrotask(() => reconcileAsks());
|
|
1663
|
+
};
|
|
1664
|
+
if (!held) { show({ ids: null, error: null }); return true; }
|
|
1665
|
+
R.current.walkEnding = openIdsNow().then(show, () => show({ ids: null, error: 'unknown error' })).finally(() => { R.current.walkEnding = null; });
|
|
1570
1666
|
return true;
|
|
1571
1667
|
};
|
|
1572
1668
|
// TUI-WALK (owner 2026-10-19: he always runs /approvals again to confirm it is empty): the walk ends with a FRESH server
|
|
@@ -1577,6 +1673,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1577
1673
|
catch (e) { say(`⚠ couldn't re-check approvals (${clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80)}); /approvals to look again`, 'warn'); return; }
|
|
1578
1674
|
const gone = walkAnsweredIds();
|
|
1579
1675
|
const open = (Array.isArray(items) ? items : []).filter(x => x && !gone.has(x.id));
|
|
1676
|
+
// TUI-CARD-SYNC (owner 2026-11-08: "Nothing else is currently waiting" above a status line still saying "⏸ 1 approval
|
|
1677
|
+
// waiting"): the counter is set from this same read, so the line and the bar never disagree.
|
|
1678
|
+
R.current.openItems = open; syncApprovalRows(); R.current.openIds = { ids: new Set(open.map(i => i.id)), at: Date.now() };
|
|
1679
|
+
dispatch({ type: 'notify', patch: { approvals: open.length, error: null, slow: false } });
|
|
1580
1680
|
// the closing line repeats the other-org count + switch hint the walk opened with (the helper's own cache: no new token mints)
|
|
1581
1681
|
const sayOthers = async () => { try { const v = api.otherOrgApprovals ? await api.otherOrgApprovals() : null; for (const l of crossOrgTuiLines(v)) say(l, 'warn'); } catch { /* the hint is optional */ } };
|
|
1582
1682
|
if (!open.length) { say('✔ Nothing else is currently waiting for you.', 'ok'); await sayOthers(); return; }
|
|
@@ -1599,13 +1699,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1599
1699
|
if (W.done.has(id)) continue;
|
|
1600
1700
|
if (gone.has(id)) { W.done.add(id); continue; } // answered in this window already: never shown again
|
|
1601
1701
|
{ const ex = askExpiry(W.items[id]); if (ex && ex <= Date.now()) { W.done.add(id); (W.dropped ||= new Set()).add(id); refreshNotify(); say(`"${clip(W.items[id].title || id, 60)}": ${expiredText(W.items[id])}`, 'accent'); continue; } }
|
|
1602
|
-
if (walkGoneElsewhere(W, id)) { W.done.add(id); say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
|
|
1702
|
+
if (walkGoneElsewhere(W, id)) { W.done.add(id); const was = answeredInline(W.items[id]); if (was) { say(`Already ${was === 'approve' ? 'approved' : 'denied'} (you answered it inline). Moving on.`, 'ok'); continue; } say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
|
|
1603
1703
|
nextId = id; break;
|
|
1604
1704
|
}
|
|
1605
1705
|
if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); walkRecheck(W); queueMicrotask(() => reconcileAsks()); return; }
|
|
1606
1706
|
const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
|
|
1607
1707
|
const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
|
|
1608
|
-
W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
|
|
1708
|
+
W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId, shownAt: W.at };
|
|
1609
1709
|
say(' ');
|
|
1610
1710
|
if (it.__orgName) say(`in ${it.__orgName} · answered there directly; this window stays in ${orgShown() || 'its own org'}`, 'warn'); // APPROVALS-AUTO: another org's card says whose it is
|
|
1611
1711
|
c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
|
|
@@ -1625,9 +1725,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1625
1725
|
say(`That is not the name, so nothing was deleted. Type ${d.name} exactly, or Esc to cancel.`, 'warn');
|
|
1626
1726
|
};
|
|
1627
1727
|
const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
|
|
1728
|
+
if (R.current.walkEnding) await R.current.walkEnding; // the stopped walk's summary prints before the next walk's first card
|
|
1628
1729
|
let decs = (list || []).filter(isDecision);
|
|
1629
1730
|
if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
|
|
1630
|
-
|
|
1731
|
+
const now = await openIdsNow();
|
|
1732
|
+
if (now.ids) decs = decs.filter(x => now.ids.has(x.id));
|
|
1733
|
+
else say(`⚠ couldn't re-check these with the server (${now.error}); they may be out of date.`, 'warn'); // the answered-in-this-window set below still applies
|
|
1631
1734
|
}
|
|
1632
1735
|
const gone = walkAnsweredIds();
|
|
1633
1736
|
decs = decs.filter(x => !gone.has(x.id));
|
|
@@ -1647,10 +1750,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1647
1750
|
// APPROVALS-AUTO (owner 2026-11-06: "just make approvals pop up, i am tired of opening them"): a NEW approval or decision
|
|
1648
1751
|
// opens by itself as the walkthrough card. It only OPENS; it never answers. A draft, typing in the last 2s, a streaming
|
|
1649
1752
|
// turn or another card on screen holds it until the next idle point; an open walk simply gains the card.
|
|
1650
|
-
const autoSkip = () => new Set([...S.current.approvals.map(x => x.inboxItemId), ...walkAnsweredIds(), ...(R.current.pendingWalk ? R.current.pendingWalk.ids : [])].filter(Boolean));
|
|
1753
|
+
const autoSkip = (items = []) => new Set([...S.current.approvals.map(x => x.inboxItemId), ...walkAnsweredIds(), ...(R.current.pendingWalk ? R.current.pendingWalk.ids : []), ...items.filter(x => x && !x.__orgId && answeredInline(x)).map(x => x.id)].filter(Boolean));
|
|
1651
1754
|
const autoBusy = () => Boolean(askOpen() || headCard(S.current) || SU.current.isOpen() || LI.current?.isOpen?.() || CN.current?.isOpen?.() || PK.current?.isOpen?.() || R.current.rs || R.current.noteAsk || R.current.delConfirm || R.current.denyReason || K.current.cardFocus || BL.current || AWAY.current.afk);
|
|
1652
1755
|
const autoOpenItems = (items, how) => {
|
|
1653
|
-
const skip = autoSkip(); const list = items.filter(x => !skip.has(x.id));
|
|
1756
|
+
const skip = autoSkip(items); const list = items.filter(x => !skip.has(x.id)); // re-checked right before opening: answered here since it queued
|
|
1654
1757
|
if (!list.length) return;
|
|
1655
1758
|
if (how === 'open') { const n = list.length; say(' '); say(`⏸ ${n === 1 ? 'A new approval just arrived; it is open below' : `${n} new approvals just arrived; they are open below, one at a time`}. Esc stops · /approvals auto off stops them opening by themselves.`, 'warn'); }
|
|
1656
1759
|
const had = R.current.pendingWalk;
|
|
@@ -1663,10 +1766,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1663
1766
|
const d = openDecision(A.st, { draft: B.current.text, lastKeyAt: K.current.lastKeyAt || 0, now: Date.now(), streaming: Boolean(S.current.turn), walkOpen: Boolean(R.current.pendingWalk), askOpen: autoBusy() });
|
|
1664
1767
|
if (!d) return;
|
|
1665
1768
|
if (d === 'open' || d === 'join') { A.waitSaid = false; autoOpenItems(takeQueue(A.st), d); return; }
|
|
1666
|
-
if (d === 'wait-draft' && !A.waitSaid) {
|
|
1769
|
+
if (d === 'wait-draft' && !A.waitSaid) { // TUI-CARD-SYNC: another org's card says whose it is, so it never reads against "Nothing waiting for you"
|
|
1770
|
+
A.waitSaid = true; const q = A.st.queue; const orgs = [...new Set(q.map(x => x.__orgId ? x.__orgName || x.__orgId : ''))];
|
|
1771
|
+
const where = orgs.length === 1 && orgs[0] ? ` in ${orgs[0]}` : orgs.some(Boolean) ? ` (${q.filter(x => x.__orgId).length} in other orgs)` : '';
|
|
1772
|
+
say(`⏸ ${q.length === 1 ? 'A new approval is' : `${q.length} new approvals are`} waiting${where}; it opens once your draft is empty.`, 'dim');
|
|
1773
|
+
}
|
|
1667
1774
|
A.timer = setTimeout(autoTry, 400); A.timer.unref?.();
|
|
1668
1775
|
};
|
|
1669
|
-
const autoArrive = (items, opts = {}) => { const A = AO.current; const r = noteArrivals(A.st, items, { skipIds: autoSkip(), ...opts }); if (A.st.queue.length) autoTry(); return r; };
|
|
1776
|
+
const autoArrive = (items, opts = {}) => { const A = AO.current; const r = noteArrivals(A.st, items, { skipIds: autoSkip(opts.orgId ? [] : items), ...opts }); if (A.st.queue.length) autoTry(); return r; };
|
|
1670
1777
|
// Other orgs: read an org's open items only when its count changed (the counts helper caches 60s), with the user's own token.
|
|
1671
1778
|
const autoOthers = async (v) => {
|
|
1672
1779
|
const A = AO.current; const first = !A.othersDone; A.othersDone = true;
|
|
@@ -1771,7 +1878,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1771
1878
|
try { const pl = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: pl }); R.current.shownIds = pl.map(x => x.id); n = pl.findIndex(x => x.id === it.id) + 1; }
|
|
1772
1879
|
catch (e) { say(`Could not re-read approvals (${clip(String(e?.message || e), 80)}); nothing was sent. Say the choice again.`, 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
|
|
1773
1880
|
}
|
|
1774
|
-
if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
|
|
1881
|
+
if (!n) { const was = answeredInline(it); say(was ? `Already ${was === 'approve' ? 'approved' : 'denied'} (you answered it inline). Moving on.` : `"${it.title}" is no longer waiting (answered or expired). Moving on.`, was ? 'ok' : 'warn'); W.done.add(it.id); walkShow(); return true; }
|
|
1775
1882
|
if (askExpiry(it) && askExpiry(it) <= Date.now()) { W.done.add(it.id); (W.dropped ||= new Set()).add(it.id); refreshNotify(); say(expiredText(it), 'accent'); walkShow(); return true; }
|
|
1776
1883
|
if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
|
|
1777
1884
|
const o = it.payload.options.find(x => x.key === rep.key);
|
|
@@ -1798,14 +1905,29 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1798
1905
|
if (!card.inboxItemId && card.approvalId && !batch) { // an event card that never learned its inbox id: find it, never answer null
|
|
1799
1906
|
try { card = { ...card, inboxItemId: (await api.approvals()).find(i => (i.payload?.approval_id || i.approval_id || i.aee_correlation_id) === card.approvalId)?.id || null }; } catch { /* falls through to the 404 path */ }
|
|
1800
1907
|
}
|
|
1801
|
-
await respondWithStepUp(card, action, { proactive, reason, bulk });
|
|
1908
|
+
const res = await respondWithStepUp(card, action, { proactive, reason, bulk });
|
|
1909
|
+
// A 2xx is not a recorded yes on its own: the server reports the card's real status (an expired approval comes
|
|
1910
|
+
// back as status expired, nothing ran). Only "resolved" lets this window say approved.
|
|
1911
|
+
const st = action === 'approve' && res && typeof res === 'object' ? res.item?.status : null;
|
|
1912
|
+
if (st && st !== 'resolved') {
|
|
1913
|
+
const text = st === 'expired'
|
|
1914
|
+
? `Not approved: ${res.message || 'this approval had expired, so nothing ran. Ask the agent again to get a fresh one.'}`
|
|
1915
|
+
: `Your approval was NOT recorded: the server still shows it ${st}. Nothing ran. Answer it again from /approvals.`;
|
|
1916
|
+
dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, batch, notRecorded: text });
|
|
1917
|
+
return { notRecorded: true };
|
|
1918
|
+
}
|
|
1919
|
+
if (card.inboxItemId) { walkAnsweredIds().add(card.inboxItemId); inlineAnswered().ids.set(card.inboxItemId, action); } // POPUP-DEDUPE
|
|
1920
|
+
if (card.approvalId) inlineAnswered().approvals.set(card.approvalId, action);
|
|
1802
1921
|
// The demo's stream carries on to its own tool result, so no "running, result later" note, no poll, no stale count.
|
|
1803
|
-
|
|
1922
|
+
const consent = action === 'approve' && card.consent && api.grantContactability ? card.consent : null;
|
|
1923
|
+
if (consent) R.current.consentAsk = { ...consent, at: Date.now() }; // the next line typed is the lawful basis (or n)
|
|
1924
|
+
dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch, consent });
|
|
1804
1925
|
if (demoLabel) { B.current = initialBuffer(); dispatch({ type: 'notify', patch: { approvals: 0, error: null } }); } // (a stray early keystroke is not left in the composer)
|
|
1805
|
-
if (action === 'approve' && card.tool !== 'hat.switch' && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
|
|
1926
|
+
if (action === 'approve' && card.tool !== 'hat.switch' && !consent && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
|
|
1806
1927
|
return { ok: true };
|
|
1807
1928
|
} catch (e) {
|
|
1808
1929
|
if (e?.status === 404 || (e?.status === 400 && /not open for response/i.test(`${e?.code || ''} ${e?.body?.error || ''} ${e?.message || ''}`))) { // already resolved elsewhere (another window, /approve all, the dashboard)
|
|
1930
|
+
if (card.inboxItemId) walkAnsweredIds().add(card.inboxItemId); // POPUP-DEDUPE: handled elsewhere is never popped up again
|
|
1809
1931
|
if (!batch) dispatch({ type: 'approval.gone', callId: card.callId, inboxItemId: card.inboxItemId || null });
|
|
1810
1932
|
return { gone: true };
|
|
1811
1933
|
}
|
|
@@ -1938,6 +2060,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1938
2060
|
const open = (signal) => api.stream({ turnId, message: withShared(queued ? QUEUED_FRAME + text : text, shared), agent: via || SN.current.agent || 'quox', conversationId: S.current.ctx.conversationId, ...(handoffFrom ? { handoffFrom } : {}), heldSlot: where.current.slot ? { name: where.current.slot, session: slotSession(), display: myDisplay() } : null, context: turnContext(), overrideId: takeOverride(), ...(MD.current === 'plan' ? { mode: 'plan' } : {}), ...streamFields(MC.current) }, signal);
|
|
1939
2061
|
// QUOXON-FW3: the server was unreachable before it said anything (a redeploy). Keep the message, wait for it
|
|
1940
2062
|
// to come back, and send once. Only a send that produced NO event is ever retried, so nothing runs twice.
|
|
2063
|
+
// Owner 2026-11-07: a "down" send can still have REACHED the server (it registered the turn_id, then the socket
|
|
2064
|
+
// dropped before any event). The resend keeps the same id; if the server says it already holds that id, attach to
|
|
2065
|
+
// that turn and show its answer. If it cannot be shown, the text goes back to the composer (a re-send there mints
|
|
2066
|
+
// a new id), never a bare "turn_id already ran" with the message lost.
|
|
2067
|
+
const resend = async function* (signal) {
|
|
2068
|
+
const it = open(signal)[Symbol.asyncIterator]();
|
|
2069
|
+
let first;
|
|
2070
|
+
try { first = await it.next(); } catch (e) { if (!isTurnIdTaken(e)) throw e; first = null; }
|
|
2071
|
+
if (first && !(first.done === false && isTurnIdTaken(first.value))) {
|
|
2072
|
+
if (first.done) return;
|
|
2073
|
+
yield first.value;
|
|
2074
|
+
for (let n = await it.next(); !n.done; n = await it.next()) yield n.value;
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
if (first) await it.return?.();
|
|
2078
|
+
const conversationId = S.current.ctx.conversationId;
|
|
2079
|
+
const held = Object.assign(new Error('turn held by the server but not attachable'), { code: 'turn_held_unattachable' });
|
|
2080
|
+
if (!conversationId || !api.reattach) throw held;
|
|
2081
|
+
let seen = false;
|
|
2082
|
+
try { for await (const ev of api.reattach({ conversationId, turnId, after: 0 }, signal)) { seen = true; yield ev; } }
|
|
2083
|
+
catch (e) { if (!seen && (e?.status === 404 || e?.status === 410)) throw held; throw e; }
|
|
2084
|
+
};
|
|
1941
2085
|
let r = await consumeTurn(turnId, open, { keepOnDown: true });
|
|
1942
2086
|
if (!r?.down) return r;
|
|
1943
2087
|
say(SERVER_RESTARTING, 'warn');
|
|
@@ -1951,7 +2095,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
1951
2095
|
edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
|
|
1952
2096
|
return say(res === 'cancelled' ? 'Cancelled. Your message is back in the composer; it was not sent.' : 'The Quox server did not come back in time. Your message is back in the composer; it was not sent.', 'warn');
|
|
1953
2097
|
}
|
|
1954
|
-
r = await consumeTurn(turnId,
|
|
2098
|
+
r = await consumeTurn(turnId, resend, { keepOnDown: true });
|
|
2099
|
+
if (r?.held) {
|
|
2100
|
+
dispatch({ type: 'turn.deferred' });
|
|
2101
|
+
edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
|
|
2102
|
+
return say(HELD_NOT_SHOWN, 'warn');
|
|
2103
|
+
}
|
|
1955
2104
|
}
|
|
1956
2105
|
if (r?.down) { dispatch({ type: 'turn.deferred' }); edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text }); say('The server keeps dropping the connection. Your message is back in the composer; it was not sent.', 'warn'); }
|
|
1957
2106
|
return r;
|
|
@@ -2026,7 +2175,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2026
2175
|
dispatchEv({ type: 'turn.begin', turnId });
|
|
2027
2176
|
const ctrl = new AbortController();
|
|
2028
2177
|
R.current.abort = ctrl;
|
|
2029
|
-
let offered = false, sawEvent = false, down = false;
|
|
2178
|
+
let offered = false, sawEvent = false, down = false, held = false;
|
|
2030
2179
|
let answerText = '', suggested = false, gate = null; // the safety net reads the answer when no suggestion came
|
|
2031
2180
|
try {
|
|
2032
2181
|
for await (const ev of open(ctrl.signal)) {
|
|
@@ -2065,7 +2214,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2065
2214
|
dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
|
|
2066
2215
|
}
|
|
2067
2216
|
} catch (e) {
|
|
2068
|
-
if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
|
|
2217
|
+
if (keepOnDown && !sawEvent && !ctrl.signal.aborted && (isServerDown(e) || e?.code === 'turn_held_unattachable')) { down = true; held = e?.code === 'turn_held_unattachable'; }
|
|
2069
2218
|
else if (!ctrl.signal.aborted) {
|
|
2070
2219
|
// FW4: settle in-flight tools explicitly (never leave a row "running"), then explain the cut with a way forward.
|
|
2071
2220
|
if (e?.status === 423 && typeof e?.body?.block_id === 'string') { // QUOXON-STEPUP: a policy block; a person may override it once
|
|
@@ -2118,7 +2267,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2118
2267
|
const ra = R.current.reask; R.current.reask = null; // y/N/c: the chat about an ask is over: the same ask again, fresh window
|
|
2119
2268
|
if (ra && !newOffer && askOpen()) { touchAsks(); if (ra.redo) ra.redo(); else say(ra.text, 'ask'); }
|
|
2120
2269
|
}
|
|
2121
|
-
return down ? { down: true } : undefined;
|
|
2270
|
+
return down ? { down: true, ...(held ? { held: true } : {}) } : undefined;
|
|
2122
2271
|
}
|
|
2123
2272
|
|
|
2124
2273
|
React.useEffect(() => {
|
|
@@ -2166,6 +2315,38 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2166
2315
|
bump(n => n + 1);
|
|
2167
2316
|
return true;
|
|
2168
2317
|
}
|
|
2318
|
+
// MORNINGREPORT-ALWAYS (owner 2026-11-09): with nothing held (DND off) /morningreport still digests what happened since
|
|
2319
|
+
// you were last here (the same since-marker as /away): approvals, slot messages to you, sessions that need you,
|
|
2320
|
+
// incidents, then the /away card (agent actions). Reuses /sitrep, the held slot's messages and /away; never a new path.
|
|
2321
|
+
async function sinceDigest() {
|
|
2322
|
+
const p = parseSince('', { lastSeen: AWAY.current.last }); const sMs = Date.parse(p.since);
|
|
2323
|
+
const part = (fn) => (fn ? Promise.resolve().then(fn).then((v) => ({ ok: true, v }), (e) => ({ ok: false, error: e?.status ? `HTTP ${e.status}` : e?.message || 'failed' })) : Promise.resolve(null));
|
|
2324
|
+
const slot = where.current.slot || null;
|
|
2325
|
+
const [sit, apl, sl, aw] = await Promise.all([
|
|
2326
|
+
part(api.sitrep && (() => api.sitrep())),
|
|
2327
|
+
part(!api.sitrep && api.approvals && (() => api.approvals())),
|
|
2328
|
+
part(slot && api.slotMessages && (() => api.slotMessages(slot, 50))),
|
|
2329
|
+
part(api.away && (() => api.away(p.since)))
|
|
2330
|
+
]);
|
|
2331
|
+
const blind = [];
|
|
2332
|
+
const sd = sit?.ok ? sitrepData(sit.v?.situation, sit.v?.approvals) : null;
|
|
2333
|
+
if (sit && !sit.ok) blind.push(`sitrep (${sit.error})`);
|
|
2334
|
+
let approvals = sd?.approvals || null;
|
|
2335
|
+
if (!approvals && apl?.ok) approvals = (apl.v || []).map((a) => ({ title: a.title || a.summary || a.id }));
|
|
2336
|
+
if (!approvals) approvals = [...S.current.approvals, ...S.current.waiting].map((a) => ({ title: a.title || a.tool || a.callId }));
|
|
2337
|
+
const label = myLabel();
|
|
2338
|
+
const slots = sl?.ok ? (sl.v || []).map((m) => ({ ...m, ts: Date.parse(m.created_at || m.ts) }))
|
|
2339
|
+
.filter((m) => Number.isFinite(m.ts) && m.ts >= sMs && !(label && m.from === label) && !isOwnBroadcast(m, { label, display: myDisplay(), userId: api.userId?.() || null })) : [];
|
|
2340
|
+
if (sl && !sl.ok) blind.push(`slot ${slot} (${sl.error})`);
|
|
2341
|
+
const sessions = [...new Set([...(R.current.roster.allRows || []).filter((x) => x.state === 'waiting').map((x) => String(x.name || x.key || 'a session')), ...(sd?.needs || []).map((n) => String(n.title))])];
|
|
2342
|
+
const incidents = aw?.ok ? (aw.v?.items || []).filter((it) => { const t = Date.parse(it.ts); return (!Number.isFinite(t) || t >= sMs) && (it.kind === 'alert' || /_failed$/.test(String(it.kind || '')) || isIncident(it)); }) : [];
|
|
2343
|
+
for (const [t, tone] of sinceLines({ approvals, slots, sessions, incidents })) say(t, tone);
|
|
2344
|
+
if (blind.length) say(`Could not read: ${blind.join(', ')}. Nothing is assumed there.`, 'warn');
|
|
2345
|
+
if (aw?.ok) dispatch({ type: 'object', obj: { type: 'away', data: aw.v } });
|
|
2346
|
+
else if (aw) say(`away: could not read the digest (${aw.error}). Nothing is assumed.`, 'warn');
|
|
2347
|
+
else say('/away is not available in this mode.', 'warn');
|
|
2348
|
+
return undefined;
|
|
2349
|
+
}
|
|
2169
2350
|
async function dndOn(until = null) {
|
|
2170
2351
|
const at = Date.now(); const untilMs = until ? untilEpoch(until, at) : null;
|
|
2171
2352
|
const note = dndNote(untilMs);
|
|
@@ -2504,6 +2685,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2504
2685
|
|
|
2505
2686
|
// ---- QUOXON-FW8: ! commands ---------------------------------------------------------------------
|
|
2506
2687
|
function bangCommand(bang, { echoed = false } = {}) {
|
|
2688
|
+
if (isWebPty(env)) return say(SHELL_REFUSED, 'warn'); // QUOXON web PTY: the browser terminal has no shell
|
|
2507
2689
|
if (bang.kind === 'reserved') return say('!! is reserved. Use ! followed by a command, e.g. !ls or !quox inbox list.', 'warn');
|
|
2508
2690
|
if (bang.kind === 'empty') return say('Usage: !<command> runs it here, !quox <args> runs this quox as you. Output stays on your screen; /share sends it to Quox.', 'warn');
|
|
2509
2691
|
let plan = { cmd: bang.cmd };
|
|
@@ -2525,7 +2707,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
2525
2707
|
out = cleanOutput(out).replace(/\n+$/, '');
|
|
2526
2708
|
if (out) dispatch({ type: 'bang.out', text: out });
|
|
2527
2709
|
};
|
|
2528
|
-
const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
|
|
2710
|
+
const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, columns: cols - 2, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
|
|
2529
2711
|
const slow = setTimeout(() => { if (R.current.bang?.run === run && !R.current.exiting) say('still running (Ctrl+C stops it)', 'accent'); }, 5000);
|
|
2530
2712
|
R.current.bang = { run, cmd };
|
|
2531
2713
|
run.done.then((r) => {
|
|
@@ -3434,6 +3616,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3434
3616
|
case '/org': {
|
|
3435
3617
|
if (!api.orgs) return say('Org switching is not available in this mode.', 'warn');
|
|
3436
3618
|
const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
|
|
3619
|
+
// Already authorised: the person typed this /org line themselves, or said y to an agent's suggested one (owner 2026-10-15,
|
|
3620
|
+
// 2026-11-06: a typed command is the authorisation, never double-approve). Read once and cleared, so it never carries over.
|
|
3621
|
+
const authorised = R0.preConfirmed === 'org' || R0.typedOrg === line.trim(); R0.preConfirmed = null; R0.typedOrg = null;
|
|
3437
3622
|
const sub = (restArg.match(/^(\S+)\s*(.*)$/) || [])[1] || '';
|
|
3438
3623
|
const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
|
|
3439
3624
|
let list;
|
|
@@ -3472,7 +3657,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3472
3657
|
if (!p) return say('No previous org in this session yet. /org lists yours.', 'warn');
|
|
3473
3658
|
if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
|
|
3474
3659
|
R0.orgPending = { id: p.id, name: p.name, at: Date.now() };
|
|
3475
|
-
if (
|
|
3660
|
+
if (authorised) return command('/org yes');
|
|
3476
3661
|
return ask(`Switch back to ${p.name}?`, { action: `/org switch ${p.id} (${p.name})`, reason: 'This starts a new conversation in that org.' });
|
|
3477
3662
|
}
|
|
3478
3663
|
if (sub !== 'switch') return say('Usage: /org, /org switch <name or id>, or /org back', 'warn');
|
|
@@ -3483,8 +3668,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3483
3668
|
if (!m.org) return say(`No org of yours matches "${query}". /org lists them.`, 'warn');
|
|
3484
3669
|
if (m.org.id === curId) return say(`Already in ${m.org.name}.`);
|
|
3485
3670
|
R0.orgPending = { id: m.org.id, name: m.org.name, at: Date.now() };
|
|
3486
|
-
|
|
3487
|
-
if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
|
|
3671
|
+
if (authorised) return command('/org yes'); // typed, or a y to the suggestion: one confirmation at most, never two
|
|
3488
3672
|
return ask(`Switch to ${m.org.name}?`, { action: `/org switch ${m.org.id} (${m.org.name})`, reason: 'This starts a new conversation in that org.' });
|
|
3489
3673
|
}
|
|
3490
3674
|
case '/todo': case '/todos': {
|
|
@@ -3681,7 +3865,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3681
3865
|
const held = (c) => Boolean(c && ((c.inbox?.length || 0) + (c.slots?.length || 0)));
|
|
3682
3866
|
if (!win || (!AWAY.current.afk && !held(win.col))) { // nothing held in the last window: same since-last-seen digest as /away, honest note first
|
|
3683
3867
|
say(win ? 'Nothing was held in your last do not disturb window. Here is what happened since you were last here.' : 'Nothing was held: do not disturb was off. Here is what happened since you were last here.', 'dim');
|
|
3684
|
-
return
|
|
3868
|
+
return sinceDigest();
|
|
3685
3869
|
}
|
|
3686
3870
|
const col = win.col;
|
|
3687
3871
|
const approvals = Math.max(S.current.notify?.approvals || 0, [...S.current.approvals, ...S.current.waiting].length);
|
|
@@ -3727,8 +3911,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3727
3911
|
return say(`✔ noted for the observer (quoxon${r.viaOrgName ? ` in ${r.viaOrgName}` : ''})`, 'ok');
|
|
3728
3912
|
}
|
|
3729
3913
|
case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
|
|
3730
|
-
const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
|
|
3731
|
-
if (!m) return say(`Usage: ${cmd} <slot
|
|
3914
|
+
const m = parsePokeArgs(restArg, restArg.trim().includes(' ') ? await slotNames(api) : null); // FORCE-WAKE: --force is a no-op, never page text; TUI-WAKE-MULTI: a,b,c or a b c pages every one
|
|
3915
|
+
if (!m) return say(`Usage: ${cmd} <slot>[,<slot>...] [text] (several slots: a,b,c or a b c; an urgent page; they are asked to acknowledge, and an idle session is woken to read it, even with an unsent draft, which is set aside and put back)`, 'warn');
|
|
3732
3916
|
return command(`/page ${m.slot} ${m.text ? m.text.trim() : `are you there? (${cmd.slice(1)} from ${myLabel() || 'the owner'})`}`);
|
|
3733
3917
|
}
|
|
3734
3918
|
case '/away': {
|
|
@@ -3764,13 +3948,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
3764
3948
|
catch (e) { return dispatch({ type: 'error', text: `Could not read the inbox (${e.message}). That does not mean it is empty; try again or run: quox inbox list` }); }
|
|
3765
3949
|
const top = newestFirst(items).slice(0, 10);
|
|
3766
3950
|
R.current.inboxList = top; R.current.inboxSeen = arrivals(items, R.current.inboxSeen || null).ids;
|
|
3767
|
-
|
|
3951
|
+
const barText = () => inboxBarText(newestFirst(items).slice(0, INBOX_BAR_READ), cols, barOpts()); // TUI-CARD-SYNC: the poll's basis, never a 30-item count
|
|
3952
|
+
dispatch({ type: 'notify', patch: { inboxBar: barText() } });
|
|
3768
3953
|
if (q.kind === 'mutes') { for (const l of mutesLines(inboxMutes())) say(l.text, l.tone); return undefined; }
|
|
3769
3954
|
if (q.kind === 'mute' || q.kind === 'unmute') {
|
|
3770
3955
|
const key = q.source.toLowerCase(); const next = { ...inboxMutes() };
|
|
3771
3956
|
if (q.kind === 'mute') next[key] = Date.now() + q.ms; else if (!next[key]) return say(`${q.source} is not muted. ${mark('/inbox mutes')} lists them.`, 'warn'); else delete next[key];
|
|
3772
3957
|
if (api.saveSetting) api.saveSetting('inboxMutes', next); else (R.current.sessionSettings ||= {}).inboxMutes = next;
|
|
3773
|
-
dispatch({ type: 'notify', patch: { inboxBar:
|
|
3958
|
+
dispatch({ type: 'notify', patch: { inboxBar: barText() } });
|
|
3774
3959
|
return say(q.kind === 'mute' ? `✔ muted ${q.source} for ${Math.round(q.ms / 360000) / 10}h: no arrival lines, out of the bar count, still listed in /inbox. Approvals are never muted. ${mark(`/inbox unmute ${q.source}`)} undoes it.` : `✔ unmuted ${q.source}`, 'ok');
|
|
3775
3960
|
}
|
|
3776
3961
|
const pick = (n) => { const it = top[n - 1]; if (!it) say(`No item ${n}: the list has ${top.length}. ${mark('/inbox')} shows it.`, 'warn'); return it; };
|
|
@@ -4157,7 +4342,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
4157
4342
|
if (verb === 'approve' && privOf(it) && !(confirm && !all && !agent)) { heldPriv.push(k); continue; }
|
|
4158
4343
|
try {
|
|
4159
4344
|
const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
|
|
4160
|
-
const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
|
|
4345
|
+
const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null, consent: bulkish || targets.length > 1 ? null : consentOf(it) }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
|
|
4161
4346
|
if (r?.gone) gone.push(k); else if (r?.ok) { done.push(k); R.current.lastAnswered = it.id; }
|
|
4162
4347
|
} catch (e) {
|
|
4163
4348
|
failed.push(`[${k}] ${e.message}`);
|
|
@@ -4283,13 +4468,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
4283
4468
|
}
|
|
4284
4469
|
const st = bind.get(cd.id);
|
|
4285
4470
|
if (askStateOpen(st)) continue;
|
|
4471
|
+
if (st && typeof st === 'object' && st.walk && st.walkWhy !== 'next' && st.walkWhy !== 'done') { // a walkthrough card that did not close normally: its line comes from the server
|
|
4472
|
+
if (!st.walkWhy) { st.walkWhy = 'lapsed'; st.at = now; walkLapsed(st); } // TUI-CARD-SYNC: left open past its window; never "expired" before the server says so
|
|
4473
|
+
if (st.settleText == null && now - (st.at ?? now) < 15000) continue; // still checking
|
|
4474
|
+
bind.delete(cd.id); takeAnswer();
|
|
4475
|
+
if (st.settleText) dispatch({ type: 'ask.settle', id: cd.id, text: st.settleText, tone: st.settleTone || 'plain' });
|
|
4476
|
+
else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
|
|
4477
|
+
continue;
|
|
4478
|
+
}
|
|
4286
4479
|
bind.delete(cd.id);
|
|
4287
4480
|
const expired = st && typeof st === 'object' ? now - (st.at ?? 0) >= ASK_TTL : false;
|
|
4288
4481
|
if (expired && st === SC.current.reattach && st.slot) R.current.declinedSlot = st.slot; // an unanswered reattach counts as a decline across the auto-restart of this window
|
|
4289
4482
|
if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
|
|
4290
4483
|
takeAnswer();
|
|
4291
|
-
|
|
4292
|
-
else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
|
|
4484
|
+
dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
|
|
4293
4485
|
continue;
|
|
4294
4486
|
}
|
|
4295
4487
|
const outcome = expired ? { kind: 'expired' } : takeAnswer() || { kind: st === 'any' || st === 'kc' || st === 'uw' ? 'expired' : 'closed' };
|
|
@@ -4610,6 +4802,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
4610
4802
|
if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { if (R.current.bang) say('A command is still running. Ctrl+C stops it.', 'warn'); else bangCommand(a.bang); bump(n => n + 1); return; }
|
|
4611
4803
|
if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
|
|
4612
4804
|
}
|
|
4805
|
+
if (R.current.consentAsk && !text.startsWith('/')) { // "needs the lawful basis: type it now" after approving a contact-consent card
|
|
4806
|
+
const a = R.current.consentAsk; R.current.consentAsk = null;
|
|
4807
|
+
const basis = text.trim();
|
|
4808
|
+
if (!basis || /^no?$/i.test(basis) || Date.now() - a.at > 600000) { say(`No consent recorded. Contact with ${a.recipient} via ${a.channel} stays denied by default.`, 'dim'); return; }
|
|
4809
|
+
const pr = a.proposed || {};
|
|
4810
|
+
const body = { recipient: a.recipient, channel: a.channel, basis };
|
|
4811
|
+
for (const k of ['purpose', 'quiet_start', 'quiet_end', 'tz_offset_minutes', 'min_interval_minutes', 'max_per_day']) if (pr[k] !== undefined && pr[k] !== null && pr[k] !== '') body[k] = pr[k];
|
|
4812
|
+
(async () => {
|
|
4813
|
+
try {
|
|
4814
|
+
const r = await api.grantContactability(body);
|
|
4815
|
+
if (!r || r.granted !== true) { say(`Consent NOT recorded for ${a.recipient} via ${a.channel}: the server did not confirm it.`, 'warn'); return; }
|
|
4816
|
+
say(`Consent recorded: ${a.recipient} via ${a.channel}, basis "${basis}". Evidence: consent artifact ${r.consent_artifact_id || '(no id returned)'}.`, 'ok');
|
|
4817
|
+
} catch (e) { say(`Consent NOT recorded for ${a.recipient} via ${a.channel} (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.body?.message || e?.message || 'failed'}).`, 'warn'); }
|
|
4818
|
+
})();
|
|
4819
|
+
return;
|
|
4820
|
+
}
|
|
4613
4821
|
if (R.current.hatPending && !text.startsWith('/')) { // "Put on the NSOC hat? y/N" from /hat: y switches (/hat yes still works)
|
|
4614
4822
|
const a = R.current.hatPending;
|
|
4615
4823
|
if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/hat yes'); return; }
|
|
@@ -4672,7 +4880,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
4672
4880
|
}
|
|
4673
4881
|
{ const ls = R.current.listShownAt; R.current.listShownAt = null; // APPROVALS-UX: y/n right after a list (not a card) is not chat to the agent
|
|
4674
4882
|
if (ls && Date.now() - ls < 600000 && /^(y(es)?|no?)$/i.test(text.trim())) { remember(text); R.current.histIdx = -1; say('Which one? /approve 1 or /approvals 1 to see it.', 'warn'); R.current.listShownAt = ls; return; } }
|
|
4675
|
-
if (isOneLineCommand(text)) { PL.current.submitted(buf.text); remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
|
|
4883
|
+
if (isOneLineCommand(text)) { if (/^\/org\s+(switch\s+\S|back\s*$|-\s*$)/i.test(text.trim())) R.current.typedOrg = text.trim(); PL.current.submitted(buf.text); remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
|
|
4676
4884
|
if (!SC.current.active && BARE_COMMAND_WORDS.has(text.toLowerCase()) && findEntry('/' + text.toLowerCase())?.kind === 'command') { // owner 2026-10-26 18:22: "clear" went to the model as chat. Offer the command BEFORE anything is sent; n sends it as chat.
|
|
4677
4885
|
const line = '/' + text.toLowerCase();
|
|
4678
4886
|
remember(text); R.current.histIdx = -1;
|
|
@@ -5024,6 +5232,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
|
|
|
5024
5232
|
return edit({ type: 'down', width: composerWidth() });
|
|
5025
5233
|
}
|
|
5026
5234
|
case 'ctrl-g': {
|
|
5235
|
+
if (isWebPty(env)) { say(SHELL_REFUSED, 'warn'); return; } // QUOXON web PTY: no external editor
|
|
5027
5236
|
const full = sanitizeForSend({ ...B.current }).text ?? B.current.text;
|
|
5028
5237
|
mouseTerm(false); // the editor gets a plain terminal (QUOXON-MOUSE), and tracking comes back after
|
|
5029
5238
|
if (isRawModeSupported) setRawMode(false);
|