quoxcli 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/lib/api/endpoints/admin.js +21 -0
  3. package/lib/api/endpoints/calendarq.js +8 -0
  4. package/lib/api/endpoints/governance.js +7 -0
  5. package/lib/api/endpoints/inboxq.js +39 -0
  6. package/lib/api/endpoints/index.js +4 -0
  7. package/lib/api/endpoints/lens.js +34 -0
  8. package/lib/api/endpoints/lucid.js +18 -0
  9. package/lib/api/endpoints/mcp.js +35 -0
  10. package/lib/api/endpoints/mon.js +6 -7
  11. package/lib/api/endpoints/notesq.js +2 -0
  12. package/lib/api/endpoints/quoxplan.js +51 -0
  13. package/lib/api/endpoints/v1.js +57 -0
  14. package/lib/commands/actions.js +51 -13
  15. package/lib/commands/admin.js +86 -0
  16. package/lib/commands/approvals.js +5 -1
  17. package/lib/commands/calendar.js +27 -0
  18. package/lib/commands/chat.js +1 -1
  19. package/lib/commands/contacts.js +2 -0
  20. package/lib/commands/fleet.js +164 -0
  21. package/lib/commands/fleetRecipe.js +174 -0
  22. package/lib/commands/lens.js +268 -0
  23. package/lib/commands/lucid.js +6 -0
  24. package/lib/commands/lucidGoals.js +423 -0
  25. package/lib/commands/mail.js +287 -7
  26. package/lib/commands/mcp.js +190 -0
  27. package/lib/commands/mon.js +14 -0
  28. package/lib/commands/monitoring.js +73 -1
  29. package/lib/commands/notesSeal.js +380 -22
  30. package/lib/commands/offload.js +51 -10
  31. package/lib/commands/plan.js +507 -54
  32. package/lib/commands/research.js +78 -0
  33. package/lib/commands/settings.js +7 -1
  34. package/lib/commands/volt.js +6 -1
  35. package/lib/commands/wazuh.js +74 -9
  36. package/lib/commands/wazuhIntake.js +55 -6
  37. package/lib/core/commandRegistrars.js +2 -0
  38. package/lib/core/config.js +14 -1
  39. package/lib/core/offload.js +83 -0
  40. package/lib/core/output.js +29 -4
  41. package/lib/core/selfUpdate.js +2 -0
  42. package/lib/core/version.js +15 -2
  43. package/lib/dragon/select.js +2 -2
  44. package/lib/quoxon/api.js +4 -0
  45. package/lib/quoxon/app.js +258 -49
  46. package/lib/quoxon/approvalsView.js +3 -1
  47. package/lib/quoxon/ask.js +4 -2
  48. package/lib/quoxon/bang.js +7 -1
  49. package/lib/quoxon/commandShort.js +1 -1
  50. package/lib/quoxon/commands.js +5 -5
  51. package/lib/quoxon/delegationDragon.js +49 -0
  52. package/lib/quoxon/dnd.js +47 -0
  53. package/lib/quoxon/dragonCompanion.js +24 -12
  54. package/lib/quoxon/events.js +12 -1
  55. package/lib/quoxon/extensions.js +2 -0
  56. package/lib/quoxon/foreignText.js +49 -0
  57. package/lib/quoxon/inboxView.js +5 -5
  58. package/lib/quoxon/launcher.js +2 -1
  59. package/lib/quoxon/loopCard.js +2 -1
  60. package/lib/quoxon/main.js +8 -2
  61. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  62. package/lib/quoxon/onboarding/view.js +1 -1
  63. package/lib/quoxon/render.js +5 -2
  64. package/lib/quoxon/resend.js +14 -0
  65. package/lib/quoxon/rows.js +3 -1
  66. package/lib/quoxon/secretShape.js +2 -0
  67. package/lib/quoxon/slash/broadcast.js +30 -6
  68. package/lib/quoxon/slash/broadcastRun.js +12 -6
  69. package/lib/quoxon/slash/cliLeaves.json +54 -0
  70. package/lib/quoxon/slash/dashboard.js +2 -0
  71. package/lib/quoxon/slash/listView.js +2 -1
  72. package/lib/quoxon/slash/palette.js +41 -0
  73. package/lib/quoxon/slash/paletteController.js +6 -2
  74. package/lib/quoxon/slash/restart.js +2 -0
  75. package/lib/quoxon/slotChat.js +7 -3
  76. package/lib/quoxon/slotRun.js +2 -0
  77. package/lib/quoxon/webPty.js +15 -0
  78. package/lib/quoxon/whatsnew.jsonl +7 -0
  79. package/lib/render/planBrief.js +614 -0
  80. package/lib/toolOutputShape.js +6 -3
  81. package/package.json +1 -1
  82. package/vendor/dragonlab/SOURCE +3 -3
  83. package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
  84. package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
  85. package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
  86. package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
  87. package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
  88. package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
  89. package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
  90. package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
  91. package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
  92. package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
  93. package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
  94. package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
  95. package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
  96. package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
  97. package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
  98. package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
  99. package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
  100. package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
  101. package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
  102. package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
  103. package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
  104. package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
  105. package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
  106. package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
  107. package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
  108. package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
  109. package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
  110. package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
  111. package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
  112. package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
  113. package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
  114. package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
  115. package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
  116. package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
  117. package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
  118. package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
  119. package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
  120. package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
  121. package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
  122. package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
  123. package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
  124. package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
  125. package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
  126. package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
  127. package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
  128. package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
  129. package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
  130. package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
@@ -5,9 +5,20 @@
5
5
  * Stored as the org setting RESEARCH_TRUSTED_DOMAINS through the SAME governed settings path as
6
6
  * `quox settings set` (PATCH /api/v1/platform/settings/flags/:key, human org admin, witnessed there).
7
7
  * Unset or empty means the built-in default list, which add/remove seed from so a first edit never drops it.
8
+ *
9
+ * quox research ask "<question>" [--save [--collection <name>]]
10
+ *
11
+ * Asks the research agent (registry id `perplexity`, the agent holding the governed web_search/web_read
12
+ * tools) through the SAME governed chat-turn route as `quox chat --agent perplexity --no-stream`
13
+ * (POST /chat/claude, Prefer: respond-async, polled at /chat/turns/:id). --save writes the answer and its
14
+ * sources as a NotesQ note through the same create route as `quox notes new`.
15
+ * Sources are the URLs the agent cited in its answer: the chat route returns tool names, not tool results,
16
+ * so a URL the agent read but did not cite is not shown, and none is ever invented.
8
17
  */
9
18
 
10
19
  import { resolveEndpoint } from '../api/endpoints.js';
