quoxcli 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +27 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +295 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +47 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +61 -7
- package/lib/commands/approvals.js +198 -3
- package/lib/commands/ask.js +4 -2
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +142 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +7 -33
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +155 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +1123 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +318 -0
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +53 -3
- package/lib/commands/offload.js +2 -3
- package/lib/commands/org.js +21 -4
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +87 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +20 -2
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +150 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +24 -5
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/vm.js +2 -2
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +37 -3
- package/lib/core/commandRegistrars.js +17 -0
- package/lib/core/config.js +29 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +136 -8
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +73 -7
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +8 -8
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +146 -0
- package/lib/quoxon/api.js +364 -27
- package/lib/quoxon/app.js +1629 -183
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +64 -8
- package/lib/quoxon/approvalSentence.js +48 -0
- package/lib/quoxon/approvalsView.js +124 -7
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +22 -3
- package/lib/quoxon/commands.js +110 -15
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +285 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +160 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +67 -10
- package/lib/quoxon/extensions.js +636 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +182 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +4 -4
- package/lib/quoxon/loopCard.js +460 -0
- package/lib/quoxon/loopCards.js +189 -0
- package/lib/quoxon/loopStream.js +98 -0
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +29 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +49 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +8 -3
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +187 -41
- package/lib/quoxon/roster.js +36 -17
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +139 -2
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +23 -2
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +31 -4
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +34 -3
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +81 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +4 -2
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
package/lib/quoxon/roster.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* nothing (never a placeholder row).
|
|
6
6
|
*/
|
|
7
7
|
import { clip } from './theme.js';
|
|
8
|
+
import { humanReason, wrapText } from './loopCard.js';
|
|
8
9
|
|
|
9
10
|
export const GLYPH = { running: '◐', waiting: '⏸', done: '✔', failed: '✖' };
|
|
10
11
|
/** A row that ended without finishing says HOW: failed is the only error; the rest are neutral (no error colour). */
|
|
11
|
-
export const OUTCOME_GLYPH = { failed: '✖', cancelled: '○', expired: '○', rejected: '⊘' };
|
|
12
|
+
export const OUTCOME_GLYPH = { failed: '✖', halted: '⚠', cancelled: '○', expired: '○', rejected: '⊘' };
|
|
12
13
|
export const FRESH_MS = 10 * 60 * 1000; // finished rows linger this long
|
|
13
14
|
export const USER_DONE_MS = 20 * 1000; // a user-started completion vanishes quickly: rows come and go
|
|
14
15
|
export const PENDING_FRESH_MS = 24 * 3600 * 1000; // an older "pending" is stale, not waiting
|
|
@@ -42,13 +43,17 @@ const FAILED = new Set(['failed', 'error', 'halted', 'cancelled', 'canceled', 't
|
|
|
42
43
|
/** The word for how an ended row ended (state stays 'failed' for the ended-badly group; this says which). */
|
|
43
44
|
export function outcomeOf(status) {
|
|
44
45
|
const st = String(status || '').toLowerCase();
|
|
46
|
+
if (st === 'halted') return 'halted';
|
|
45
47
|
if (st === 'cancelled' || st === 'canceled') return 'cancelled';
|
|
46
48
|
if (st === 'expired') return 'expired';
|
|
47
49
|
if (st === 'rejected') return 'rejected';
|
|
48
50
|
return 'failed';
|
|
49
51
|
}
|
|
50
52
|
/** Word, glyph and whether it is an error, for a row. Non-failed states return their own state. */
|
|
51
|
-
export function endWord(r) { return r.state === 'failed' ? outcomeOf(r.status) : r.state; }
|
|
53
|
+
export function endWord(r) { return r.state === 'failed' ? (r.outcome || outcomeOf(r.status)) : r.state; }
|
|
54
|
+
/** A human-halted loop keeps objective status 'failed'; the server says loop_state 'halted' (older servers: the halt text). */
|
|
55
|
+
const HALT_TEXT = /^Loop (?:halted on demand|stopped by human review)/i;
|
|
56
|
+
const isHaltedLoop = (o) => o.execution_mode === 'loop' && (o.loop_state === 'halted' || String(o.status || '').toLowerCase() === 'halted' || (String(o.status || '').toLowerCase() === 'failed' && HALT_TEXT.test(String(o.error_message || ''))));
|
|
52
57
|
export const glyphOf = (r) => (r.state === 'failed' ? OUTCOME_GLYPH[endWord(r)] : GLYPH[r.state]);
|
|
53
58
|
|
|
54
59
|
/** How long a finished row lingers before it leaves the roster (null while running/waiting). */
|
|
@@ -83,18 +88,22 @@ export function fromObjective(o, now = Date.now()) {
|
|
|
83
88
|
if (!state) return null;
|
|
84
89
|
const loop = o.execution_mode === 'loop';
|
|
85
90
|
let activity = '';
|
|
86
|
-
|
|
91
|
+
const haltedLoop = state === 'failed' && isHaltedLoop(o);
|
|
92
|
+
// a halted loop reads its clean sentence (the server's loop_reason, with the person's name), never the raw record text
|
|
93
|
+
const shownError = !haltedLoop ? o.error_message : (o.loop_reason || ((n) => (n ? `Stopped by ${n[1].trim()}` : 'Halted on demand'))(/^Loop halted on demand by \S+ \(([^)]{1,80})\)/i.exec(String(o.error_message || ''))));
|
|
94
|
+
if (state === 'failed' && shownError) activity = humanReason(shownError); // the sentence whole, minus the raw spawner prefix; the view decides how to show it
|
|
95
|
+
else if (loop && state === 'done') activity = o.iteration_count > 0 ? `${o.iteration_count} turn${o.iteration_count === 1 ? '' : 's'}` : ''; // the row prints "done <age>" itself; a stale "iter 0/5" beside it was the lie
|
|
87
96
|
else if (loop && o.max_iterations) activity = `iter ${o.iteration_count || 0}/${o.max_iterations}`;
|
|
88
97
|
else if (o.task_count) activity = `${o.tasks_completed || 0}/${o.task_count} tasks`;
|
|
89
98
|
else if (state === 'waiting') activity = 'waiting to start';
|
|
90
99
|
const from = startedAt ?? createdAt;
|
|
91
100
|
return {
|
|
92
101
|
key: `objective:${o.id}`, id: o.id, source: 'objectives', background: false, kind: loop ? 'loop' : 'objective', state,
|
|
93
|
-
name: o.title || o.id, activity, status: o.status,
|
|
102
|
+
name: o.title || o.id, activity, status: o.status, ...(haltedLoop ? { outcome: 'halted' } : {}),
|
|
94
103
|
startedAt: from, finishedAt, ageMs: from != null ? (state === 'done' || state === 'failed' ? (finishedAt ?? now) - from : now - from) : null,
|
|
95
104
|
expiryMs: expiryFor(state, USER_DONE_MS),
|
|
96
105
|
progressAt: latest(parseTime(o.updated_at), parseTime(o.last_progress_at), parseTime(o.last_heartbeat_at)),
|
|
97
|
-
detail: { description: o.description, error:
|
|
106
|
+
detail: { description: o.description, error: shownError ? humanReason(shownError) : shownError, tokens: o.tokens_used, toolCalls: o.tool_calls_used }
|
|
98
107
|
};
|
|
99
108
|
}
|
|
100
109
|
|
|
@@ -235,7 +244,7 @@ export function countsLine(rows) {
|
|
|
235
244
|
const n = (s) => rows.filter(r => r.state === s).length;
|
|
236
245
|
const o = (w) => rows.filter(r => r.state === 'failed' && endWord(r) === w).length;
|
|
237
246
|
return [['running', 'running', n('running'), GLYPH.running], ['waiting', 'waiting', n('waiting'), GLYPH.waiting],
|
|
238
|
-
['failed', 'failed', o('failed'), OUTCOME_GLYPH.failed], ['cancelled', 'cancelled', o('cancelled'), OUTCOME_GLYPH.cancelled],
|
|
247
|
+
['failed', 'failed', o('failed'), OUTCOME_GLYPH.failed], ['halted', 'halted', o('halted'), OUTCOME_GLYPH.halted], ['cancelled', 'cancelled', o('cancelled'), OUTCOME_GLYPH.cancelled],
|
|
239
248
|
['expired', 'expired', o('expired'), OUTCOME_GLYPH.expired], ['rejected', 'rejected', o('rejected'), OUTCOME_GLYPH.rejected], ['done', 'finished', n('done'), GLYPH.done]]
|
|
240
249
|
.filter(([, , k]) => k).map(([, w, k, g]) => `${g} ${k} ${w}`).join(' · ');
|
|
241
250
|
}
|
|
@@ -249,46 +258,56 @@ export function countsLine(rows) {
|
|
|
249
258
|
* one row each plus a summary line (owner, live 05:29): the bottom area must never grow with the inbox. A single approval
|
|
250
259
|
* keeps its own row. Focus (the user is walking the roster) shows the real rows so selection stays truthful.
|
|
251
260
|
*/
|
|
252
|
-
export function collapseApprovals(rows, now = Date.now()) {
|
|
261
|
+
export function collapseApprovals(rows, now = Date.now(), staleAt = null) {
|
|
253
262
|
const aps = rows.filter(r => r.kind === 'approval' && r.state === 'waiting');
|
|
254
|
-
if (aps.length < 2) return rows;
|
|
263
|
+
if (aps.length < (staleAt ? 1 : 2)) return rows;
|
|
255
264
|
const started = aps.map(r => r.startedAt).filter(x => x != null);
|
|
256
265
|
const newest = aps.reduce((a, b) => ((b.startedAt ?? -Infinity) > (a.startedAt ?? -Infinity) ? b : a));
|
|
257
266
|
const one = { key: 'approval:*', id: '*', source: 'approvals', kind: 'approval', state: 'waiting', background: false, collapsed: true, count: aps.length,
|
|
258
267
|
name: `${aps.length} approvals waiting`, activity: '/approvals', status: 'pending', startedAt: started.length ? Math.min(...started) : null, finishedAt: null,
|
|
259
|
-
oldestMs: started.length ? now - Math.min(...started) : null, newestTitle: String(newest.detail?.description || newest.name || ''), detail: {} };
|
|
268
|
+
oldestMs: started.length ? now - Math.min(...started) : null, staleAt: staleAt || null, newestTitle: String(newest.detail?.description || newest.name || ''), detail: {} };
|
|
260
269
|
let placed = false;
|
|
261
270
|
return rows.flatMap(r => (aps.includes(r) ? (placed ? [] : (placed = true, [one])) : [r]));
|
|
262
271
|
}
|
|
263
272
|
|
|
264
|
-
|
|
273
|
+
const KIND_ICON = { loop: '∞', objective: '◎' }; // QuoxGlyph registry keys qloop / objective
|
|
274
|
+
export const kindLabel = k => (KIND_ICON[k] ? `${KIND_ICON[k]} ${k}` : k);
|
|
275
|
+
|
|
276
|
+
export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = true, sel = 0, focus = false, now = Date.now(), motion = false, staleAt = null }) {
|
|
265
277
|
if (!on || !rows.length) return { mode: 'hidden', lines: [], more: 0 };
|
|
266
|
-
if (!focus) rows = collapseApprovals(rows, now);
|
|
278
|
+
if (!focus || staleAt) rows = collapseApprovals(rows, now, staleAt); // signed out: the cached count is last-known, always one stale row
|
|
267
279
|
if (cols < MIN_FULL_COLS || termRows < MIN_FULL_ROWS) {
|
|
268
280
|
return { mode: 'summary', lines: [], more: 0, summary: clip(' ' + countsLine(rows) + ' · /roster', cols - 1) };
|
|
269
281
|
}
|
|
270
282
|
const shown = rows.slice(0, max);
|
|
271
283
|
const ageW = Math.max(5, ...shown.map(r => [...ageText(r, now)].length));
|
|
272
|
-
let pos = 0;
|
|
273
|
-
const kindW = Math.min(
|
|
284
|
+
let pos = 0, reasonShown = false;
|
|
285
|
+
const kindW = Math.min(11, Math.max(...shown.map(r => [...kindLabel(r.kind)].length)));
|
|
274
286
|
const lines = shown.map((r, i) => {
|
|
275
287
|
const el = ageText(r, now).padStart(ageW);
|
|
276
288
|
const tone = toneName(r, pos, now); if (r.kind !== 'approval') pos++;
|
|
277
289
|
const actW = Math.min(18, Math.max(0, ...shown.map(x => [...(x.activity || '')].length)));
|
|
278
|
-
const
|
|
290
|
+
const longReason = r.state === 'failed' && [...(r.activity || '')].length > actW; // a reason is never cut: the column says "stopped", the sentence follows wrapped
|
|
291
|
+
const act = (longReason ? clip('stopped', actW) : clip(r.activity || '', actW)).padEnd(actW);
|
|
279
292
|
const fixed = 1 + 1 + 1 + kindW + 1 + 1 + actW + 1 + ageW + 2; // pulse column (space + glyph); marker glyph kind name act elapsed
|
|
280
293
|
const nameW = Math.max(8, cols - 2 - fixed - 1);
|
|
281
294
|
const name = clip(r.name, nameW).padEnd(nameW);
|
|
282
295
|
const marker = focus && i === sel ? '›' : ' ';
|
|
296
|
+
if (r.collapsed && r.staleAt) { // signed out: last-known count with its time, never worded as live (owner live 2026-10-27)
|
|
297
|
+
const d = new Date(r.staleAt), at = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
298
|
+
const text = clip(`${r.count} waiting as of ${at} · sign in to see more: /login`, cols - 4);
|
|
299
|
+
return { key: r.key, kind: 'approval', state: r.state, tone: 'dim', selected: false, marker: ' ', glyph: GLYPH[r.state], rest: ' ' + text, pulse: '', pulseRole: 'dim', pulseKind: 'none', text: ` ${GLYPH[r.state]} ${text}` };
|
|
300
|
+
}
|
|
283
301
|
if (r.collapsed) { // one summary row: count, oldest age, newest title, where to go; clipped to the line
|
|
284
302
|
const head = `${r.count} approvals waiting · oldest ${elapsed(r.oldestMs)} · newest: `, tail = ' · /approvals';
|
|
285
303
|
const room = cols - 5 - [...head].length - [...tail].length;
|
|
286
304
|
const text = room >= 12 ? `${head}${clip(r.newestTitle, room)}${tail}` : clip(`${r.count} approvals waiting · oldest ${elapsed(r.oldestMs)}${tail}`, cols - 4); // too narrow for a title
|
|
287
305
|
return { key: r.key, kind: 'approval', state: r.state, tone: toneName(r, pos, now), selected: false, marker: ' ', glyph: GLYPH[r.state], rest: ' ' + text, pulse: '', pulseRole: 'dim', pulseKind: 'none', text: ` ${GLYPH[r.state]} ${text}` };
|
|
288
306
|
}
|
|
289
|
-
const rest = `${r.kind.padEnd(kindW)} ${name} ${act} ${el}`;
|
|
307
|
+
const rest = `${kindLabel(r.kind).padEnd(kindW)} ${name} ${act} ${el}`;
|
|
290
308
|
const p = pulseOf(r, now, { motion });
|
|
291
|
-
|
|
309
|
+
const extra = longReason && !reasonShown ? (reasonShown = true, wrapText(r.activity, Math.max(20, cols - 8))) : null; // spelled out for the newest stopped row only: the bar must not grow with every old failure
|
|
310
|
+
return { key: r.key, kind: r.kind, state: r.state, tone, extra, selected: focus && i === sel, marker, glyph: glyphOf(r), glyphRole: r.state === 'failed' && endWord(r) === 'halted' ? 'warn' : r.state === 'failed' && endWord(r) !== 'failed' ? 'dim' : null, rest: ' ' + rest, pulse: ' ' + p.glyph, pulseRole: p.role, pulseKind: p.kind, text: `${marker}${glyphOf(r)} ${rest} ${p.glyph}` };
|
|
292
311
|
});
|
|
293
312
|
return { mode: 'full', lines, more: Math.max(0, rows.length - shown.length) };
|
|
294
313
|
}
|
|
@@ -302,6 +321,6 @@ export function peekLines(row, now = Date.now()) {
|
|
|
302
321
|
if (d.description) out.push(`goal ${d.description}`);
|
|
303
322
|
if (d.workflowId) out.push(`workflow ${d.workflowId}`);
|
|
304
323
|
if (d.trigger) out.push(`trigger ${d.trigger}`);
|
|
305
|
-
if (d.error && row.state === 'failed') out.push(`error ${typeof d.error === 'string' ? d.error : JSON.stringify(d.error)}`);
|
|
324
|
+
if (d.error && row.state === 'failed') out.push(`error ${typeof d.error === 'string' ? humanReason(d.error) : JSON.stringify(d.error)}`);
|
|
306
325
|
return out;
|
|
307
326
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /roster text for the two things that are not work rows: attached agents (org lease list) and this box's live
|
|
3
|
+
* `claude --resume` processes. Pure; the process list and the attach list are injected by the caller.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const age = (sec) => {
|
|
7
|
+
const s = Math.max(0, Math.round(sec));
|
|
8
|
+
if (s < 60) return `${s}s`;
|
|
9
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
10
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
11
|
+
return `${Math.floor(s / 86400)}d`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const sinceText = (iso, now) => {
|
|
15
|
+
const t = Date.parse(iso || '');
|
|
16
|
+
return Number.isNaN(t) ? '?' : age((now - t) / 1000);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** attached agents -> one line each: agent, splinter, session, lease id, heartbeat age, expiry. */
|
|
20
|
+
export function attachLines(items, now = Date.now()) {
|
|
21
|
+
if (!items || !items.length) return [' attached agents: none'];
|
|
22
|
+
return items.map((a) => ` ${a.agent_id} ${a.splinter || 'S1'} · ${a.session_label || '(no label)'} · lease ${a.lease_id} · heartbeat ${sinceText(a.last_heartbeat_at, now)} ago · expires ${a.expires_at || '?'}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const where = (p) => (p.tmux ? `tmux ${p.tmux}` : `not in tmux${p.tty && p.tty !== '?' ? ` ${p.tty}` : ''}`);
|
|
26
|
+
const one = (p) => `${p.pid} ${where(p)} ${age(p.ageSec || 0)}`;
|
|
27
|
+
|
|
28
|
+
/** live claude processes -> lines, grouped by name case-insensitively; a name held more than once is flagged. */
|
|
29
|
+
export function sessionLines(procs) {
|
|
30
|
+
if (!procs || !procs.length) return [" this box's sessions: none"];
|
|
31
|
+
const groups = new Map();
|
|
32
|
+
for (const p of procs) {
|
|
33
|
+
const k = String(p.name).toLowerCase();
|
|
34
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
35
|
+
groups.get(k).push(p);
|
|
36
|
+
}
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const g of groups.values()) {
|
|
39
|
+
const name = g[0].name;
|
|
40
|
+
if (g.length === 1) out.push(` ${name}: ${one(g[0])}`);
|
|
41
|
+
else out.push(` ⚠ ${name} x${g.length}: ${g.map(one).join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
package/lib/quoxon/rows.js
CHANGED
|
@@ -21,8 +21,11 @@ import { execFile } from 'node:child_process';
|
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
22
|
import { join, dirname } from 'node:path';
|
|
23
23
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
24
|
+
import { cellWidth } from './cells.js';
|
|
24
25
|
|
|
25
26
|
export const MAX_ROWS = 3;
|
|
27
|
+
/** Lines drawn above the status bar (a row can bring several with `lines`); the stored-row cap stays MAX_ROWS. */
|
|
28
|
+
export const MAX_LINES = 4;
|
|
26
29
|
export const FETCH_TIMEOUT_MS = 5000;
|
|
27
30
|
/** url rows fetch from busy boxes (the proof board is ~400 KB): 15 s. cmd rows keep FETCH_TIMEOUT_MS. */
|
|
28
31
|
export const URL_TIMEOUT_MS = 15000;
|
|
@@ -220,11 +223,55 @@ const trendText = (t) => {
|
|
|
220
223
|
return '';
|
|
221
224
|
};
|
|
222
225
|
|
|
226
|
+
/* ---- cells (contract v1.1, TUI-QPE 2026-10-19) --------------------------------------------------
|
|
227
|
+
* A row may carry `cells`: [{ parts: [{ text, tone?, bold?, keep?, group? }], keep? }]. Each part has its own colour
|
|
228
|
+
* (a tone), so one row can say "70%" in amber and "▼12 broke" in red. `keep` says what goes first when the window is
|
|
229
|
+
* narrow: the lowest keep is dropped whole (a part, the parts sharing its group, or a cell; never half a word); no keep
|
|
230
|
+
* = never dropped. Rows with the same `grid` share one keep order and line their cells up in columns; `min_cols` hides
|
|
231
|
+
* a row in a narrower window.
|
|
232
|
+
*/
|
|
233
|
+
export const CELL_LIMITS = { cells: 6, parts: 12, text: 80 };
|
|
234
|
+
const keepOf = (k) => (typeof k === 'number' && Number.isFinite(k) ? Math.max(0, Math.min(99, k)) : null);
|
|
235
|
+
|
|
236
|
+
function parseCells(list, warnings) {
|
|
237
|
+
if (!Array.isArray(list)) { warnings.push('"cells" is a list of {parts: [{text, tone}]}; ignored.'); return []; }
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const c of list.slice(0, CELL_LIMITS.cells)) {
|
|
240
|
+
const raw = Array.isArray(c?.parts) ? c.parts : typeof c?.text === 'string' ? [c] : null;
|
|
241
|
+
if (!raw) { warnings.push('A cell needs "parts" (or "text"); skipped.'); continue; }
|
|
242
|
+
const parts = [];
|
|
243
|
+
for (const p of raw.slice(0, CELL_LIMITS.parts)) {
|
|
244
|
+
// Keep edge spaces (a part's own joiner, " · reach 214/266"): clean the middle, not the ends.
|
|
245
|
+
const t = typeof p?.text === 'string' ? p.text : typeof p?.text === 'number' ? fmtNum(p.text) : '';
|
|
246
|
+
const lead = /^\s/.test(t) ? ' ' : ''; const trail = /\s$/.test(t) ? ' ' : '';
|
|
247
|
+
const body = cleanText(t, CELL_LIMITS.text);
|
|
248
|
+
if (!body) continue;
|
|
249
|
+
const part = { text: lead + body + trail };
|
|
250
|
+
if (p.tone !== undefined && p.tone !== null) {
|
|
251
|
+
if (STATUS_ROW_TONES.includes(p.tone)) { if (p.tone !== 'neutral') part.tone = p.tone; }
|
|
252
|
+
else warnings.push(`a part's "tone" is one of ${STATUS_ROW_TONES.join(', ')}; "${cleanText(p.tone, 16)}" shows as neutral.`);
|
|
253
|
+
}
|
|
254
|
+
if (p.bold === true) part.bold = true;
|
|
255
|
+
const k = keepOf(p.keep); if (k !== null) part.keep = k;
|
|
256
|
+
if (typeof p.group === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(p.group)) part.group = p.group; // parts that drop together
|
|
257
|
+
parts.push(part);
|
|
258
|
+
}
|
|
259
|
+
if (!parts.length) continue;
|
|
260
|
+
const cell = { parts };
|
|
261
|
+
const k = keepOf(c.keep); if (k !== null) cell.keep = k;
|
|
262
|
+
out.push(cell);
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** cells -> one plain line (screen context, /row list, a narrow fallback). */
|
|
268
|
+
export const cellsText = (cells) => (cells || []).map((c) => c.parts.map((p) => p.text).join('').trim()).join(' │ ');
|
|
269
|
+
|
|
223
270
|
/**
|
|
224
271
|
* Parsed data -> { seg, warnings } | { error }. Lenient on read (an unknown tone shows neutral and is named
|
|
225
272
|
* in warnings, so `quox rows test` can say so); strict on what it prints (everything passes cleanText).
|
|
226
273
|
*/
|
|
227
|
-
export function parseStatusRow(d) {
|
|
274
|
+
export function parseStatusRow(d, depth = 0) {
|
|
228
275
|
if (!d || typeof d !== 'object' || Array.isArray(d)) return { error: 'A status row is a JSON object: {"text": "..."}.' };
|
|
229
276
|
const warnings = [];
|
|
230
277
|
const seg = {};
|
|
@@ -233,7 +280,25 @@ export function parseStatusRow(d) {
|
|
|
233
280
|
if (typeof d.value === 'number' && Number.isFinite(d.value)) seg.value = fmtNum(d.value);
|
|
234
281
|
else if (typeof d.value === 'string' && cleanText(d.value, 24)) seg.value = cleanText(d.value, 24);
|
|
235
282
|
else if (d.value !== undefined && d.value !== null) warnings.push('"value" is a number or a short string; ignored.');
|
|
283
|
+
if (depth === 0 && d.lines !== undefined && d.lines !== null) { // TUI-QPE 2026-10-20: one source, several lines (v1.1)
|
|
284
|
+
if (!Array.isArray(d.lines)) warnings.push('"lines" is a list of status rows; ignored.');
|
|
285
|
+
else {
|
|
286
|
+
const lines = [];
|
|
287
|
+
for (const x of d.lines.slice(0, MAX_LINES)) { const q = parseStatusRow(x, 1); if (q.error) warnings.push(`a line: ${q.error}`); else { lines.push(q.seg); warnings.push(...q.warnings); } }
|
|
288
|
+
if (lines.length) { seg.lines = lines; if (!seg.text) seg.text = lines.map((l) => l.text).join(' / ').slice(0, 160); }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (d.cells !== undefined && d.cells !== null) { // TUI-QPE: coloured, responsive cells (v1.1)
|
|
292
|
+
const cells = parseCells(d.cells, warnings);
|
|
293
|
+
if (cells.length) { seg.cells = cells; if (!seg.text) seg.text = cellsText(cells); }
|
|
294
|
+
}
|
|
236
295
|
if (!seg.text && !seg.value) return { error: 'Not a status row: it needs "text" (see quox rows contract).' };
|
|
296
|
+
if (d.grid !== undefined && d.grid !== null) {
|
|
297
|
+
if (typeof d.grid === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(d.grid)) seg.grid = d.grid; else warnings.push('"grid" is a short name (letters, digits, - and _); ignored.');
|
|
298
|
+
}
|
|
299
|
+
if (d.min_cols !== undefined && d.min_cols !== null) {
|
|
300
|
+
if (Number.isInteger(d.min_cols) && d.min_cols >= 20 && d.min_cols <= 400) seg.minCols = d.min_cols; else warnings.push('"min_cols" is a whole number from 20 to 400; ignored.');
|
|
301
|
+
}
|
|
237
302
|
if (d.tone !== undefined && d.tone !== null) {
|
|
238
303
|
if (STATUS_ROW_TONES.includes(d.tone)) { if (d.tone !== 'neutral') seg.tone = d.tone; }
|
|
239
304
|
else warnings.push(`"tone" is one of ${STATUS_ROW_TONES.join(', ')}; "${cleanText(d.tone, 16)}" shows as neutral.`);
|
|
@@ -620,6 +685,12 @@ export function rowParts(st, { now = Date.now(), labelW = 0 } = {}) {
|
|
|
620
685
|
const live = st.status === 'ok' || (st.status === 'error' && d.role !== 'error');
|
|
621
686
|
if (!live || st.status === 'loading') return [[d.text, d.role]];
|
|
622
687
|
const label = row.label || seg?.label || '';
|
|
688
|
+
if (seg?.cells?.length) { // TUI-QPE: one cells row on its own (quox rows test); RowsBar uses layoutRows
|
|
689
|
+
const out = label ? [[label + ' ', 'brand', true]] : [];
|
|
690
|
+
seg.cells.forEach((c, i) => { if (i) out.push([CELL_SEP, 'label']); for (const p of c.parts) out.push(partSeg(p)); });
|
|
691
|
+
if (st.status === 'error') out.push([` · ${ageMark(now - st.at)} old`, 'warn']);
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
623
694
|
const toneR = seg?.tone ? toneRole(seg.tone) : null;
|
|
624
695
|
if (!label && !seg?.tone && !seg?.value && !seg?.trend) return [[d.text, d.role]];
|
|
625
696
|
const out = [];
|
|
@@ -651,6 +722,178 @@ export function clipParts(parts, width) {
|
|
|
651
722
|
return out;
|
|
652
723
|
}
|
|
653
724
|
|
|
725
|
+
/* ---- cells layout (TUI-QPE) ---------------------------------------------------------------- */
|
|
726
|
+
|
|
727
|
+
export const CELL_SEP = ' │ ';
|
|
728
|
+
const W = (t) => cellWidth(String(t));
|
|
729
|
+
const cellW = (c) => c.parts.reduce((a, p) => a + W(p.text), 0);
|
|
730
|
+
const cellsW = (cs) => cs.reduce((a, c, i) => a + cellW(c) + (i ? W(CELL_SEP) : 0), 0);
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* cells -> the cells that fit in `room` columns. Whole items go, lowest `keep` first (a tie drops the rightmost); an
|
|
734
|
+
* item with no keep stays. If even the kept items do not fit, the line ends at a word boundary with … (never mid-word).
|
|
735
|
+
*/
|
|
736
|
+
/** Drop the one lowest-keep item (a tie drops the rightmost; a part takes its group with it). -> new cells, or null. */
|
|
737
|
+
export function dropLowest(cells) {
|
|
738
|
+
let best = null;
|
|
739
|
+
cells.forEach((c, i) => {
|
|
740
|
+
const cand = (keep, pos, j) => { if (keep == null) return; if (!best || keep < best.keep || (keep === best.keep && pos > best.pos)) best = { keep, pos, i, j }; };
|
|
741
|
+
cand(c.keep, i * 1000 + 999, -1);
|
|
742
|
+
c.parts.forEach((p, j) => cand(p.keep, i * 1000 + j, j));
|
|
743
|
+
});
|
|
744
|
+
if (!best) return null;
|
|
745
|
+
const cs = cells.map((c) => ({ ...c, parts: [...c.parts] }));
|
|
746
|
+
if (best.j < 0) cs.splice(best.i, 1);
|
|
747
|
+
else {
|
|
748
|
+
const c = cs[best.i]; const g = c.parts[best.j].group;
|
|
749
|
+
c.parts = lead(g ? c.parts.filter((p) => p.group !== g) : c.parts.filter((_, j) => j !== best.j));
|
|
750
|
+
if (!c.parts.length) cs.splice(best.i, 1);
|
|
751
|
+
}
|
|
752
|
+
return cs;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function fitCells(cells, room) {
|
|
756
|
+
let cs = (cells || []).map((c) => ({ ...c, parts: c.parts.map((p) => ({ ...p })) }));
|
|
757
|
+
while (cellsW(cs) > room) { const next = dropLowest(cs); if (!next) break; cs = next; }
|
|
758
|
+
if (cellsW(cs) <= room) return { cells: cs, clipped: false };
|
|
759
|
+
// Still too wide with only the must-keep items: cut at the last word that fits.
|
|
760
|
+
const out = []; let used = 0; const max = Math.max(1, room - 1);
|
|
761
|
+
for (let i = 0; i < cs.length; i++) {
|
|
762
|
+
const parts = [];
|
|
763
|
+
if (i) { if (used + W(CELL_SEP) > max) break; used += W(CELL_SEP); }
|
|
764
|
+
let full = true;
|
|
765
|
+
for (const p of cs[i].parts) {
|
|
766
|
+
const w = W(p.text);
|
|
767
|
+
if (used + w <= max) { parts.push(p); used += w; continue; }
|
|
768
|
+
const words = p.text.split(/(\s+)/); let t = '';
|
|
769
|
+
for (const wd of words) { if (used + W(t + wd) > max) break; t += wd; }
|
|
770
|
+
t = t.replace(/\s+$/, '');
|
|
771
|
+
if (t.trim()) { parts.push({ ...p, text: t }); used += W(t); } // whole words only, so this is a word boundary
|
|
772
|
+
full = false; break;
|
|
773
|
+
}
|
|
774
|
+
if (parts.length) out.push({ ...cs[i], parts });
|
|
775
|
+
if (!full) break;
|
|
776
|
+
}
|
|
777
|
+
if (!out.length) out.push({ parts: [{ text: '' }] });
|
|
778
|
+
const last = out[out.length - 1].parts; last[last.length - 1] = { ...last[last.length - 1], text: last[last.length - 1].text.replace(/\s+$/, '') };
|
|
779
|
+
last.push({ text: '…' });
|
|
780
|
+
return { cells: out, clipped: true };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** A cell whose first item was dropped must not start with its joiner (" · CLI 578/958"): trim a leading " · ". */
|
|
784
|
+
const lead = (parts) => (parts.length && /^\s*·\s*/.test(parts[0].text) ? [{ ...parts[0], text: parts[0].text.replace(/^\s*·\s*/, '') }, ...parts.slice(1)] : parts);
|
|
785
|
+
|
|
786
|
+
/** One part -> a [text, role, bold] segment (the RowsBar shape). Tones are palette roles, never grey. */
|
|
787
|
+
const partSeg = (p) => [p.text, p.tone ? toneRole(p.tone) : 'prose', Boolean(p.bold)];
|
|
788
|
+
|
|
789
|
+
/** States whose seg has cells and something to show. */
|
|
790
|
+
const hasCells = (st) => Boolean(st?.seg?.cells?.length) && (st.status === 'ok' || (st.status === 'error' && st.text));
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* The rows RowsBar draws at `cols`, in order -> [{ st, parts }]: rows below their min_cols are left out; cells rows
|
|
794
|
+
* are fitted, and rows sharing a `grid` share a lead (label) column and pad their cells so the separators line up.
|
|
795
|
+
* Other rows keep the classic rowParts + clipParts path, unchanged.
|
|
796
|
+
*/
|
|
797
|
+
/**
|
|
798
|
+
* Rows -> one state per drawn line: a row whose seg has `lines` becomes one state per line (ids `<id>.<n>`, the row's
|
|
799
|
+
* label on the first only, the row's grid unless a line names its own). Other rows pass through unchanged.
|
|
800
|
+
*/
|
|
801
|
+
export function expandLines(states) {
|
|
802
|
+
const out = [];
|
|
803
|
+
for (const st of states || []) {
|
|
804
|
+
const ls = st?.seg?.lines;
|
|
805
|
+
if (!ls?.length || !(st.status === 'ok' || (st.status === 'error' && st.text))) { out.push(st); continue; }
|
|
806
|
+
ls.forEach((seg, i) => out.push({ ...st, row: { ...st.row, id: `${st.row.id}.${i + 1}`, label: i === 0 ? st.row.label : '' },
|
|
807
|
+
text: seg.text, seg: { ...seg, grid: seg.grid || st.seg.grid || `#${st.row.id}`, ...(i === 0 ? {} : { label: undefined }) } }));
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function layoutRows(states, cols, { now = Date.now() } = {}) {
|
|
813
|
+
const shown = expandLines(states).filter((st) => !(st?.seg?.minCols && cols < st.seg.minCols)).slice(0, MAX_LINES);
|
|
814
|
+
const room = Math.max(1, cols - 2);
|
|
815
|
+
const labelW = labelWidth(shown.filter((st) => !hasCells(st)));
|
|
816
|
+
const groups = new Map();
|
|
817
|
+
for (const st of shown) if (hasCells(st)) { const g = st.seg.grid || `#${st.row.id}`; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(st); }
|
|
818
|
+
const done = new Map();
|
|
819
|
+
for (const list of groups.values()) {
|
|
820
|
+
const leadOf = (st) => String(st.row.label || st.seg.label || '');
|
|
821
|
+
const leadW = Math.min(16, Math.max(0, ...list.map((st) => W(leadOf(st)))));
|
|
822
|
+
const leadCols = leadW ? leadW + 1 : 0;
|
|
823
|
+
const roomL = room - leadCols;
|
|
824
|
+
const base = list.map((st) => {
|
|
825
|
+
const cells = st.seg.cells.map((c) => ({ ...c, parts: [...c.parts] }));
|
|
826
|
+
return cells;
|
|
827
|
+
});
|
|
828
|
+
// `keep` is ONE priority across the grid. The layout drops the shortest prefix of the grid-wide order (lowest keep
|
|
829
|
+
// first; a tie drops the rightmost, then the lower row) that lets every row fit with its columns lined up. A wider
|
|
830
|
+
// window can only shorten that prefix, so it never shows less (2026-10-20: a greedy per-row pass did, at 68/69 cols).
|
|
831
|
+
const units = [];
|
|
832
|
+
base.forEach((cells, r) => cells.forEach((c, i) => {
|
|
833
|
+
if (c.keep != null) units.push({ r, i, j: -1, keep: c.keep, pos: i * 1000 + 999 });
|
|
834
|
+
const seenG = new Set();
|
|
835
|
+
c.parts.forEach((p, j) => {
|
|
836
|
+
if (p.keep == null) return;
|
|
837
|
+
if (p.group) { if (seenG.has(p.group)) return; seenG.add(p.group); }
|
|
838
|
+
units.push({ r, i, j, group: p.group, keep: p.keep, pos: i * 1000 + j });
|
|
839
|
+
});
|
|
840
|
+
}));
|
|
841
|
+
units.sort((x, y) => x.keep - y.keep || y.pos - x.pos || y.r - x.r);
|
|
842
|
+
const build = (k) => {
|
|
843
|
+
const gone = units.slice(0, k);
|
|
844
|
+
return base.map((cells, r) => cells.map((c, i) => {
|
|
845
|
+
if (gone.some((u) => u.r === r && u.i === i && u.j < 0)) return null;
|
|
846
|
+
const parts = c.parts.filter((p, j) => !gone.some((u) => u.r === r && u.i === i && u.j >= 0 && (u.group ? p.group === u.group : u.j === j)));
|
|
847
|
+
return parts.length ? { ...c, parts: lead(parts) } : null;
|
|
848
|
+
}).filter(Boolean));
|
|
849
|
+
};
|
|
850
|
+
const colWidths = (rows) => { const w = []; for (const cs of rows) cs.forEach((c, i) => { if (i < cs.length - 1) w[i] = Math.max(w[i] || 0, cellW(c)); }); return w; };
|
|
851
|
+
const alignedW = (cs, w) => cs.reduce((a, c, i) => a + (i < cs.length - 1 ? Math.max(w[i] || 0, cellW(c)) : cellW(c)) + (i ? W(CELL_SEP) : 0), 0);
|
|
852
|
+
let rows = null;
|
|
853
|
+
for (let k = 0; k <= units.length; k++) {
|
|
854
|
+
const cand = build(k);
|
|
855
|
+
// A row whose must-keep items alone are too wide is cut at a word below and left out of the column widths.
|
|
856
|
+
const over = cand.map((cs) => cellsW(cs) > roomL);
|
|
857
|
+
const w = colWidths(cand.filter((_, r) => !over[r]));
|
|
858
|
+
if (cand.every((cs, r) => over[r] ? !units.slice(k).some((u) => u.r === r) : alignedW(cs, w) <= roomL)) { rows = cand; break; }
|
|
859
|
+
}
|
|
860
|
+
rows ??= build(units.length);
|
|
861
|
+
const over = rows.map((cs) => cellsW(cs) > roomL);
|
|
862
|
+
const colW = colWidths(rows.filter((_, r) => !over[r]));
|
|
863
|
+
list.forEach((st, r) => {
|
|
864
|
+
const f = over[r] ? fitCells(rows[r], roomL) : { cells: rows[r], clipped: false };
|
|
865
|
+
const pads = f.cells.map((c, i) => (!f.clipped && i < f.cells.length - 1 ? Math.max(0, (colW[i] || 0) - cellW(c)) : 0));
|
|
866
|
+
const out = [];
|
|
867
|
+
if (leadCols) out.push([leadOf(st) + ' '.repeat(Math.max(0, leadW - W(leadOf(st)))) + ' ', 'brand', true]);
|
|
868
|
+
f.cells.forEach((c, i) => {
|
|
869
|
+
if (i) out.push([CELL_SEP, 'label']);
|
|
870
|
+
for (const p of c.parts) out.push(partSeg(p));
|
|
871
|
+
if (pads[i]) out.push([' '.repeat(pads[i]), 'prose']);
|
|
872
|
+
});
|
|
873
|
+
done.set(st, out);
|
|
874
|
+
});
|
|
875
|
+
// The age of a stale value is said ONCE per grid and only in space the fit left over, never in the fit budget:
|
|
876
|
+
// appended per line it pushed whole items out every minute as the data aged (coordinator 2026-10-20 03:39Z).
|
|
877
|
+
// It goes on the first stale line that has room, else the line with the most room; with no room it is left out.
|
|
878
|
+
const stale = list.filter((st) => st.status === 'error' && st.at != null);
|
|
879
|
+
if (stale.length) {
|
|
880
|
+
const age = ` · ${ageMark(now - Math.min(...stale.map((st) => st.at)))} old`;
|
|
881
|
+
const used = (st) => done.get(st).reduce((a, [t]) => a + W(t), 0);
|
|
882
|
+
const roomOf = (st) => room - used(st);
|
|
883
|
+
const pick = stale.find((st) => roomOf(st) >= W(age)) || [...list].sort((a, b) => roomOf(b) - roomOf(a))[0];
|
|
884
|
+
if (pick && roomOf(pick) >= W(age)) done.get(pick).push([age, 'warn']);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return shown.map((st) => ({ st, parts: done.get(st) || clipParts(rowParts(st, { now, labelW }), room) }));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** /qpe rows N: keep the first n rows of a grid (0 hides the grid). Rows not in the grid are untouched. */
|
|
891
|
+
export function limitGrid(states, grid, n) {
|
|
892
|
+
if (n == null || !Number.isInteger(n) || n < 0) return states;
|
|
893
|
+
let seen = 0;
|
|
894
|
+
return (states || []).filter((st) => st?.seg?.grid !== grid || ++seen <= n);
|
|
895
|
+
}
|
|
896
|
+
|
|
654
897
|
/* ---- local-only store for cmd rows -------------------------------------------------------- */
|
|
655
898
|
|
|
656
899
|
export const localRowsFile = () => join(homedir(), '.config', 'quox', 'quoxon-rows.local.json');
|
|
@@ -48,7 +48,7 @@ const age = (ms) => {
|
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
/** A line the user cannot have meant as a command they want repeated (PIN-bearing verbs): name only. */
|
|
51
|
-
const PIN_VERBS = /^\/(authenticate|authorise|authorize|pin|login|connect|key)\b/i;
|
|
51
|
+
const PIN_VERBS = /^\/(authenticate|authorise|authorize|pin|login|connect|key|verify-email)\b/i;
|
|
52
52
|
|
|
53
53
|
function whoSection(w) {
|
|
54
54
|
const bits = [];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON-SECRET-GUARD: one detector for secret-shaped text, shared by the composer (offer before send), the
|
|
3
|
+
* history writer and the transcript writer (redact before write). Low false positives on purpose: a bare number
|
|
4
|
+
* never matches unless a keyword sits next to it, or it is a Luhn-valid card number.
|
|
5
|
+
*/
|
|
6
|
+
const REDACTED = '[REDACTED';
|
|
7
|
+
|
|
8
|
+
const luhn = (digits) => {
|
|
9
|
+
let sum = 0, dbl = false;
|
|
10
|
+
for (let i = digits.length - 1; i >= 0; i--) { let d = digits.charCodeAt(i) - 48; if (dbl) { d *= 2; if (d > 9) d -= 9; } sum += d; dbl = !dbl; }
|
|
11
|
+
return sum % 10 === 0;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// keyword, then a value: after ":" or "=" anything non-space; after plain space/"is" only a value carrying a digit (4+ chars)
|
|
15
|
+
const KEYWORDS = [
|
|
16
|
+
['pin', 'pin'], ['passcode', 'pass\\s?code'], ['password', 'password|passwd|pass\\s?word'],
|
|
17
|
+
['api-key', 'api[ _-]?key'], ['secret', 'secret'], ['token', 'token']
|
|
18
|
+
];
|
|
19
|
+
const kwRe = (kw) => new RegExp(`\\b(?:${kw})\\b(?:[ \\t]+(?:number|no\\.?|code))?(?:[ \\t]*[:=][ \\t]*["']?([^\\s"',;]+)|[ \\t]+(?:is[ \\t]+)?["']?([^\\s"',;]*\\d[^\\s"',;]*))`, 'gi');
|
|
20
|
+
|
|
21
|
+
const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
|
|
22
|
+
const trimVal = (v) => v.replace(/^[(\[`<*_]+/, '').replace(/[)\]`>*_.,;:!?]+$/, '');
|
|
23
|
+
/**
|
|
24
|
+
* Precision mode: is a keyword-adjacent value really secret-shaped, or just prose ("PIN ask", "token budget",
|
|
25
|
+
* a ULID, a path, a placeholder)? pin/passcode need a 4-8 digit run; the others need a 8+ char value that mixes
|
|
26
|
+
* letters and digits (or carries a symbol) and is not an id, path, url, placeholder or short hex.
|
|
27
|
+
*/
|
|
28
|
+
function preciseValueOk(kind, raw) {
|
|
29
|
+
if (/^[<$\{\[~\/]|^[a-z]+:\/\/|\.\.\.|^\$|^\.{0,2}\//i.test(raw)) return false;
|
|
30
|
+
const v = trimVal(raw);
|
|
31
|
+
if (kind === 'pin' || kind === 'passcode') return /^\d{4,8}$/.test(v);
|
|
32
|
+
if (v.length < 8 || ULID.test(v) || /^[0-9a-f]{7,12}$/i.test(v) || /^\d{4}-\d{2}-\d{2}/.test(v)) return false;
|
|
33
|
+
if (/[<>{}()/\\]|^\W/.test(v) && !/^[\w!@#$%^&*+=.-]+$/.test(v)) return false;
|
|
34
|
+
return /[A-Za-z]/.test(v) && /\d/.test(v);
|
|
35
|
+
}
|
|
36
|
+
const CARD_FORM = /^(?:\d{13,19}|\d{4}(?:[ -]\d{4}){2,3}(?:[ -]\d{1,3})?|\d{4}[ -]\d{6}[ -]\d{5})$/;
|
|
37
|
+
|
|
38
|
+
/** -> [{ kind, start, end }] value spans, sorted, non-overlapping. opts.precise = memory-sync mode (words are not secrets). */
|
|
39
|
+
export function findSecrets(text, opts = {}) {
|
|
40
|
+
const s = String(text ?? '');
|
|
41
|
+
const precise = !!opts.precise;
|
|
42
|
+
const hits = [];
|
|
43
|
+
const add = (kind, start, end) => { if (!/^\[redacted/i.test(s.slice(start, end))) hits.push({ kind, start, end }); };
|
|
44
|
+
const grp = (m, i) => { const v = m[i]; const at = m.index + m[0].lastIndexOf(v); return [at, at + v.length]; };
|
|
45
|
+
for (const [kind, kw] of KEYWORDS) {
|
|
46
|
+
for (const m of s.matchAll(kwRe(kw))) {
|
|
47
|
+
const i = m[1] ? 1 : 2;
|
|
48
|
+
if (i === 2 && m[2].length < 4) continue;
|
|
49
|
+
if (i === 1 && m[1].length < 2) continue;
|
|
50
|
+
if (precise && !preciseValueOk(kind, m[i])) continue;
|
|
51
|
+
add(kind, ...grp(m, i));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const m of s.matchAll(/sort[ \t]*code[^\d\n]{0,15}(\d{2}-?\d{2}-?\d{2})(?!\d)/gi)) add('sort-code', ...grp(m, 1));
|
|
55
|
+
for (const m of s.matchAll(/account(?:[ \t]*(?:number|no\.?|num))?[^\d\n]{0,15}(\d{8})(?!\d)/gi)) add('account-number', ...grp(m, 1));
|
|
56
|
+
for (const m of s.matchAll(/customer[ \t]*(?:number|no\.?|num|id)[^\d\n]{0,15}(\d{6,})(?!\d)/gi)) add('customer-number', ...grp(m, 1));
|
|
57
|
+
for (const m of s.matchAll(/(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])/g)) {
|
|
58
|
+
const d = m[0].replace(/[ -]/g, '');
|
|
59
|
+
if (precise) {
|
|
60
|
+
if (!CARD_FORM.test(m[0]) || /[A-Za-z_]/.test(s[m.index - 1] ?? '') || /[A-Za-z_]/.test(s[m.index + m[0].length] ?? '')) continue;
|
|
61
|
+
if (/^\d{4}(?:-\d{4}){3}$/.test(m[0]) && /[0-9a-f]{4,}-$/i.test(s.slice(Math.max(0, m.index - 6), m.index))) continue;
|
|
62
|
+
}
|
|
63
|
+
if (d.length >= 13 && d.length <= 19 && luhn(d)) add('card-number', m.index, m.index + m[0].length);
|
|
64
|
+
}
|
|
65
|
+
for (const m of s.matchAll(/\bsk-[A-Za-z0-9_-]{8,}/g)) if (!precise || m[0].length >= 20) add('api-key', m.index, m.index + m[0].length);
|
|
66
|
+
for (const m of s.matchAll(/\b(?:ghp|gho|ghs)_[A-Za-z0-9]{16,}/g)) add('api-key', m.index, m.index + m[0].length);
|
|
67
|
+
for (const m of s.matchAll(/\bAKIA[0-9A-Z]{16}\b/g)) add('api-key', m.index, m.index + m[0].length);
|
|
68
|
+
for (const m of s.matchAll(/\beyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]+){1,2}/g)) add('jwt', m.index, m.index + m[0].length);
|
|
69
|
+
hits.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const h of hits) { const last = out[out.length - 1]; if (last && h.start < last.end) continue; out.push(h); }
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** -> unique kinds in order of appearance ([] when clean). */
|
|
76
|
+
export const secretKinds = (text, opts) => [...new Set(findSecrets(text, opts).map(h => h.kind))];
|
|
77
|
+
|
|
78
|
+
/** Matched values become [REDACTED:<kind>]; everything else is untouched. */
|
|
79
|
+
export function redactSecrets(text) {
|
|
80
|
+
if (typeof text !== 'string' || !text) return text;
|
|
81
|
+
const hits = findSecrets(text);
|
|
82
|
+
if (!hits.length) return text;
|
|
83
|
+
let out = '', at = 0;
|
|
84
|
+
for (const h of hits) { out += text.slice(at, h.start) + `${REDACTED}:${h.kind}]`; at = h.end; }
|
|
85
|
+
return out + text.slice(at);
|
|
86
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// TUI-STATUS: which REGISTERED server this quox runs on (the fleet host registry, `quox fleet hosts`).
|
|
2
|
+
// Pure matcher + a once-per-session resolver. Unregistered or any failure -> null: the status line shows nothing,
|
|
3
|
+
// and never the raw hostname (screenshots).
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
/** This machine's non-internal IPv4 addresses. */
|
|
7
|
+
export function localIps(ifaces = os.networkInterfaces()) {
|
|
8
|
+
return Object.values(ifaces || {}).flat().filter(a => a && a.family === 'IPv4' && !a.internal).map(a => a.address);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const bare = (s) => String(s || '').toLowerCase().split('.')[0];
|
|
12
|
+
|
|
13
|
+
/** hosts: [{ id, ip, group }] -> { name, role } for the host matching this machine's hostname or IP, else null. */
|
|
14
|
+
export function matchServer(hosts, { hostname, ips = [] } = {}, registry) {
|
|
15
|
+
if (!Array.isArray(hosts)) return null;
|
|
16
|
+
const hn = bare(hostname);
|
|
17
|
+
const mine = new Set(ips);
|
|
18
|
+
const hit = hosts.find(h => h && h.id && hn && bare(h.id) === hn)
|
|
19
|
+
|| hosts.find(h => h && h.id && h.ip && h.ip !== '0.0.0.0' && mine.has(h.ip));
|
|
20
|
+
if (!hit) return null;
|
|
21
|
+
// DEVFLEET: with a registry the role is the server's PURPOSE (e.g. "main dev"), never the Bastion group; no entry -> nothing.
|
|
22
|
+
if (registry !== undefined) { const p = registry?.servers?.[hit.id]?.purpose; return { name: String(hit.id), role: p ? String(p) : null }; }
|
|
23
|
+
return { name: String(hit.id), role: hit.group ? String(hit.group) : null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The profile's instance host when it is NOT this machine (a remote instance), else null. */
|
|
27
|
+
export function remoteHost(url, { hostname, ips = [] } = {}) {
|
|
28
|
+
let h;
|
|
29
|
+
try { h = new URL(url).hostname.toLowerCase(); } catch { return null; }
|
|
30
|
+
if (!h || h === 'localhost' || h === '::1' || h === '[::1]' || h.startsWith('127.') || mine(h, ips) || bare(h) === bare(hostname)) return null;
|
|
31
|
+
return h;
|
|
32
|
+
}
|
|
33
|
+
function mine(h, ips) { return ips.includes(h); }
|
|
34
|
+
|
|
35
|
+
/** -> { name, role, remote } | null. Never throws; `listHosts` is async () => hosts[]. */
|
|
36
|
+
export async function resolveServerChip({ listHosts, hostname = os.hostname(), ips = localIps(), url = null, registry } = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const m = matchServer(await listHosts(), { hostname, ips }, registry);
|
|
39
|
+
if (!m) return null;
|
|
40
|
+
return { ...m, remote: url ? remoteHost(url, { hostname, ips }) : null };
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
}
|
package/lib/quoxon/session.js
CHANGED
|
@@ -141,6 +141,16 @@ export function sayMissText(input, rows, cap = 8) {
|
|
|
141
141
|
].filter(Boolean).join('\n');
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/** SAY-LAST-TARGET: a word that is a URL, a path or an address (has / : or .) is never a slot or session name. */
|
|
145
|
+
export const looksLikeAddress = (w) => /[/:.]/.test(String(w || ''));
|
|
146
|
+
|
|
147
|
+
/** SAY-LAST-TARGET: "/say" with no usable target and nothing recent to offer: who to, and who is reachable. */
|
|
148
|
+
export function sayWhoText(rows, cap = 8) {
|
|
149
|
+
const held = (Array.isArray(rows) ? rows : []).filter(r => r?.name && r.holder_session && r.state !== 'free');
|
|
150
|
+
const reach = held.slice(0, cap).map(r => `${r.name} (${r.holder_session})`).join(', ');
|
|
151
|
+
return ['Who to? /say <slot> <message>', reach ? `Reachable now: ${reach}${held.length > cap ? `, +${held.length - cap} more (/slots)` : ''}` : null].filter(Boolean).join('\n');
|
|
152
|
+
}
|
|
153
|
+
|
|
144
154
|
const idOf = (a) => String(a?.agent_id ?? a?.id ?? a?.name ?? '');
|
|
145
155
|
const nameOf = (a) => String(a?.name ?? a?.agent_id ?? a?.id ?? '');
|
|
146
156
|
|