quoxcli 0.4.0 → 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.
Files changed (175) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/inbox.js +6 -0
  9. package/lib/api/endpoints/inboxq.js +19 -0
  10. package/lib/api/endpoints/index.js +2 -0
  11. package/lib/api/endpoints/mon.js +122 -0
  12. package/lib/api/endpoints/tools.js +13 -0
  13. package/lib/api/endpoints/v1.js +13 -0
  14. package/lib/commands/admin.js +35 -0
  15. package/lib/commands/agentAttach.js +15 -0
  16. package/lib/commands/approvals.js +193 -3
  17. package/lib/commands/ask.js +1 -1
  18. package/lib/commands/auth.js +64 -11
  19. package/lib/commands/backup.js +11 -4
  20. package/lib/commands/capabilities.js +3 -2
  21. package/lib/commands/chat.js +15 -0
  22. package/lib/commands/claude.js +4 -1
  23. package/lib/commands/compliance.js +109 -0
  24. package/lib/commands/connect.js +69 -0
  25. package/lib/commands/dev.js +6 -8
  26. package/lib/commands/doctor.js +22 -3
  27. package/lib/commands/evidence.js +2 -1
  28. package/lib/commands/gripe.js +24 -13
  29. package/lib/commands/inbox.js +37 -3
  30. package/lib/commands/inboxq.js +147 -0
  31. package/lib/commands/kuma.js +78 -5
  32. package/lib/commands/licenceLinkView.js +311 -0
  33. package/lib/commands/linkedin.js +19 -0
  34. package/lib/commands/loop.js +30 -3
  35. package/lib/commands/lucid.js +1 -0
  36. package/lib/commands/mail.js +161 -20
  37. package/lib/commands/memory.js +68 -4
  38. package/lib/commands/memorySync.js +158 -0
  39. package/lib/commands/mon.js +55 -144
  40. package/lib/commands/notes.js +29 -7
  41. package/lib/commands/notesSeal.js +259 -0
  42. package/lib/commands/objective.js +5 -2
  43. package/lib/commands/offload.js +2 -3
  44. package/lib/commands/plan.js +17 -0
  45. package/lib/commands/provider.js +29 -8
  46. package/lib/commands/qlarity.js +32 -1
  47. package/lib/commands/run.js +2 -1
  48. package/lib/commands/server.js +37 -0
  49. package/lib/commands/setup.js +127 -75
  50. package/lib/commands/skills.js +4 -1
  51. package/lib/commands/slot.js +49 -9
  52. package/lib/commands/status.js +92 -44
  53. package/lib/commands/tool.js +30 -0
  54. package/lib/commands/vault.js +6 -3
  55. package/lib/commands/walkthrough.js +14 -2
  56. package/lib/commands/watch.js +11 -2
  57. package/lib/commands/wazuh.js +824 -0
  58. package/lib/commands/wazuhIntake.js +317 -0
  59. package/lib/commands/wazuhReport.js +192 -0
  60. package/lib/commands/workflow.js +47 -4
  61. package/lib/commands/x.js +50 -0
  62. package/lib/core/announce.js +86 -0
  63. package/lib/core/auth.js +20 -2
  64. package/lib/core/callMe.js +118 -0
  65. package/lib/core/client.js +27 -1
  66. package/lib/core/commandRegistrars.js +8 -0
  67. package/lib/core/config.js +28 -2
  68. package/lib/core/confirm.js +68 -7
  69. package/lib/core/crossOrgApprovals.js +69 -0
  70. package/lib/core/errors.js +52 -1
  71. package/lib/core/governedSession.js +135 -5
  72. package/lib/core/inboxResolution.js +3 -2
  73. package/lib/core/loginGuard.js +73 -0
  74. package/lib/core/offload.js +37 -0
  75. package/lib/core/org-override.js +5 -1
  76. package/lib/core/output.js +1 -1
  77. package/lib/core/profileTrust.js +182 -0
  78. package/lib/core/quoxglyph.js +22 -1
  79. package/lib/core/router.js +14 -0
  80. package/lib/core/stepUpPrompt.js +99 -0
  81. package/lib/core/version.js +1 -1
  82. package/lib/core/walkthrough.js +9 -5
  83. package/lib/dragon/select.js +2 -2
  84. package/lib/quoxon/EXTENSIONS.md +142 -19
  85. package/lib/quoxon/api.js +185 -34
  86. package/lib/quoxon/app.js +1032 -152
  87. package/lib/quoxon/approvalBatch.js +117 -0
  88. package/lib/quoxon/approvalFacts.js +56 -2
  89. package/lib/quoxon/approvalSentence.js +8 -4
  90. package/lib/quoxon/approvalsView.js +69 -11
  91. package/lib/quoxon/autoOpen.js +94 -0
  92. package/lib/quoxon/backgroundWork.js +59 -0
  93. package/lib/quoxon/bang.js +1 -1
  94. package/lib/quoxon/capabilities.js +7 -2
  95. package/lib/quoxon/commandShort.js +7 -0
  96. package/lib/quoxon/commands.js +40 -10
  97. package/lib/quoxon/connect.js +5 -3
  98. package/lib/quoxon/deckLazy.js +58 -0
  99. package/lib/quoxon/destructive.js +35 -0
  100. package/lib/quoxon/dnd.js +71 -0
  101. package/lib/quoxon/dragonCompanion.js +37 -7
  102. package/lib/quoxon/dragonMoments.js +78 -0
  103. package/lib/quoxon/echo.js +1 -0
  104. package/lib/quoxon/events.js +43 -7
  105. package/lib/quoxon/extensions.js +398 -38
  106. package/lib/quoxon/gripeQueue.js +135 -0
  107. package/lib/quoxon/inboxView.js +125 -22
  108. package/lib/quoxon/keys.js +2 -2
  109. package/lib/quoxon/loopCard.js +5 -1
  110. package/lib/quoxon/loopCards.js +17 -8
  111. package/lib/quoxon/loopStream.js +20 -9
  112. package/lib/quoxon/mailView.js +71 -0
  113. package/lib/quoxon/main.js +20 -5
  114. package/lib/quoxon/markdown.js +1 -1
  115. package/lib/quoxon/model.js +15 -0
  116. package/lib/quoxon/neverAnswered.js +20 -0
  117. package/lib/quoxon/objects/away.js +43 -5
  118. package/lib/quoxon/onboarding/connectServer.js +162 -0
  119. package/lib/quoxon/onboarding/copy.js +6 -0
  120. package/lib/quoxon/onboarding/index.js +5 -43
  121. package/lib/quoxon/onboarding/probe.js +20 -1
  122. package/lib/quoxon/onboarding/view.js +16 -3
  123. package/lib/quoxon/orgSwitch.js +22 -0
  124. package/lib/quoxon/print.js +2 -0
  125. package/lib/quoxon/profileSwitch.js +7 -0
  126. package/lib/quoxon/promptHistory.js +3 -1
  127. package/lib/quoxon/render.js +63 -14
  128. package/lib/quoxon/roster.js +10 -5
  129. package/lib/quoxon/rosterSessions.js +44 -0
  130. package/lib/quoxon/screenContext.js +1 -1
  131. package/lib/quoxon/secretShape.js +86 -0
  132. package/lib/quoxon/slash/agents.js +2 -1
  133. package/lib/quoxon/slash/argSources.js +9 -2
  134. package/lib/quoxon/slash/args.js +4 -2
  135. package/lib/quoxon/slash/broadcast.js +18 -5
  136. package/lib/quoxon/slash/broadcastRun.js +13 -2
  137. package/lib/quoxon/slash/cliLeaves.json +63 -1
  138. package/lib/quoxon/slash/hatWalk.js +1 -1
  139. package/lib/quoxon/slash/palette.js +22 -1
  140. package/lib/quoxon/slash/paletteController.js +10 -4
  141. package/lib/quoxon/slash/restart.js +7 -4
  142. package/lib/quoxon/slash/stepUp.js +5 -5
  143. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  144. package/lib/quoxon/slashSpinner.js +35 -0
  145. package/lib/quoxon/slotAttach.js +8 -0
  146. package/lib/quoxon/slotChat.js +17 -0
  147. package/lib/quoxon/slotFormat.js +1 -1
  148. package/lib/quoxon/slotOutbox.js +113 -0
  149. package/lib/quoxon/suggestCommand.js +1 -1
  150. package/lib/quoxon/term.js +25 -1
  151. package/lib/quoxon/transcript.js +2 -1
  152. package/lib/quoxon/welcome.js +1 -1
  153. package/lib/quoxon/whatsnew.js +1 -1
  154. package/lib/quoxon/whatsnew.jsonl +36 -0
  155. package/lib/server/installFlow.js +168 -0
  156. package/lib/server/status.js +15 -1
  157. package/lib/toolOutputShape.js +55 -0
  158. package/package.json +3 -2
  159. package/vendor/dragonlab/SOURCE +3 -3
  160. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  161. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  162. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  163. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  164. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  165. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  166. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  167. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  168. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  169. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  170. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  171. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  172. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  173. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  174. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  175. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * QUOXON (owner 2026-11-05): the render layer for `quox setup license link`.