20
+ import { QuoxError } from '../core/errors.js';
21
+ import { sendOneShot } from './chat.js';
11
22
 
12
23
  const KEY = 'RESEARCH_TRUSTED_DOMAINS';
13
24
 
@@ -41,6 +52,25 @@ async function write(client, list) {
41
52
  return client.patch(ep.path, { service: ep.service, body: { value: list.join(',') } });
42
53
  }
43
54
 
55
+ export const RESEARCH_AGENT = 'perplexity';
56
+
57
+ /** URLs cited in an answer (bare or markdown links), trailing punctuation trimmed, first-seen order, deduped. */
58
+ export function extractSources(text) {
59
+ const seen = new Set();
60
+ for (const m of String(text || '').matchAll(/https?:\/\/[^\s<>()\[\]"'`]+(?:\([^\s()]*\)[^\s<>()\[\]"'`]*)*/g)) {
61
+ const url = m[0].replace(/[.,;:!?*_]+$/, '');
62
+ if (url.length > 'https://'.length) seen.add(url);
63
+ }
64
+ return [...seen];
65
+ }
66
+
67
+ function noteMarkdown({ question, answer, sources, toolsUsed, agent }) {
68
+ const title = question.replace(/\s+/g, ' ').trim().slice(0, 120);
69
+ const src = sources.length ? sources.map((u) => `- ${u}`).join('\n') : '_No sources were returned for this answer: the agent cited no URL._';
70
+ return `# ${title}\n\n**Question:** ${question}\n\n${answer.trim()}\n\n## Sources\n\n_URLs cited in the answer. The research route does not report which pages were actually read; check the answer text._\n\n${src}\n\n---\n` +
71
+ `Asked with \`quox research ask\` (agent ${agent}) on ${new Date().toISOString()}. Tools called: ${toolsUsed.length ? toolsUsed.join(', ') : 'none'}.\n`;
72
+ }
73
+
44
74
  export function registerResearchCommands(program, ctx) {
45
75
  const { client } = ctx;
46
76
  const r = program.command('research').description('Governed web research settings');
@@ -76,4 +106,52 @@ export function registerResearchCommands(program, ctx) {
76
106
  await write(client, next);
77
107
  process.stdout.write(`removed ${dom} (${next.length} entries, org setting)\n`);
78
108
  });
109
+
110
+ r.command('ask <question...>')
111
+ .description('Ask the research agent a question; prints the answer and the sources it cited (--save keeps it as a NotesQ note)')
112
+ .option('--save', 'save the answer and sources as a NotesQ note')
113
+ .option('--collection <name>', 'NotesQ collection for --save', 'research')
114
+ .action(async (words, options) => {
115
+ if (ctx.auth && ctx.auth.requireAuth) ctx.auth.requireAuth();
116
+ const question = words.join(' ').trim();
117
+ if (!question) throw new QuoxError('Ask a question: quox research ask "<question>"', 2);
118
+ const g = program.opts();
119
+ const json = g.output === 'json' || g.json === true;
120
+
121
+ const { data, sessionId } = await sendOneShot(client, { message: question, agent: RESEARCH_AGENT });
122
+ const raw = data && (data.result ?? data.response ?? data.text);
123
+ const answer = typeof raw === 'string' ? raw : '';
124
+ if (!answer.trim()) throw new QuoxError(`The research agent returned no answer${data && data.error ? ` (${data.error})` : ''}.`, 1);
125
+ const toolsUsed = Array.isArray(data.tools_used) ? [...new Set(data.tools_used.map((t) => (typeof t === 'string' ? t : t && (t.name || t.tool))).filter(Boolean))] : [];
126
+ const agent = data.agent_used || RESEARCH_AGENT;
127
+ const sources = extractSources(answer);
128
+
129
+ let note = null;
130
+ if (options.save) {
131
+ const ep = resolveEndpoint('notesq.notes.create');
132
+ const saved = await client.post(ep.path, {
133
+ service: ep.service,
134
+ body: { scope: 'org', markdown: noteMarkdown({ question, answer, sources, toolsUsed, agent }), properties: { collection: options.collection, tags: ['research'] } },
135
+ });
136
+ note = saved && saved.note;
137
+ if (!note || !note.id) throw new QuoxError('NotesQ returned no note id: the answer was not saved.', 1);
138
+ }
139
+
140
+ if (json) {
141
+ console.log(JSON.stringify({ question, agent, answer, sources, tools_used: toolsUsed, conversation_id: sessionId || null, note }, null, 2));
142
+ return;
143
+ }
144
+ console.log(answer.trim());
145
+ console.log('');
146
+ if (sources.length) {
147
+ console.log(`Sources (${sources.length} URLs cited in the answer; the route does not report which pages were actually read):`);
148
+ sources.forEach((u, i) => console.log(` ${i + 1}. ${u}`));
149
+ } else {
150
+ console.log('Sources: none. The agent cited no URL in its answer.');
151
+ }
152
+ const ranSearch = toolsUsed.includes('web_search');
153
+ console.log(`Tools called: ${toolsUsed.length ? toolsUsed.join(', ') : 'none'}${ranSearch ? ' (called, not necessarily successful: read the answer)' : ' (web_search did not run on this turn)'}`);
154
+ if (agent !== RESEARCH_AGENT) console.log(`Answered by: ${agent}`);
155
+ if (note) console.log(`Saved note ${note.id} in collection ${options.collection}.`);
156
+ });
79
157
  }
@@ -12,6 +12,7 @@
12
12
  import { formatOutput, formatDetail } from '../core/output.js';
13
13
  import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
14
14
  import { resolveEndpoint } from '../api/endpoints.js';
15
+ import { withStepUp } from '../core/stepUpPrompt.js';
15
16
 
16
17
  async function fetchFlags(client) {
17
18
  const ep = resolveEndpoint('v1.settings.list');
@@ -59,7 +60,12 @@ export function registerSettingsCommands(program, ctx) {
59
60
  .action(async (key, value) => {
60
61
  auth.requireAuth();
61
62
  const ep = resolveEndpoint('v1.settings.set', { key });
62
- const data = await client.patch(ep.path, { service: ep.service, body: { value } });
63
+ // VULCAN: weakening an approval control (STEP_UP_ENFORCE off) needs a step-up PIN; the server
64
+ // decides, and withStepUp asks for the PIN on a hidden prompt and retries once (scope override).
65
+ const data = await withStepUp(
66
+ (headers) => client.patch(ep.path, { service: ep.service, headers, body: { value } }),
67
+ { client, orgId: auth.getOrgId?.(program.opts().org) || null, scope: 'override', io: ctx.stepUpIO, what: `Changing ${key}` },
68
+ );
63
69
  if (program.opts().output === 'json') {
64
70
  console.log(JSON.stringify(data, null, 2));
65
71
  return;
@@ -511,7 +511,12 @@ export function registerVoltCommands(program, ctx = {}) {
511
511
  console.log(` Agents: ${(m.agents_involved || []).join(', ') || 'none'}`);
512
512
  console.log(` Policies: ${(m.policy_ids_evaluated || []).length} evaluated`);
513
513
  console.log(` HITL: ${m.hitl_decisions || 0} decisions`);
514
- console.log(` Signed: ${m.signed ? 'yes' : 'no'}`);
514
+ // Signatures live beside the manifest (fetched bundles: `signatures[]`;
515
+ // stored rows: `signature`); collector manifests never set m.signed.
516
+ const sigCount = Array.isArray(parsed.signatures) && parsed.signatures.length > 0
517
+ ? parsed.signatures.length
518
+ : (parsed.signature ? 1 : (m.signed ? 1 : 0));
519
+ console.log(` Signed: ${sigCount > 0 ? `yes (${sigCount} signature${sigCount === 1 ? '' : 's'}, not checked here; run quox volt verify)` : 'no'}`);
515
520
  console.log(` Redacted: ${m.redactions_present ? 'yes' : 'no'}`);
516
521
  console.log(` Created: ${m.created_ts || 'N/A'}`);
517
522
  console.log(` Started: ${m.started_at || 'N/A'}`);
@@ -28,6 +28,7 @@
28
28
  * the verb, endpoint and status. It is never rendered as "0 agents". `status` exits 3 when the manager is unhealthy.
29
29
  */
30
30
 
31
+ import fs from 'fs';
31
32
  import { resolveEndpoint } from '../api/endpoints.js';
32
33
  import { line as glyphLine } from '../core/quoxglyph.js';
33
34
  import { resolveOrgId } from './helpers.js';
@@ -209,8 +210,10 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
209
210
  const a = await allAgents('status');
210
211
  const counts = countStates(a.items);
211
212
  if (a.total > a.items.length) counts.total = a.total;
213
+ // WAZ-11: agent 000 is the manager's own agent; coverage leaves it out, so status says when its count includes it.
214
+ const includesManager = a.items.some((x) => String(x && x.id) === '000');
212
215
  if (jsonOut()) {
213
- console.log(JSON.stringify({ healthy, daemons, stopped_core: brokenCore, agents: counts }, null, 2));
216
+ console.log(JSON.stringify({ healthy, daemons, stopped_core: brokenCore, agents: { ...counts, includes_manager: includesManager, excluding_manager: includesManager ? counts.total - 1 : counts.total } }, null, 2));
214
217
  } else {
215
218
  head('status', `manager ${healthy ? 'healthy' : 'unhealthy'} · ${plural(counts.total, 'agent')}`, healthy ? 'ok' : 'fail');
216
219
  console.log(`Manager ${healthy ? 'healthy' : 'unhealthy'}: ${running.length} running${stopped.length ? `, ${stopped.length} stopped` : ''}`);
@@ -218,6 +221,7 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
218
221
  console.log(pad(stopped.map((n) => [` ${n}`, OPTIONAL_DAEMONS.has(n) ? `${daemons[n]} (optional)` : String(daemons[n])])).join('\n'));
219
222
  }
220
223
  console.log(`Agents: ${countText(counts)}${a.total > a.items.length ? ` (counted the first ${a.items.length} of ${a.total})` : ''}`);
224
+ if (includesManager) console.log(`${plural(counts.total, 'agent')} (incl. the manager, agent 000); ${plural(counts.total - 1, 'agent')} excluding it, as quox wazuh coverage counts.`);
221
225
  if (!healthy) console.log(`Not running: ${brokenCore.join(', ')}. Check the manager host's service logs.`);
222
226
  }
223
227
  if (!healthy) process.exitCode = 3;
@@ -376,6 +380,15 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
376
380
  head('coverage', s.covered === null || s.covered === undefined ? 'fleet unknown' : `${s.covered} of ${s.fleet_hosts} covered`,
377
381
  degraded || s.missing_agent > 0 || s.disconnected > 0 ? 'warn' : 'ok');
378
382
  console.log([n(s.fleet_hosts, 'fleet hosts'), `${s.wazuh_agents} Wazuh agents`, n(s.covered, 'covered'), n(s.missing_agent, 'missing agent'), `${s.disconnected} disconnected`, n(s.no_vuln_inventory, 'without vulnerability inventory')].join(', '));
383
+ // WAZ-11 (audit batch 5, N4): an unknown fleet-side number always says why, on stdout beside the summary (the JSON
384
+ // carries the reason as fleet_note / fleet_error; the human line used to print "unknown" with no reason).
385
+ if (s.fleet_hosts === null || s.fleet_hosts === undefined || s.covered === null || s.covered === undefined) {
386
+ const why = data.fleet_note
387
+ || (data.fleet_error === 'fleet_not_permitted' ? 'this organisation is not permitted to compare against the server fleet on this Quox instance (fleet_not_permitted)' : '')
388
+ || (data.fleet_error ? `the fleet list could not be read (${data.fleet_error})` : '')
389
+ || 'the collector gave no reason';
390
+ console.log(`Fleet hosts and covered are unknown: ${why}`);
391
+ }
379
392
  if (data.fleet_error) console.error(`Fleet list could not be read (${data.fleet_error}); fleet-side numbers are unknown, not zero.`);
380
393
  if (data.vuln_error) console.error(`Vulnerability inventory could not be read (${data.vuln_error}); that section is unknown, not empty.`);
381
394
  const section = (title, list, cols, row) => {
@@ -432,11 +445,13 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
432
445
  const { ep, data } = await getRoute('vulns', 'mon.wazuh.vulnerabilities.list', query);
433
446
  if (!data || data.success === false || !Array.isArray(data.data)) throw new WazFail(`quox wazuh vulns: ${ep.method} ${ep.path} returned an error body: ${(data && (data.message || data.error)) || 'no vulnerabilities array'}`);
434
447
  const list = data.data;
435
- if (jsonOut()) { console.log(JSON.stringify({ vulnerabilities: list, total: data.total ?? list.length }, null, 2)); return; }
448
+ if (jsonOut()) { console.log(JSON.stringify({ vulnerabilities: list, total: data.total ?? list.length, total_is_lower_bound: data.total_is_lower_bound === true }, null, 2)); return; }
436
449
  const bySev = {};
437
450
  for (const v of list) { const s = (v.vulnerability && v.vulnerability.severity) || 'Unknown'; bySev[s] = (bySev[s] || 0) + 1; }
438
451
  const sevText = ['Critical', 'High', 'Medium', 'Low', 'Unknown'].filter((s) => bySev[s]).map((s) => `${bySev[s]} ${s}`).join(' · ');
439
- head('vulns', `${list.length === 0 ? 'none' : sevText}${(data.total ?? 0) > list.length ? ` (showing ${list.length} of ${data.total})` : ''}`, bySev.Critical ? 'warn' : 'ok');
452
+ // WAZ-11: an indexer-capped total is a lower bound ("10,000+"), never shown as an exact count.
453
+ const totalText = data.total_is_lower_bound === true ? `${Number(data.total).toLocaleString('en-GB')}+` : data.total;
454
+ head('vulns', `${list.length === 0 ? 'none' : sevText}${(data.total ?? 0) > list.length ? ` (showing ${list.length} of ${totalText})` : ''}`, bySev.Critical ? 'warn' : 'ok');
440
455
  if (!list.length) { console.log('No vulnerabilities.'); return; }
441
456
  const rows = [['SEVERITY', 'SCORE', 'CVE', 'AGENT', 'PACKAGE']];
442
457
  for (const v of list) rows.push([(v.vulnerability && v.vulnerability.severity) || '-', (v.vulnerability && v.vulnerability.score && v.vulnerability.score.base) ?? '-', (v.vulnerability && v.vulnerability.id) || '-', (v.agent && v.agent.name) || '-', v.package ? `${v.package.name || ''} ${v.package.version || ''}`.trim() : '-']);
@@ -728,15 +743,44 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
728
743
  // ---------------------------------------------------------------------------------------------------------------
729
744
 
730
745
  async function hiddenPrompt(message) {
731
- if (!process.stdin.isTTY) throw new WazFail(`quox wazuh connect: ${message} needs a terminal (the password is typed hidden, never passed as a flag or piped)`);
746
+ if (!process.stdin.isTTY) throw new WazFail(`quox wazuh connect: ${message} needs a terminal (the password is typed hidden, never passed as a flag); without a terminal pipe it in with --password-stdin / --indexer-password-stdin`);
732
747
  const { default: inquirer } = await import('inquirer');
733
748
  const ans = await inquirer.prompt([{ type: 'password', name: 'v', message, mask: '*', validate: (v) => (v && v.length > 0) || 'required' }]);
734
749
  return ans.v;
735
750
  }
736
751
 
752
+ /** All of stdin as text (quox#1588 --password-stdin). Never echoed. */
753
+ async function readAllStdin() {
754
+ const chunks = [];
755
+ for await (const chunk of process.stdin) chunks.push(chunk);
756
+ return Buffer.concat(chunks).toString('utf8');
757
+ }
758
+
759
+ const CA_MAX_BYTES = 64 * 1024;
760
+ /**
761
+ * quox#1588: read the indexer CA for --indexer-ca. A public certificate, but the file is still never printed: errors name
762
+ * the path only. Must hold at least one PEM CERTIFICATE block and NO private key (a bundle with a key is refused, so a key
763
+ * never lands in the vault entry by mistake).
764
+ */
765
+ function readIndexerCa(file) {
766
+ let text;
767
+ try {
768
+ const st = fs.statSync(file);
769
+ if (!st.isFile()) throw new Error('not a file');
770
+ if (st.size > CA_MAX_BYTES) throw new Error(`larger than ${CA_MAX_BYTES} bytes`);
771
+ text = fs.readFileSync(file, 'utf8');
772
+ } catch (err) {
773
+ throw new WazFail(`quox wazuh connect: --indexer-ca ${file} could not be read (${err && err.code ? err.code : (err && err.message) || 'error'})`);
774
+ }
775
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text)) throw new WazFail(`quox wazuh connect: --indexer-ca ${file} contains a private key; pass the CA certificate only (nothing was saved)`);
776
+ if (!/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/.test(text)) throw new WazFail(`quox wazuh connect: --indexer-ca ${file} is not a PEM certificate (expected -----BEGIN CERTIFICATE-----)`);
777
+ return text;
778
+ }
779
+
737
780
  export function attachWazuhConnect(wazuh, program, ctx) {
738
781
  const { client, auth } = ctx;
739
782
  const promptSecret = ctx.promptSecret || hiddenPrompt;
783
+ const readStdin = ctx.readStdin || readAllStdin;
740
784
  async function importInstanceEnv() {
741
785
  const ep = resolveEndpoint('mon.wazuh.credential.import-instance-env');
742
786
  let res;
@@ -765,19 +809,22 @@ export function attachWazuhConnect(wazuh, program, ctx) {
765
809
  }
766
810
 
767
811
  wazuh.command('connect')
768
- .description('Connect this organisation to its Wazuh: stores the API (and optional indexer) credential in the vault, then runs status. Admin only. Passwords are prompted, never flags.')
812
+ .description('Connect this organisation to its Wazuh: stores the API (and optional indexer) credential in the vault, then runs status. Admin only. Passwords are prompted (or piped with --password-stdin), never flags.')
769
813
  .option('--from-instance-env', 'import the collector\'s own WAZUH_* environment into this org\'s vault (instance superadmin who is also an org admin; no prompts, no other flags)')
770
814
  .option('--url <url>', 'Wazuh manager API URL, e.g. https://wazuh-host:55000')
771
815
  .option('--user <user>', 'Wazuh API username, e.g. wazuh-wui')
772
816
  .option('--indexer-url <url>', 'Wazuh indexer URL, e.g. https://wazuh-host:9200')
773
817
  .option('--indexer-user <user>', 'Wazuh indexer username (a password is then prompted)')
774
818
  .option('--transport <mode>', 'indexer transport: auto|direct|host', 'auto')
775
- .addHelpText('after', '\nExample: quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --transport host')
819
+ .option('--indexer-ca <file>', 'PEM CA certificate file for a self-signed indexer; stored as indexer_ca in the vault entry')
820
+ .option('--password-stdin', 'read the Wazuh API password from stdin (line 1) instead of the hidden prompt; never argv, never echoed')
821
+ .option('--indexer-password-stdin', 'read the indexer password from stdin (the next line after the API password, if both) instead of the prompt; needs --indexer-user')
822
+ .addHelpText('after', '\nExample: quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --transport host\nNo terminal: printf \'%s\\n%s\\n\' "$API_PW" "$IDX_PW" | quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --indexer-ca root-ca.pem --password-stdin --indexer-password-stdin')
776
823
  .action(async (options) => {
777
824
  try {
778
825
  auth.requireAuth();
779
826
  if (options.fromInstanceEnv) {
780
- if (options.url || options.user || options.indexerUrl || options.indexerUser) throw new WazFail('quox wazuh connect: --from-instance-env cannot be combined with --url, --user, --indexer-url or --indexer-user');
827
+ if (options.url || options.user || options.indexerUrl || options.indexerUser || options.indexerCa || options.passwordStdin || options.indexerPasswordStdin) throw new WazFail('quox wazuh connect: --from-instance-env cannot be combined with --url, --user, --indexer-url, --indexer-user, --indexer-ca, --password-stdin or --indexer-password-stdin');
781
828
  await importInstanceEnv();
782
829
  return;
783
830
  }
@@ -786,6 +833,23 @@ export function attachWazuhConnect(wazuh, program, ctx) {
786
833
  if (!/^https:\/\/[^\s/]+/.test(options.url)) throw new WazFail('quox wazuh connect: --url must be an https:// URL (the credential is sent to it)');
787
834
  if (options.indexerUrl && !/^https:\/\/[^\s/]+/.test(options.indexerUrl)) throw new WazFail('quox wazuh connect: --indexer-url must be an https:// URL');
788
835
  if (!['auto', 'direct', 'host'].includes(options.transport)) throw new WazFail('quox wazuh connect: --transport must be auto, direct or host');
836
+ if (options.indexerPasswordStdin && !options.indexerUser) throw new WazFail('quox wazuh connect: --indexer-password-stdin needs --indexer-user <user>');
837
+ // quox#1588: the CA file is read and checked BEFORE anything is prompted or saved.
838
+ const indexerCa = options.indexerCa ? readIndexerCa(String(options.indexerCa)) : null;
839
+ // quox#1588: stdin is read once; line 1 = API password (--password-stdin), the next = indexer password.
840
+ let stdinLines = null;
841
+ if (options.passwordStdin || options.indexerPasswordStdin) {
842
+ const text = String((await readStdin()) || '');
843
+ stdinLines = text.split(/\r?\n/).map((l) => l.trim());
844
+ if (!stdinLines.some(Boolean)) throw new WazFail('quox wazuh connect: --password-stdin / --indexer-password-stdin given but stdin was empty');
845
+ }
846
+ const fromStdin = (what) => {
847
+ const v = stdinLines.shift();
848
+ if (!v) throw new WazFail(`quox wazuh connect: stdin had no ${what} line (send the API password on line 1 and the indexer password on line 2 when both stdin flags are set)`);
849
+ return v;
850
+ };
851
+ const apiPassword = options.passwordStdin ? fromStdin('API password') : null;
852
+ const indexerPassword = options.indexerPasswordStdin ? fromStdin('indexer password') : null;
789
853
  const orgId = resolveOrgId(auth, program);
790
854
  const listEp = resolveEndpoint('vault.list', { orgId });
791
855
  let existing;
@@ -795,12 +859,13 @@ export function attachWazuhConnect(wazuh, program, ctx) {
795
859
  } catch (err) { throw failFrom('connect', listEp.method, listEp.path, err); }
796
860
 
797
861
  const credentials = { base_url: options.url, username: options.user };
798
- credentials.password = await promptSecret(`Wazuh API password for ${options.user}`);
862
+ credentials.password = apiPassword || await promptSecret(`Wazuh API password for ${options.user}`);
799
863
  if (options.indexerUser) {
800
864
  credentials.indexer_username = options.indexerUser;
801
- credentials.indexer_password = await promptSecret(`Wazuh indexer password for ${options.indexerUser}`);
865
+ credentials.indexer_password = indexerPassword || await promptSecret(`Wazuh indexer password for ${options.indexerUser}`);
802
866
  }
803
867
  if (options.indexerUrl) credentials.indexer_url = options.indexerUrl;
868
+ if (indexerCa) credentials.indexer_ca = indexerCa;
804
869
  credentials.indexer_transport = options.transport;
805
870
 
806
871
  const verb = existing ? 'update' : 'create';
@@ -25,11 +25,19 @@ import { relDuration } from './kuma.js';
25
25
 
26
26
  const RUNBOOK = 'docs/wazuh/custom-quox-integration.md';
27
27
 
28
+ // WAZ-11: ARGUS's routing proposal from the verdict: what it proposes and the command a person runs to accept it.
29
+ function proposalCell(p) {
30
+ if (!p || typeof p !== 'object' || !p.proposed_action) return 'none';
31
+ return `${p.proposed_action}${p.verdict ? ` (ARGUS verdict ${p.verdict})` : ''}${p.command ? `: ${p.command}` : ''}`;
32
+ }
33
+
28
34
  // WAZ P7: compact context flags. A source that could not answer shows as SRC?, never as "nothing".
35
+ // WAZ-11: a source the org never set up (context.not_configured, e.g. no Uptime Kuma) is left out, not SRC? on every row.
29
36
  const UNKNOWN_FLAG = { maintenance: 'MAINT?', kuma: 'KUMA?', deploys: 'DEPLOY?' };
30
37
  export function contextFlags(ctx) {
31
38
  if (!ctx || typeof ctx !== 'object') return '-';
32
- const parts = [...(ctx.flags || []), ...(ctx.unavailable || []).map((s) => UNKNOWN_FLAG[s] || `${String(s).toUpperCase()}?`)];
39
+ const skip = new Set(Array.isArray(ctx.not_configured) ? ctx.not_configured : []);
40
+ const parts = [...(ctx.flags || []), ...(ctx.unavailable || []).filter((s) => !skip.has(s)).map((s) => UNKNOWN_FLAG[s] || `${String(s).toUpperCase()}?`)];
33
41
  return parts.length ? parts.join(' ') : '-';
34
42
  }
35
43
 
@@ -210,6 +218,10 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
210
218
  }
211
219
 
212
220
  // ---- incidents ----
221
+ // WAZ P11: the governance incident a level >= notify-level Wazuh incident opened (shared lifecycle: status, assignee, tickets).
222
+ const governanceCell = (i) => (i.governance_incident_id
223
+ ? `${i.governance_incident_id} (${i.governance_incident && i.governance_incident.status ? i.governance_incident.status : 'status unavailable'})`
224
+ : '-');
213
225
  const ago = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? relDuration(Date.now() - t).replace(/ /g, '') : (iso || '-'); };
214
226
 
215
227
  const incidents = example(wazuh.command('incidents')
@@ -223,16 +235,42 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
223
235
  if (bad(data) || !Array.isArray(data.incidents)) throw errBody('incidents', ep, data, 'response had no incidents array');
224
236
  if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
225
237
  const openN = data.incidents.filter((i) => i.open).length;
226
- head('incidents', `${data.incidents.length} shown · ${openN} open`, openN ? 'warn' : 'ok');
227
- if (!data.incidents.length) return;
238
+ // WAZ-11: governance incidents still open/investigating after their Wazuh side went quiet (additive; the same
239
+ // section ARGUS's wazuh_incidents returns, so the CLI and chat count the same).
240
+ const q = data.quiet_still_open && typeof data.quiet_still_open === 'object' ? data.quiet_still_open : null;
241
+ const qRows = q && Array.isArray(q.incidents) ? q.incidents : [];
242
+ const qUnknown = !!(q && q.count === null);
243
+ const qN = qRows.length;
244
+ const qHead = qUnknown ? ' · gov-open: unknown' : (qN ? ` · ${qN} gov-open (quiet)` : '');
245
+ head('incidents', `${data.incidents.length} shown · ${openN} open${qHead}`, openN || qN || qUnknown ? 'warn' : 'ok');
246
+ const printQuiet = () => {
247
+ if (qUnknown) { console.log(`\nstill open in governance: unknown (${q.error || 'the collector could not read it'})`); return; }
248
+ if (!qN) return;
249
+ // WAZ-11 (audit batch 5, NEW-D a): VERDICT, ARGUS's triage verdict of the Wazuh incident ('-' = not triaged).
250
+ const qr = [['GOVERNANCE', 'STATUS', 'SEVERITY', 'ASSIGNED', 'WAZUH', 'CLOSED AS', 'CLOSED', 'AGENT', 'RULE', 'VERDICT']];
251
+ for (const g of qRows) {
252
+ qr.push([g.id, g.status, g.severity, g.assigned_to || '-', g.wazuh_incident_id || '-', g.wazuh_close_reason || '-', ago(g.wazuh_closed_at),
253
+ g.agent_name || g.agent_id || '-', g.rule_id || '-', g.verdict || '-']);
254
+ }
255
+ console.log(`\n${q.label || 'Wazuh went quiet; still open in governance'} (${qN}):`);
256
+ console.log(pad(qr).join('\n'));
257
+ console.log('Resolve or claim: quox monitoring incidents claim <id> | quox monitoring incidents status <id> resolved --resolution "<what was done>"');
258
+ };
259
+ if (!data.incidents.length) { printQuiet(); return; }
228
260
  // WAZ P7: CONTEXT (MAINT / KUMA-DOWN / DEPLOYED, SRC? when a source could not answer) when the collector sent it.
229
261
  const withCtx = data.incidents.some((i) => i.context);
230
- const rows = [['ID', 'STATE', 'LEVEL', 'AGENT', 'RULE', 'DESCRIPTION', 'COUNT', 'LAST SEEN', 'MITRE', ...(withCtx ? ['CONTEXT'] : [])]];
262
+ // WAZ P11: GOVERNANCE (the linked governance incident id + its status) when any incident is linked.
263
+ const withGov = data.incidents.some((i) => i.governance_incident_id);
264
+ // WAZ P11 1c: VERDICT (ARGUS's rule-based first pass: noise / fix_needed / security_event) when any incident has one.
265
+ const withVerdict = data.incidents.some((i) => i.triage && i.triage.verdict);
266
+ const rows = [['ID', 'STATE', 'LEVEL', 'AGENT', 'RULE', 'DESCRIPTION', 'COUNT', 'LAST SEEN', 'MITRE', ...(withCtx ? ['CONTEXT'] : []), ...(withGov ? ['GOVERNANCE'] : []), ...(withVerdict ? ['VERDICT'] : [])]];
231
267
  for (const i of data.incidents) {
232
268
  rows.push([i.id, i.open ? 'open' : `closed${i.close_reason === 'quiet' ? ' (quiet)' : ''}`, i.level, i.agent_name || i.agent_id, i.rule_id,
233
- String(i.rule_description || '-').slice(0, 60), i.count ?? '-', ago(i.last_seen), (i.mitre_ids || []).join(',') || '-', ...(withCtx ? [contextFlags(i.context)] : [])]);
269
+ String(i.rule_description || '-').slice(0, 60), i.count ?? '-', ago(i.last_seen), (i.mitre_ids || []).join(',') || '-', ...(withCtx ? [contextFlags(i.context)] : []),
270
+ ...(withGov ? [governanceCell(i)] : []), ...(withVerdict ? [(i.triage && i.triage.verdict) || '-'] : [])]);
234
271
  }
235
272
  console.log(pad(rows).join('\n'));
273
+ printQuiet();
236
274
  })), 'quox wazuh incidents --open --since 24h');
237
275
 
238
276
  example(incidents.command('show')
@@ -256,12 +294,23 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
256
294
  ? `no: suppressed, host was in a Kuma maintenance window (${(i.suppressed.monitors || []).join(', ') || 'monitor'})`
257
295
  : 'no (digest only)')],
258
296
  ['Log', i.log_snippet || '-'],
297
+ ['Governance', governanceCell(i)],
298
+ ['Verdict', i.triage && i.triage.verdict ? `${i.triage.verdict} (ARGUS rule-based first pass, no model)` : 'not triaged (set when a governance incident is opened)'],
299
+ ...(i.triage && Array.isArray(i.triage.reasons) && i.triage.reasons.length ? [['Why', i.triage.reasons.join('; ')]] : []),
300
+ ['Proposal', proposalCell(i.proposal)],
259
301
  ...contextRows(i.context),
260
302
  ];
261
303
  const dg = i.diagnosis;
262
304
  rows.push(['Diagnosis', dg ? `${dg.status} (${dg.trigger || dg.mode || 'manual'}) ${dg.summary || ''}`.trim() : `not run (quox wazuh incidents diagnose ${i.id})`]);
263
305
  const es = i.escalation;
264
- rows.push(['Escalated', es && es.finding_id ? `finding ${es.finding_id} in engagement ${es.engagement_id}${es.engagement_name ? ` (${es.engagement_name})` : ''}` : `no (quox wazuh incidents escalate ${i.id})`]);
306
+ // WAZ-11 (audit batch 5, N3): two different escalations, labelled apart. Escalated = sent to QuoxSecurity as a
307
+ // finding; Unowned = the governance incident's own flag (escalated because nobody claimed it).
308
+ rows.push(['Escalated', es && es.finding_id ? `finding ${es.finding_id} in engagement ${es.engagement_id}${es.engagement_name ? ` (${es.engagement_name})` : ''}: sent to QuoxSecurity (${es.finding_id})` : `not sent to QuoxSecurity (quox wazuh incidents escalate ${i.id})`]);
309
+ if (typeof i.governance_unowned_escalated === 'boolean') {
310
+ rows.push(['Unowned', i.governance_unowned_escalated
311
+ ? `unowned-escalated (nobody claimed ${i.governance_incident_id} in time: inbox item + LUCID ask; claim it: quox monitoring incidents claim ${i.governance_incident_id})`
312
+ : 'not unowned-escalated']);
313
+ }
265
314
  if (!i.open) rows.push(['Closed', `${i.closed_at}${i.closed_by ? ` by ${i.closed_by}` : ''}${i.close_reason ? `: ${i.close_reason}` : ''}`]);
266
315
  console.log(pad(rows).join('\n'));
267
316
  if (dg) printChecks(dg, pad);
@@ -56,6 +56,7 @@ export const COMMAND_REGISTRARS = [
56
56
  ['../commands/file.js', 'registerFileCommands'],
57
57
  ['../commands/evidence.js', 'registerEvidenceCommands'],
58
58
  ['../commands/admin.js', 'registerAdminCommands'],
59
+ ['../commands/mcp.js', 'registerMcpCommands'],
59
60
  ['../commands/aee.js', 'registerAeeCommands'],
60
61
  ['../commands/integration.js', 'registerIntegrationCommands'],
61
62
  ['../commands/consent.js', 'registerConsentCommands'],
@@ -114,6 +115,7 @@ export const COMMAND_REGISTRARS = [
114
115
  ['../commands/awareness.js', 'registerAwarenessCommands'],
115
116
  ['../commands/mind.js', 'registerMindCommands'],
116
117
  ['../commands/lucid.js', 'registerLucidCommands'],
118
+ ['../commands/lens.js', 'registerLensCommands'],
117
119
  ['../commands/hyperion.js', 'registerHyperionCommands'],
118
120
  ['../commands/tg.js', 'registerTgCommands'],
119
121
  ['../commands/dev.js', 'registerDevCommands'],
@@ -41,6 +41,15 @@ function acquireLock(lockFile) {
41
41
  function releaseLock(lockFile) { try { unlinkSync(lockFile); } catch { /* already gone */ } }
42
42
  const J = (v) => JSON.stringify(v === undefined ? null : v);
43
43
 
44
+ function envTokenExpiryMs(token) {
45
+ try {
46
+ const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
47
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
44
53
  const DEFAULT_CONFIG = {
45
54
  version: 1,
46
55
  activeProfile: 'local',
@@ -417,7 +426,11 @@ export function loadConfig(profileOverride) {
417
426
  getAuth() {
418
427
  // Env var override
419
428
  if (process.env.QUOX_TOKEN) {
420
- return { access_token: process.env.QUOX_TOKEN };
429
+ // The JWT's own exp is the env token's expiry. Without it auth.isExpired() read every env
430
+ // token as expired and tried a refresh there is no refresh token for (the dashboard /cli
431
+ // browser terminal then opened the conversation app signed out). Not verification: the server verifies.
432
+ const exp = envTokenExpiryMs(process.env.QUOX_TOKEN);
433
+ return exp ? { access_token: process.env.QUOX_TOKEN, expires_at: exp } : { access_token: process.env.QUOX_TOKEN };
421
434
  }
422
435
  return this.getActiveProfile()?.auth || null;
423
436
  },
@@ -501,6 +501,89 @@ export async function workerStatus(deps) {
501
501
  };
502
502
  }
503
503
 
504
+ // 2026-11-07: three worker leases expired overnight with no warning and the nightly suite died on the human-lease
505
+ // error. Every offload command now warns inside this window, and status/leases list every configured worker.
506
+ export const LEASE_WARN_H = 12;
507
+ const STATE_FILE_RE = /^offload(?:-([a-z0-9][a-z0-9-]{0,31}))?\.json$/;
508
+
509
+ /** Configured workers from the state files: '' is the shared default, then named ones sorted. */
510
+ export function listWorkerNames(deps) {
511
+ let files = [];
512
+ try { files = deps.fs.readdirSync(configDir(deps.home)); } catch { return []; }
513
+ const names = [];
514
+ for (const f of files) {
515
+ const m = STATE_FILE_RE.exec(f);
516
+ if (m) names.push(m[1] || '');
517
+ }
518
+ return names.sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b)));
519
+ }
520
+
521
+ /** Lease state for an expiry timestamp: EXPIRED, EXPIRING (within LEASE_WARN_H), OK, or UNKNOWN. */
522
+ export function leaseInfo(expiresAt, now) {
523
+ const t = expiresAt ? Date.parse(expiresAt) : NaN;
524
+ if (!Number.isFinite(t)) return { lease: 'UNKNOWN', hours_left: null };
525
+ const hours = Math.round(((t - now) / 3600000) * 10) / 10;
526
+ if (t <= now) return { lease: 'EXPIRED', hours_left: hours };
527
+ return { lease: hours <= LEASE_WARN_H ? 'EXPIRING' : 'OK', hours_left: hours };
528
+ }
529
+
530
+ /** What a HUMAN runs to renew: there is no lease-extend verb, so lease a fresh worker (the old one is released after). */
531
+ export function renewCommand(name) {
532
+ return `quox -p <human-profile> offload --fresh${name ? ` --worker ${name}` : ''} -- true`;
533
+ }
534
+
535
+ const fmtLeft = (h) => (Math.abs(h) < 1 ? `${Math.round(Math.abs(h) * 60)}m` : `${Math.round(Math.abs(h))}h`);
536
+ export function describeLease(l) {
537
+ if (l.lease === 'EXPIRED') return l.hours_left == null ? 'EXPIRED (the pool no longer holds it)' : `EXPIRED ${fmtLeft(l.hours_left)} ago`;
538
+ if (l.lease === 'EXPIRING' || l.lease === 'OK') return `${l.lease}, expires in ${fmtLeft(l.hours_left)}`;
539
+ return l.lease;
540
+ }
541
+
542
+ function leaseEntry(name, st, live, now) {
543
+ const base = { worker: name || 'default', vmid: null, ip: null, lessee: null, pool_state: null, lease_expires_at: null, lease: 'NONE', hours_left: null, renew: null };
544
+ if (!st.worker) return base;
545
+ const expires = live?.lease_expires_at || st.worker.expires || null;
546
+ let info = leaseInfo(expires, now);
547
+ const pool = live === undefined ? 'unknown' : live ? live.state : 'missing';
548
+ // The pool is the truth when it answered: a VM it no longer holds (or holds for someone else) is gone.
549
+ if (live === null || (live && (live.state !== 'leased' || (st.worker.lessee && live.lessee && live.lessee !== st.worker.lessee)))) {
550
+ info = { lease: 'EXPIRED', hours_left: info.lease === 'EXPIRED' ? info.hours_left : null };
551
+ }
552
+ return {
553
+ ...base, vmid: st.worker.vmid, ip: live?.ip || st.worker.ip || null, lessee: st.worker.lessee || null,
554
+ pool_state: pool, lease_expires_at: expires, ...info,
555
+ renew: info.lease === 'EXPIRED' || info.lease === 'EXPIRING' ? renewCommand(name) : null,
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Every configured worker's lease. `live: true` asks the pool once (a failure is reported, the cached expiry is
561
+ * used); `live: false` reads only the local state files (the pre-run warning must not add a pool call).
562
+ * Read-only: never leases, renews or releases.
563
+ */
564
+ export async function allLeases(deps, { live = true } = {}) {
565
+ const now = deps.now();
566
+ let pool = null; let poolErr = null;
567
+ if (live) {
568
+ try { pool = await poolStatus(deps.rest); } catch (e) { poolErr = String(e?.message || e); }
569
+ }
570
+ const leases = listWorkerNames(deps).map((name) => {
571
+ const st = loadState({ ...deps, worker: name || undefined });
572
+ const v = pool && st.worker ? vmOf(pool, st.worker.vmid) : undefined;
573
+ return leaseEntry(name, st, v, now);
574
+ });
575
+ return {
576
+ checked_at: new Date(now).toISOString(), warn_within_h: LEASE_WARN_H, source: pool ? 'pool' : 'cache',
577
+ ...(poolErr ? { pool_error: poolErr } : {}),
578
+ any_attention: leases.some((l) => l.renew), leases,
579
+ };
580
+ }
581
+
582
+ /** One line per expired or expiring lease, naming the command a human runs. */
583
+ export function leaseWarnings(report) {
584
+ return report.leases.filter((l) => l.renew).map((l) => `warning: worker ${l.worker} (vm ${l.vmid}) lease ${l.lease === 'EXPIRED' ? describeLease(l) : `expires in ${fmtLeft(l.hours_left)} (${l.lease_expires_at})`}; leasing needs a human, who renews it with: ${l.renew}`);
585
+ }
586
+
504
587
  /** Real side effects. */
505
588
  export function realDeps({ rest, env = process.env, log = (m) => process.stderr.write(`offload: ${m}\n`) }) {
506
589
  return {
@@ -12,7 +12,8 @@ import Table from 'cli-table3';
12
12
  * Format and print tabular data
13
13
  *
14
14
  * @param {Object} result - { columns, headers, rows } or array of objects
15
- * @param {Object} options - { format, title, emptyMessage, full } (full: no 60-char cell truncation in table mode)
15
+ * @param {Object} options - { format, title, emptyMessage, full, fit } (full: no 60-char cell truncation in table mode;
16
+ * fit: { width, flex } opt-in, shortens the `flex` column with an ellipsis so each table line fits `width`)
16
17
  */
17
18
  export function formatOutput(result, options = {}) {
18
19
  const format = options.format || getOutputFormat();
@@ -167,9 +168,9 @@ function formatTable(rows, columns, headers, options) {
167
168
  },
168
169
  });
169
170
 
170
- for (const row of rows) {
171
- table.push(columns.map(col => formatValue(row[col], options.full === true)));
172
- }
171
+ const cells = rows.map(row => columns.map(col => formatValue(row[col], options.full === true)));
172
+ if (options.fit) fitCells(cells, columns, headers, options.fit);
173
+ for (const r of cells) table.push(r);
173
174
 
174
175
  console.log(table.toString());
175
176
 
@@ -179,6 +180,30 @@ function formatTable(rows, columns, headers, options) {
179
180
  }
180
181
  }
181
182
 
183
+ // eslint-disable-next-line no-control-regex
184
+ const ANSI = /\x1b\[[0-9;]*m/g;
185
+ const visibleLength = (s) => [...String(s).replace(ANSI, '')].length;
186
+
187
+ /**
188
+ * Opt-in width fitting (fit: { width, flex }). cli-table3 here pads each cell by 1 on both sides and joins
189
+ * cells with a 2-space middle, so a line is sum(w + 2) + 2 * (n - 1). When that passes `width`, the `flex`
190
+ * column is cut to the remaining room with an ellipsis instead of letting the terminal wrap the row
191
+ * (owner 2026-11-07: wrapped `quox actions list` rows in the 73-column TUI).
192
+ */
193
+ function fitCells(cells, columns, headers, { width, flex } = {}) {
194
+ const w = Number(width);
195
+ const fi = columns.indexOf(flex);
196
+ if (!(w > 0) || fi < 0) return;
197
+ const widths = columns.map((_, i) => Math.max(visibleLength(headers[i] ?? ''), ...cells.map(r => visibleLength(r[i]))));
198
+ const total = widths.reduce((a, b) => a + b + 2, 0) + 2 * (columns.length - 1);
199
+ if (total <= w) return;
200
+ const room = Math.max(visibleLength(headers[fi] ?? ''), 8, widths[fi] - (total - w));
201
+ for (const r of cells) {
202
+ const plain = String(r[fi]).replace(ANSI, '');
203
+ if ([...plain].length > room) r[fi] = [...plain].slice(0, room - 1).join('').trimEnd() + '…';
204
+ }
205
+ }
206
+
182
207
  function formatCsv(rows, columns, headers) {
183
208
  console.log(headers.map(csvEscape).join(','));
184
209
  for (const row of rows) {
@@ -20,6 +20,7 @@ import os from 'node:os';
20
20
  import { RUNNING_ROOT, findOnPath } from '../quoxon/slash/restart.js';
21
21
  import { cmpSemver } from '../server/install.js';
22
22
  import { askText } from '../quoxon/ask.js';
23
+ import { isWebPty, SHELL_REFUSED } from '../quoxon/webPty.js';
23
24
 
24
25
  export const NPM_PACKAGE = 'quoxcli';
25
26
  export const NOTES_MAX = 5;
@@ -146,6 +147,7 @@ export async function checkUpdate(info, { deps = {} } = {}) {
146
147
 
147
148
  /** Run the update with the terminal handed over (prompts, sudo). -> { ok, code } or { ok:false, error } */
148
149
  export function runUpdate(plan, { spawn = spawnSync, env = process.env } = {}) {
150
+ if (isWebPty(env)) return { ok: false, error: SHELL_REFUSED }; // QUOXON web PTY: no npm/sudo on the server
149
151
  const r = spawn(plan.command.cmd, plan.command.args, { stdio: 'inherit', env });
150
152
  if (r.error) return { ok: false, error: `${r.error.code || 'failed'}: ${r.error.message}` };
151
153
  const code = r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1);