3
+ *
4
+ * The owner linked the wrong licence twice in one day (one granting 0
5
+ * plugins) because neither the web page nor the CLI said what was being
6
+ * picked. This view frames the code and link, counts the code down live,
7
+ * and on approval names WHAT was linked: licence id, plan, plugins, seats.
8
+ *
9
+ * Everything here is pure (strings in, strings out) so it is testable without
10
+ * a terminal. Colour comes from the house QuoxGlyph module (lib/core/
11
+ * quoxglyph.js: line grammar, brand gradient, status colours) and the spinner
12
+ * frames from the TUI's house spinner; nothing here invents a parallel style.
13
+ * With colour off every function returns plain lines with no escape codes.
14
+ */
15
+
16
+ import { line, setColor, GRAD, STATUS, celebrate } from '../core/quoxglyph.js';
17
+ import { SPIN_FRAMES } from '../quoxon/slashSpinner.js';
18
+ import { motionAllowed } from '../quoxon/motion/clock.js';
19
+
20
+ const R = '\x1b[0m';
21
+ const BOLD = '\x1b[1m';
22
+ const DIM = '\x1b[2m';
23
+ const UNDERLINE = '\x1b[4m';
24
+ const fg = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
25
+ const AMBER = STATUS.warn.rgb;
26
+ const GREEN = STATUS.ok.rgb;
27
+
28
+ // eslint-disable-next-line no-control-regex
29
+ const ESC = /\x1b\]8;;[^\x07]*\x07|\x1b\[[0-9;]*[A-Za-z]/g;
30
+ /** Visible width: escape codes stripped, one column per code point. */
31
+ const vis = (s) => [...String(s).replace(ESC, '')].length;
32
+ const padTo = (s, w) => s + ' '.repeat(Math.max(0, w - vis(s)));
33
+
34
+ /** Run a QuoxGlyph render with colour forced on or off, then hand control back to auto. */
35
+ function withColor(color, fn) {
36
+ setColor(color);
37
+ try { return fn(); } finally { setColor(null); }
38
+ }
39
+
40
+ /** Should this terminal get colour? Same rule as quoxglyph, decided for an injected stream. */
41
+ export function colorFor(term, env = process.env) {
42
+ if (env.QUOX_COLOR === '0') return false;
43
+ if (env.QUOX_COLOR === '1' || env.QUOX_COLOR === '2') return true;
44
+ return Boolean(term && term.isTTY) && !env.NO_COLOR;
45
+ }
46
+
47
+ /** Should the waiting line animate? TTY only, and never under reduced motion. */
48
+ export const animateFor = (term, env = process.env) => motionAllowed(Boolean(term && term.isTTY), env);
49
+
50
+ /** m:ss, floored at 0:00. */
51
+ export function fmtCountdown(ms) {
52
+ const total = Math.max(0, Math.floor(Number(ms) / 1000) || 0);
53
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
54
+ }
55
+
56
+ /** WQXP-7T4K -> "W Q X P - 7 T 4 K". */
57
+ export const spacedCode = (code) => [...String(code || '')].join(' ');
58
+
59
+ /** Paint text in the brand gradient (per visible character), bold. */
60
+ function gradient(text) {
61
+ const chars = [...text];
62
+ const last = Math.max(1, chars.length - 1);
63
+ return BOLD + chars.map((ch, i) => (ch === ' ' ? ch
64
+ : fg(GRAD[0].map((v, k) => Math.round(v + (GRAD[1][k] - v) * (i / last)))) + ch)).join('') + R;
65
+ }
66
+
67
+ /** ▰▰▰▱▱ in the brand gradient, for a 0..1 ratio. */
68
+ function bar(ratio, width = 10) {
69
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
70
+ const last = Math.max(1, width - 1);
71
+ let out = '';
72
+ for (let i = 0; i < filled; i++) out += fg(GRAD[0].map((v, k) => Math.round(v + (GRAD[1][k] - v) * (i / last)))) + '▰';
73
+ return out + (filled ? R : '') + DIM + '▱'.repeat(width - filled) + R;
74
+ }
75
+
76
+ /** OSC 8 hyperlink whose visible text is the full URL, so it is clickable either way. */
77
+ const hyperlink = (url) => `\x1b]8;;${url}\x07${UNDERLINE}${fg(GRAD[0])}${url}${R}\x1b]8;;\x07`;
78
+
79
+ /**
80
+ * A rounded frame around rows (already styled). The title sits in the top
81
+ * border. The frame grows to fit its widest row (a URL is never clipped).
82
+ */
83
+ function frame(rows, { title = '', cols = 80, tint = null } = {}) {
84
+ const b = (s) => (tint ? fg(tint) + s + R : DIM + s + R);
85
+ const titleText = title ? ` ${title} ` : '';
86
+ const inner = Math.max(44, Math.min(Math.max(44, cols - 6), 76), ...rows.map((r) => vis(r) + 4), vis(titleText) + 4);
87
+ const top = b('╭─') + titleText + b('─'.repeat(Math.max(0, inner - 1 - vis(titleText))) + '╮');
88
+ const body = rows.map((r) => b('│') + padTo(` ${r}`, inner) + b('│'));
89
+ const bottom = b('╰' + '─'.repeat(inner) + '╯');
90
+ return [top, ...body, bottom].map((l) => ` ${l}`).join('\n');
91
+ }
92
+
93
+ /** Greedy wrap of items into lines no wider than `width`, joined by `sep`. */
94
+ function wrapList(items, width, sep = ' · ') {
95
+ const lines = [];
96
+ let cur = '';
97
+ for (const item of items) {
98
+ const next = cur ? cur + sep + item : item;
99
+ if (cur && vis(next) > width) { lines.push(cur); cur = item; } else cur = next;
100
+ }
101
+ if (cur) lines.push(cur);
102
+ return lines;
103
+ }
104
+
105
+ const minutes = (ms) => Math.max(1, Math.round(ms / 60000));
106
+
107
+ /**
108
+ * The card shown while the code waits for approval.
109
+ * @param {{userCode:string,url:string,remainingMs:number,color:boolean,cols?:number}} o
110
+ */
111
+ export function renderLinkCard({ userCode, url, remainingMs, color, cols = 80 }) {
112
+ const header = withColor(color, () => line('quox', 'licence link', { result: 'approve on quox.ai', status: 'busy' }));
113
+ if (!color) {
114
+ return [
115
+ header,
116
+ `Open ${url} and enter this code:`,
117
+ '',
118
+ ` ${userCode}`,
119
+ '',
120
+ `The code is good for ${minutes(remainingMs)} minutes.`,
121
+ 'Pick the licence you want on quox.ai: this instance gets exactly what you approve.',
122
+ 'Waiting for approval...',
123
+ ].join('\n');
124
+ }
125
+ const label = (s) => `${DIM}${s.padEnd(11)}${R}`;
126
+ const rows = [
127
+ '',
128
+ `${label('Your code')}${gradient(spacedCode(userCode))}`,
129
+ '',
130
+ `${label('Open')}${hyperlink(url)}`,
131
+ `${label('Expires in')}${bar(1)} ${BOLD}${fmtCountdown(remainingMs)}${R}`,
132
+ '',
133
+ `${fg(AMBER)}Pick the licence you want on quox.ai.${R}`,
134
+ `${DIM}This instance gets exactly what you approve.${R}`,
135
+ ];
136
+ return [header, frame(rows, { title: `${BOLD}Link this instance${R}`, cols })].join('\n');
137
+ }
138
+
139
+ /**
140
+ * One frame of the live waiting line (TTY only): spinner, label, countdown.
141
+ * @param {{frame:number,remainingMs:number,totalMs?:number,color:boolean,note?:string}} o
142
+ */
143
+ export function renderWaitingLine({ frame: i, remainingMs, totalMs = 600000, color, note = '' }) {
144
+ const spin = SPIN_FRAMES[i % SPIN_FRAMES.length];
145
+ const left = `${fmtCountdown(remainingMs)} left`;
146
+ const extra = note ? ` · ${note}` : '';
147
+ if (!color) return `${spin} Waiting for approval on quox.ai · ${left}${extra}`;
148
+ const ratio = totalMs > 0 ? remainingMs / totalMs : 0;
149
+ const tone = ratio < 0.2 ? fg(AMBER) : '';
150
+ return ` ${fg(GRAD[0])}${spin}${R} Waiting for approval on quox.ai ${DIM}│${R} ${bar(ratio)} ${tone}${BOLD}${left}${R}${note ? `${DIM}${extra}${R}` : ''}`;
151
+ }
152
+
153
+ const bareId = (raw) => String(raw).split('@')[0];
154
+ const orgAllowance = (raw) => {
155
+ const m = /@orgs=(\d+)/.exec(String(raw));
156
+ return m ? Number(m[1]) : null;
157
+ };
158
+
159
+ /**
160
+ * What was linked, from the approved lease plus the read-back of
161
+ * GET /api/setup/licensing/entitlements (the route `quox licence status`
162
+ * reads). The read-back wins where it has the licence; the lease is the
163
+ * fallback, so a failed read never hides what was activated.
164
+ *
165
+ * @returns {{license_id, plans:string[], entitlements:string[], seats:Array<{plugin,used,total}>, warnings:string[], servers?:{used,total}}}
166
+ */
167
+ export function linkedDetails(lease = {}, status = null, poll = {}) {
168
+ const id = lease.license_id || null;
169
+ const row = (status && Array.isArray(status.licenses))
170
+ ? status.licenses.find((l) => l && l.license_id === id) : null;
171
+
172
+ let rawEntitlements;
173
+ let plans;
174
+ if (row) {
175
+ rawEntitlements = row.entitlements || [];
176
+ plans = row.plans || [];
177
+ } else {
178
+ const raw = lease.entitlements || [];
179
+ plans = raw.filter((e) => String(e).toLowerCase().startsWith('plan.')).map((e) => bareId(e).slice(5).toLowerCase()).filter(Boolean);
180
+ rawEntitlements = raw.filter((e) => !String(e).toLowerCase().startsWith('plan.'));
181
+ }
182
+
183
+ const bands = (status && status.org_bands && typeof status.org_bands === 'object') ? status.org_bands : {};
184
+ const entitlements = [...new Set(rawEntitlements.map(bareId))];
185
+ const seats = [];
186
+ for (const raw of rawEntitlements) {
187
+ const plugin = bareId(raw);
188
+ const band = bands[plugin];
189
+ if (band) seats.push({ plugin, used: Number(band.assigned_count ?? 0), total: Number(band.allowance ?? 0) });
190
+ else if (orgAllowance(raw) != null) seats.push({ plugin, used: null, total: orgAllowance(raw) });
191
+ }
192
+
193
+ const out = { license_id: id, plans, entitlements, seats, warnings: entitlements.length === 0 ? ['zero_entitlements'] : [] };
194
+ // Server seats travel only when the poll response carries them (the
195
+ // control plane sends max_servers and bound; additive, never guessed).
196
+ if (poll && poll.max_servers != null) {
197
+ out.servers = { used: Array.isArray(poll.bound) ? poll.bound.length : null, total: Number(poll.max_servers) };
198
+ }
199
+ return out;
200
+ }
201
+
202
+ const seatText = (s) => (s.used == null ? `${s.plugin} up to ${s.total} orgs` : `${s.plugin} ${s.used} of ${s.total} orgs`);
203
+ const leaseText = (iso) => {
204
+ const t = Date.parse(iso);
205
+ return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : null;
206
+ };
207
+
208
+ /** The exact command that unlinks this licence and starts a fresh link. */
209
+ export function relinkCommand(licenseId, profile) {
210
+ const q = profile ? `quox -p ${profile}` : 'quox';
211
+ return `${q} setup license deactivate --license-id ${licenseId} --yes && ${q} setup license link`;
212
+ }
213
+
214
+ /**
215
+ * The result after approval: compact, celebratory when the licence grants
216
+ * something, amber with the one fix command when it grants nothing.
217
+ * @param {ReturnType<typeof linkedDetails>} d
218
+ * @param {{color:boolean, cols?:number, leaseExpiresAt?:string, profile?:string, readBackError?:string}} o
219
+ */
220
+ export function renderLinkedCard(d, { color, cols = 80, leaseExpiresAt = null, profile = null, readBackError = null } = {}) {
221
+ const zero = d.warnings.includes('zero_entitlements');
222
+ const header = withColor(color, () => (zero
223
+ ? line('quox', 'licence linked', { result: 'grants 0 plugins', status: 'warn' })
224
+ : line('quox', 'licence linked', { result: color ? celebrate('instance activated') : 'instance activated', status: 'ok', max: 400 })));
225
+ const plan = d.plans.length ? d.plans.join(', ') : '(none on this licence)';
226
+ const renews = leaseText(leaseExpiresAt);
227
+ const fix = relinkCommand(d.license_id, profile);
228
+ const warnText = 'This licence grants 0 plugins, so nothing unlocks. You may have picked the wrong licence on quox.ai.';
229
+ const readBack = readBackError
230
+ ? `Could not read the licence details back (${readBackError}). Showing what the lease carried; run \`quox licence status\` to check.`
231
+ : null;
232
+
233
+ if (!color) {
234
+ const out = [header];
235
+ if (readBack) out.push(readBack);
236
+ out.push(`Licence: ${d.license_id}`);
237
+ out.push(`Plan: ${plan}`);
238
+ out.push(`Plugins (${d.entitlements.length}): ${d.entitlements.join(', ') || 'none'}`);
239
+ if (d.seats.length) out.push(`Seats: ${d.seats.map(seatText).join(', ')}`);
240
+ if (d.servers) out.push(`Servers: ${d.servers.used ?? '?'} of ${d.servers.total} in use`);
241
+ if (renews) out.push(`Lease renews by: ${renews}`);
242
+ if (zero) {
243
+ out.push(`⚠ ${warnText}`);
244
+ out.push('Unlink it and link the right one:');
245
+ out.push(` ${fix}`);
246
+ } else {
247
+ out.push('This instance is activated. No key was ever pasted into it.');
248
+ }
249
+ return out.join('\n');
250
+ }
251
+
252
+ const label = (s) => `${DIM}${s.padEnd(10)}${R}`;
253
+ const width = Math.min(Math.max(44, cols - 6), 76) - 14;
254
+ const rows = [
255
+ `${label('Licence')}${BOLD}${d.license_id}${R}`,
256
+ `${label('Plan')}${d.plans.length ? gradient(plan) : `${DIM}${plan}${R}`}`,
257
+ ];
258
+ if (zero) {
259
+ rows.push(`${label('Plugins')}${fg(AMBER)}${BOLD}0${R}${fg(AMBER)} entitled${R}`);
260
+ } else {
261
+ const list = wrapList(d.entitlements, width);
262
+ rows.push(`${label('Plugins')}${BOLD}${d.entitlements.length}${R} ${DIM}entitled${R}`);
263
+ for (const l of list) rows.push(`${' '.repeat(10)}${fg(GREEN)}${l}${R}`);
264
+ }
265
+ for (const [n, s] of d.seats.entries()) {
266
+ const ratio = s.used == null || !s.total ? 0 : s.used / s.total;
267
+ const text = s.used == null ? `up to ${s.total} orgs` : `${s.used} of ${s.total} orgs`;
268
+ rows.push(`${label(n === 0 ? 'Seats' : '')}${s.plugin} ${bar(ratio, 6)} ${text}`);
269
+ }
270
+ if (d.servers) rows.push(`${label('Servers')}${d.servers.used ?? '?'} of ${d.servers.total} in use`);
271
+ if (renews) rows.push(`${label('Lease')}${DIM}renews by ${renews}${R}`);
272
+
273
+ const title = zero
274
+ ? `${fg(AMBER)}${BOLD}${STATUS.warn.glyph} Linked, but it grants 0 plugins${R}`
275
+ : `${fg(GREEN)}${BOLD}${STATUS.ok.glyph} Licence linked${R}`;
276
+ const out = [header];
277
+ if (readBack) out.push(` ${fg(AMBER)}${readBack}${R}`);
278
+ out.push(frame(rows, { title, cols, tint: zero ? AMBER : GREEN }));
279
+ if (zero) {
280
+ out.push(` ${fg(AMBER)}${BOLD}${STATUS.warn.glyph}${R} ${fg(AMBER)}${warnText}${R}`);
281
+ out.push(` ${DIM}Unlink it and link the right one:${R}`);
282
+ out.push(` ${BOLD}${fix}${R}`);
283
+ } else {
284
+ out.push(` ${DIM}No key was ever pasted into this instance.${R}`);
285
+ }
286
+ return out.join('\n');
287
+ }
288
+
289
+ /**
290
+ * The live waiting indicator. On an animating TTY it redraws one line in
291
+ * place every 80 ms (house spinner pace) with the countdown; with motion off
292
+ * it draws once. `note()` prints a line above it without tearing the spinner.
293
+ * `stop()` clears the line. Never used when the stream is not a TTY.
294
+ */
295
+ export function startWaiting({ term, deadline, totalMs, color, animate, now = () => Date.now(), frameMs = 80 }) {
296
+ let i = 0;
297
+ let note = '';
298
+ const draw = () => {
299
+ term.write('\r\x1b[2K' + renderWaitingLine({ frame: i++, remainingMs: deadline - now(), totalMs, color, note }));
300
+ };
301
+ draw();
302
+ const timer = animate ? setInterval(draw, frameMs) : null;
303
+ if (timer && typeof timer.unref === 'function') timer.unref();
304
+ return {
305
+ note(text) { note = text; draw(); },
306
+ stop() {
307
+ if (timer) clearInterval(timer);
308
+ term.write('\r\x1b[2K');
309
+ },
310
+ };
311
+ }
@@ -4,6 +4,7 @@
4
4
  * quox linkedin post "<text>" --as <author_urn> [--visibility PUBLIC|CONNECTIONS] [--link <url>] [--image-url <https url>]
5
5
  * quox linkedin comment <post_urn> "<text>" --as <actor_urn>
6
6
  * quox linkedin posts <author_urn> [--count <n>]
7
+ * quox linkedin whoami
7
8
  * quox linkedin org <org_id>
8
9
  *
9
10
  * Both write verbs execute the linkedin connector tools through the governed
@@ -154,6 +155,24 @@ export function registerLinkedInCommands(program, ctx) {
154
155
  }
155
156
  });
156
157
 
158
+ // ── linkedin me (read) ───────────────────────────────────────────────────
159
+
160
+ linkedin
161
+ .command('whoami')
162
+ .description('Show the connected member identity and the member URN to use with --as on posts (read-only). Needs the openid and profile scopes on the token.')
163
+ .action(async () => {
164
+ const data = await executeLinkedInTool({ client, auth, program }, 'linkedin_get_me', {});
165
+
166
+ if (program.opts().output === 'json') {
167
+ formatDetail(data, { format: 'json' });
168
+ return;
169
+ }
170
+ const me = data.result || {};
171
+ printSuccess(`${me.name || 'Unknown'} (${me.member_urn || 'no member URN returned'})`);
172
+ if (me.email) printInfo(`Email: ${me.email}`);
173
+ if (me.member_urn) printInfo(`Use as author: --as ${me.member_urn}`);
174
+ });
175
+
157
176
  // ── linkedin org (read) ──────────────────────────────────────────────────
158
177
 
159
178
  linkedin
@@ -29,6 +29,32 @@ function toMs(ts) {
29
29
  return Number.isNaN(n) ? null : n;
30
30
  }
31
31
 
32
+ const LIVE_STATES = new Set(['pending', 'starting', 'decomposing', 'running', 'waiting']);
33
+
34
+ /**
35
+ * One row, one vocabulary. The list route counts turn events, which loops driven by the runner never write, so a
36
+ * finished loop could read 0 there. The server now sends a real 0 (started, ran nothing) and null (no record at
37
+ * all); a number from the server is kept, null stays unknown unless the loop is live. objective_status is the objective row's word ('cancelled' for a
38
+ * human halt); `state` is the loop's, so the row says one thing and the raw word is kept as objective_row_status.
39
+ */
40
+ export function normLoop(l) {
41
+ const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
42
+ const count = n(l.turn) || n(l.iteration_count);
43
+ // the server's turn is authoritative: a real 0 (started, ran nothing) stays 0; only null/absent means unknown
44
+ const turn = count || (l.turn === 0 || LIVE_STATES.has(l.state) ? 0 : null);
45
+ const out = { ...l, turn };
46
+ if (l.objective_status !== undefined && l.state && l.objective_status !== l.state) {
47
+ out.objective_row_status = l.objective_status;
48
+ out.objective_status = l.state;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function turnLabel(l) {
54
+ if (l.turn === null) return '-';
55
+ return l.max_turns ? `${l.turn}/${l.max_turns}` : String(l.turn);
56
+ }
57
+
32
58
  const SETTLE_MAX_MS = 90000;
33
59
  const SETTLE_POLL_MS = 4000;
34
60
 
@@ -149,7 +175,7 @@ function oneLineErr(err) {
149
175
 
150
176
  export function statusLines(s, { diagnostics = false } = {}) {
151
177
  const lines = [];
152
- const turns = `turn ${s.turn}${s.max_turns ? `/${s.max_turns}` : ''}`;
178
+ const turns = `turn ${s.turn === null || s.turn === undefined ? '-' : s.turn}${s.max_turns ? `/${s.max_turns}` : ''}`;
153
179
  lines.push(loopLine(s.title || s.objective_id, `${s.state}, ${turns}`, s.state === 'running' ? 'busy' : (s.state === 'completed' ? 'ok' : (s.state === 'completed_with_errors' || s.state === 'halted' || s.state === 'interrupted' ? 'warn' : (s.finished ? 'fail' : 'busy')))));
154
180
  lines.push(` id ${s.objective_id}`);
155
181
  lines.push(` host ${s.host || '-'} backend ${s.backend || '-'} scope ${(s.scope || []).join(',') || '-'}`);
@@ -240,11 +266,12 @@ export function registerLoopCommands(program, ctx) {
240
266
  auth.requireAuth();
241
267
  const ep = resolveEndpoint('loops.list');
242
268
  const data = await client.get(ep.path, { service: ep.service, query: { limit: options.limit } });
269
+ const loops = (data.loops || []).map(normLoop);
243
270
  formatOutput({
244
271
  columns: ['objective_id', 'title', 'state', 'turn', 'backend', 'created_at'],
245
272
  headers: ['ID', 'Title', 'State', 'Turn', 'Backend', 'Created'],
246
- rows: (data.loops || []).map((l) => ({ ...l, turn: l.max_turns ? `${l.turn}/${l.max_turns}` : String(l.turn) })),
247
- }, { format: program.opts().output, emptyMessage: 'No loops in this org yet.' });
273
+ rows: loops.map((l) => ({ ...l, turn: turnLabel(l) })),
274
+ }, { format: program.opts().output, raw: loops, emptyMessage: 'No loops in this org yet.' });
248
275
  });
249
276
 
250
277
  // SECURITY containment (2026-10-26): which orgs may run loops in-server (core-local) while a security review is open.
@@ -105,6 +105,7 @@ export function registerLucidCommands(program, ctx) {
105
105
  if (o.markdown) { const { renderSitrepMarkdown } = await import('../lucid/markdown.js'); console.log(renderSitrepMarkdown(data)); return; }
106
106
  const { renderSitrep } = await import('../lucid/sitrep.js');
107
107
  const tty = Boolean(process.stdout.isTTY);
108
+ if (tty) process.stderr.write('Tip: for a shorter form use --spoken (sentences), --agent (compact) or --markdown (one chat message).\n');
108
109
  process.stdout.write(renderSitrep(data, {
109
110
  width: process.stdout.columns || 100,
110
111
  color: tty && !process.env.NO_COLOR && process.env.QUOX_COLOR !== '0',
@@ -48,6 +48,8 @@ function parseCsvFile(path) {
48
48
  const VALID_ZONES = ['focus', 'fyi', 'auto', 'screened'];
49
49
  const VALID_KINDS = ['file', 'block', 'draft'];
50
50
  const VALID_DECISIONS = ['approve', 'deny'];
51
+ // quox#1518: past tense comes from a map, never verb + 'd' ("denyd").
52
+ const DECISION_PAST_TENSE = { approve: 'approved', deny: 'denied', reject: 'rejected' };
51
53
 
52
54
  // Small CLI-local copy of src/config/emailProviders.js's EMAIL_PROVIDERS.
53
55
  // The cli/ package can't import from src/, so the host/port defaults for
@@ -67,6 +69,21 @@ function printJson(data) {
67
69
  console.log(JSON.stringify(data, null, 2));
68
70
  }
69
71
 
72
+ // quox#1504: a failure is printed as a failure (stderr, non-zero exit), never
73
+ // dressed up as an empty success.
74
+ function printFailure(message) {
75
+ console.error(message);
76
+ process.exitCode = 1;
77
+ }
78
+
79
+ function pinHint(accountId, error) {
80
+ return /^TLS: /.test(error || '')
81
+ ? `If this server's certificate is one you trust, an org admin can pin it: quox mail account pin ${accountId} --sha256 <fingerprint>`
82
+ : null;
83
+ }
84
+
85
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
86
+
70
87
  export function registerMailCommands(program, ctx) {
71
88
  const { client, auth } = ctx;
72
89
 
@@ -85,9 +102,9 @@ export function registerMailCommands(program, ctx) {
85
102
  const data = await client.get(ep.path, { service: ep.service });
86
103
  if (isJsonOutput(program)) return printJson(data);
87
104
  formatOutput({
88
- columns: ['id', 'emailAddress', 'displayName', 'pollEnabled', 'createdAt'],
89
- headers: ['ID', 'Email', 'Name', 'Polling', 'Connected'],
90
- rows: data.accounts || [],
105
+ columns: ['id', 'emailAddress', 'displayName', 'pollEnabled', 'tlsPin', 'lastError'],
106
+ headers: ['ID', 'Email', 'Name', 'Polling', 'TLS pin', 'Last error'],
107
+ rows: (data.accounts || []).map((a) => ({ ...a, tlsPin: a.tlsPinSha256 ? 'sha256 pinned' : '', lastError: a.lastError || '' })),
91
108
  }, { format: program.opts().output });
92
109
  });
93
110
 
@@ -288,31 +305,123 @@ export function registerMailCommands(program, ctx) {
288
305
 
289
306
  mail
290
307
  .command('sync')
291
- .description('Sync a mail account now (fetch new messages, run triage)')
308
+ .description('Sync a mail account now (fetch new messages, run triage). Waits for the result and shows the real cause if the mailbox cannot be reached (TLS, login, network).')
292
309
  .argument('<accountId>', 'account ID')
293
- .action(async (accountId) => {
310
+ .option('--wait <seconds>', 'how long to wait for a background sync to finish (0 = do not wait)', '120')
311
+ .action(async (accountId, options) => {
294
312
  auth.requireAuth();
295
313
  const ep = resolveEndpoint('inboxq.sync');
296
- // A first full-mailbox pull runs the whole IMAP fetch + classify in one
297
- // synchronous request and routinely exceeds the default 30s client timeout
298
- // (the sync still completes server-side, but the CLI showed a false
299
- // "timed out"). Give sync a generous 4-minute ceiling (nginx allows 300s).
314
+ // A first full-mailbox pull can be slow; the collector usually runs it
315
+ // detached and returns {started:true} at once. Keep a generous ceiling
316
+ // for the inline shape (nginx allows 300s).
300
317
  const data = await client.post(ep.path, { service: ep.service, body: { accountId }, timeout: 240000 });
318
+
319
+ // Detached run: poll sync-status until it finishes, then report the
320
+ // OUTCOME. quox#1504: this used to print "No folders reported" while the
321
+ // background run failed TLS and nobody saw why.
322
+ if (data && (data.started || data.status === 'already_syncing')) {
323
+ const sep = resolveEndpoint('inboxq.syncStatus');
324
+ const deadline = Date.now() + Math.max(0, Number(options.wait) || 0) * 1000;
325
+ let st = await client.get(sep.path, { service: sep.service, query: { accountId } });
326
+ while (st && st.syncing && Date.now() < deadline) {
327
+ await sleep(2000);
328
+ st = await client.get(sep.path, { service: sep.service, query: { accountId } });
329
+ }
330
+ if (isJsonOutput(program)) return printJson({ ...data, status: st });
331
+ if (st && st.lastError) {
332
+ printFailure(`Sync of ${accountId} failed: ${st.lastError}`);
333
+ for (const f of st.folderErrors || []) printFailure(` ${f.folder}: ${f.error} (failures in a row: ${f.consecutiveFailures}${f.retired ? ', retired' : ''})`);
334
+ const hint = pinHint(accountId, st.lastError);
335
+ if (hint) console.error(hint);
336
+ return;
337
+ }
338
+ if (st && st.syncing) {
339
+ printSuccess(`Sync of ${accountId} is still running (${st.stored ?? 0} messages stored so far). Check: quox mail sync-status ${accountId}`);
340
+ return;
341
+ }
342
+ printSuccess(`Synced ${accountId}. ${st?.stored ?? 0} messages stored.`);
343
+ return;
344
+ }
345
+
301
346
  if (isJsonOutput(program)) return printJson(data);
302
347
  const summary = data.summary || {};
303
348
  const folders = summary.folders || [];
349
+ const failed = folders.filter((f) => f.failed);
304
350
  if (folders.length === 0) {
305
- printSuccess(`Synced ${accountId}. No folders reported.`);
351
+ if (data.lastError) { printFailure(`Sync of ${accountId} failed: ${data.lastError}`); return; }
352
+ printSuccess(`Synced ${accountId}. No folders were eligible this pass (all in backoff or retired); see quox mail sync-status ${accountId}.`);
306
353
  return;
307
354
  }
308
355
  formatOutput({
309
- columns: ['folder', 'new', 'failed'],
310
- headers: ['Folder', 'New', 'Failed'],
311
- rows: folders,
356
+ columns: ['folder', 'new', 'error'],
357
+ headers: ['Folder', 'New', 'Error'],
358
+ rows: folders.map((f) => ({ ...f, new: f.new ?? '', error: f.error || f.skipped || '' })),
312
359
  }, { format: program.opts().output });
360
+ if (failed.length) {
361
+ printFailure(`Sync of ${accountId}: ${failed.length} of ${folders.length} folder(s) failed: ${failed[0].error}`);
362
+ const hint = pinHint(accountId, failed[0].error);
363
+ if (hint) console.error(hint);
364
+ return;
365
+ }
313
366
  printSuccess(`Synced ${accountId}. LLM calls: ${summary.llmCalls ?? 0}`);
314
367
  });
315
368
 
369
+ mail
370
+ .command('sync-status')
371
+ .description('Show whether an account is syncing, how many messages are stored, and the last connect/TLS/login error if any')
372
+ .argument('<accountId>', 'account ID')
373
+ .action(async (accountId) => {
374
+ auth.requireAuth();
375
+ const sep = resolveEndpoint('inboxq.syncStatus');
376
+ const st = await client.get(sep.path, { service: sep.service, query: { accountId } });
377
+ if (isJsonOutput(program)) return printJson(st);
378
+ formatDetail({
379
+ syncing: Boolean(st.syncing),
380
+ stored: st.stored ?? 0,
381
+ folders: st.folders ? `${st.folders.backfilled}/${st.folders.total} fully imported` : '',
382
+ tlsPin: st.tlsPinSha256 || 'none (default certificate verification)',
383
+ lastError: st.lastError || 'none',
384
+ lastErrorAt: st.lastErrorAt || '',
385
+ }, { format: program.opts().output });
386
+ if ((st.folderErrors || []).length) {
387
+ formatOutput({
388
+ columns: ['folder', 'error', 'consecutiveFailures', 'retired', 'nextAttemptAt'],
389
+ headers: ['Folder', 'Error', 'Failures', 'Retired', 'Next attempt'],
390
+ rows: st.folderErrors,
391
+ }, { format: program.opts().output });
392
+ }
393
+ const hint = pinHint(accountId, st.lastError);
394
+ if (hint) console.error(hint);
395
+ });
396
+
397
+ // ── account (per-account settings) ───────────────────────────────────
398
+
399
+ const account = mail
400
+ .command('account')
401
+ .description('Per-account settings for a connected mailbox');
402
+
403
+ account
404
+ .command('pin')
405
+ .description('Pin the TLS certificate (SHA-256 fingerprint) an account trusts, for a mail server with a self-signed or private-CA certificate. Only that exact certificate is accepted; anything else still fails. Org admin only. Get the fingerprint with: openssl s_client -connect <host>:<port> </dev/null | openssl x509 -noout -fingerprint -sha256')
406
+ .argument('<accountId>', 'account ID')
407
+ .option('--sha256 <fingerprint>', 'SHA-256 certificate fingerprint (64 hex characters, colons optional; the openssl output line is accepted)')
408
+ .option('--clear', 'remove the pin (back to default certificate verification)')
409
+ .action(async (accountId, options) => {
410
+ auth.requireAuth();
411
+ if (Boolean(options.sha256) === Boolean(options.clear)) {
412
+ printFailure('Give exactly one of --sha256 <fingerprint> or --clear.');
413
+ return;
414
+ }
415
+ const ep = resolveEndpoint('inboxq.accounts.tlsPin', { accountId });
416
+ const body = options.clear ? { clear: true } : { sha256: options.sha256 };
417
+ const data = await client.put(ep.path, { service: ep.service, body });
418
+ if (isJsonOutput(program)) return printJson(data);
419
+ const pin = data?.account?.tlsPinSha256;
420
+ printSuccess(pin
421
+ ? `Pinned ${accountId} to TLS certificate sha256 ${pin}. Run quox mail sync ${accountId} to retry now.`
422
+ : `Removed the TLS pin from ${accountId}; it uses default certificate verification again.`);
423
+ });
424
+
316
425
  // ── messages ─────────────────────────────────────────────────────────
317
426
 
318
427
  mail
@@ -408,20 +517,52 @@ export function registerMailCommands(program, ctx) {
408
517
 
409
518
  watch
410
519
  .command('add')
411
- .description('Add a watch')
520
+ .description('Add a watch (awaited-mail alert). Give --match, or at least one of --from/--subject/--text/--zone.')
412
521
  .requiredOption('--label <label>', 'watch label')
413
- .requiredOption('--match <json>', 'match rule as a JSON object')
414
- .option('--account <id>', 'restrict the watch to one account')
522
+ .option('--match <json>', 'match rule as a JSON object (advanced; alternative to the --from/--subject/--text/--zone flags)')
523
+ .option('--from <addr>', 'match: sender address/name contains this')
524
+ .option('--subject <text>', 'match: subject contains this')
525
+ .option('--text <keyword>', 'match: message text contains this')
526
+ .option('--zone <zone>', 'match: only mail triaged into this zone (focus|fyi|auto|screened)')
527
+ .option('--notify <csv>', 'alert channels, comma list of hitl,lucid,telegram,discord,call,sms', 'hitl')
528
+ .option('--account <id>', 'restrict the watch to one account (omit to watch all your mailboxes)')
415
529
  .action(async (options) => {
416
530
  auth.requireAuth();
417
- const match = parseJSON(options.match, '--match');
418
- const body = { label: options.label, match };
531
+ let match;
532
+ if (options.match) {
533
+ match = parseJSON(options.match, '--match');
534
+ } else {
535
+ match = {};
536
+ if (options.from) match.from = options.from;
537
+ if (options.subject) match.subject = options.subject;
538
+ if (options.text) match.text = options.text;
539
+ if (options.zone) match.zone = options.zone;
540
+ }
541
+ if (!match || typeof match !== 'object' || Object.keys(match).length === 0) {
542
+ throw new Error('Give --match, or at least one of --from, --subject, --text, --zone, or the watch would match nothing in particular.');
543
+ }
544
+ const notify = String(options.notify).split(',').map((s) => s.trim()).filter(Boolean);
545
+ const body = { label: options.label, match, notify };
419
546
  if (options.account) body.accountId = options.account;
420
547
  const ep = resolveEndpoint('inboxq.watches.create');
421
548
  const data = await client.post(ep.path, { service: ep.service, body });
422
549
  if (isJsonOutput(program)) return printJson(data);
423
550
  const created = data.watch || data;
424
- printSuccess(`Added watch "${options.label}" (${created.id})`);
551
+ printSuccess(`Added watch "${options.label}" (${created.id}); alerts via ${notify.join(', ')}.`);
552
+ const ext = notify.filter((c) => ['telegram', 'discord', 'call', 'sms'].includes(c));
553
+ if (ext.length) console.log(`Note: ${ext.join(', ')} need a destination configured, or they report unconfigured; hitl and lucid work out of the box.`);
554
+ });
555
+
556
+ watch
557
+ .command('rm')
558
+ .description('Delete a watch')
559
+ .argument('<watchId>', 'watch ID')
560
+ .action(async (watchId) => {
561
+ auth.requireAuth();
562
+ const ep = resolveEndpoint('inboxq.watches.delete', { watchId });
563
+ const data = await client.delete(ep.path, { service: ep.service });
564
+ if (isJsonOutput(program)) return printJson(data);
565
+ printSuccess(`Watch ${watchId} deleted.`);
425
566
  });
426
567
 
427
568
  watch
@@ -493,7 +634,7 @@ export function registerMailCommands(program, ctx) {
493
634
  const ep = resolveEndpoint('inboxq.proposals.resolve', { proposalId });
494
635
  const data = await client.post(ep.path, { service: ep.service, body: { decision: options.decision } });
495
636
  if (isJsonOutput(program)) return printJson(data);
496
- printSuccess(`Proposal ${proposalId} ${options.decision}d.`);
637
+ printSuccess(`Proposal ${proposalId} ${DECISION_PAST_TENSE[options.decision] || options.decision}.`);
497
638
  });
498
639
 
499
640
  // ── moves (AQ-9E U2: reversible moves) ─────────────────────────────────