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
package/lib/commands/x.js CHANGED
@@ -389,6 +389,56 @@ export function registerXCommands(program, ctx) {
389
389
  }
390
390
  });
391
391
 
392
+ // ── x whoami / accounts ───────────────────────────────────────────────────
393
+ // quox#867: a focused read of the connected social account (platform, handle,
394
+ // connection status), without the posting-budget half of `x status`. Backs the
395
+ // chat read-parity lane (an agent/CLI asking "which X account am I connected as?").
396
+ x
397
+ .command('whoami')
398
+ .alias('accounts')
399
+ .description('Show the connected X account: platform, handle and connection status')
400
+ .action(async () => {
401
+ auth.requireAuth();
402
+ const orgId = resolveOrgId(auth, program);
403
+ const ep = resolveEndpoint('v1.tools.execute');
404
+
405
+ let cred = null;
406
+ let credError = null;
407
+ try {
408
+ const credData = await client.post(ep.path, {
409
+ service: ep.service,
410
+ body: { tool_name: 'twitter_verify_credentials', input: {}, org_id: orgId },
411
+ });
412
+ if (credData && credData.success === false) {
413
+ credError = credData.error || 'unknown error';
414
+ } else {
415
+ cred = credData && credData.result ? credData.result : credData;
416
+ }
417
+ } catch (err) {
418
+ credError = err.message;
419
+ }
420
+
421
+ const account = {
422
+ platform: 'x',
423
+ connected: !credError && !!cred,
424
+ handle: cred && cred.username ? `@${cred.username}` : null,
425
+ name: (cred && cred.name) || null,
426
+ id: (cred && cred.id) || null,
427
+ error: credError,
428
+ };
429
+
430
+ if (program.opts().output === 'json') {
431
+ formatDetail({ accounts: [account] }, { format: 'json' });
432
+ return;
433
+ }
434
+
435
+ if (credError) {
436
+ printWarning(`X: not connected (twitter_verify_credentials via POST ${ep.path}): ${credError}`);
437
+ } else {
438
+ printSuccess(`X: connected as ${account.handle}${account.name ? ` (${account.name})` : ''}`);
439
+ }
440
+ });
441
+
392
442
  // ── x media-root ────────────────────────────────────────────────────────
393
443
 
394
444
  x
@@ -0,0 +1,86 @@
1
+ /**
2
+ * announce: the ONE renderer CLI verbs use to say "Quox did something".
3
+ *
4
+ * Owner order 2026-10-29: if anything Quox happens, it announces. A verb that
5
+ * writes or governs prints exactly one QuoxGlyph line:
6
+ * <icon> NAME │ <verb object> │ <result>[ · <duration>] <status>
7
+ * built on core/quoxglyph.js (the canonical grammar), never hand-typed.
8
+ *
9
+ * Output rules:
10
+ * - stderr, not stdout: scripted stdout (tables, ids, --json) is byte-for-byte unchanged;
11
+ * - silent under --json / --quiet;
12
+ * - brand-gradient name only when stderr is a TTY, plain when piped or redirected.
13
+ */
14
+
15
+ import { line } from './quoxglyph.js';
16
+ import { getOutputFormat } from './output.js';
17
+
18
+ // eslint-disable-next-line no-control-regex
19
+ const ANSI = /\x1b\[[0-9;]*m/g;
20
+
21
+ function errMessage(err) {
22
+ if (!err) return 'failed';
23
+ const body = err.body && (err.body.message || err.body.error);
24
+ return String(body || err.message || err).replace(/\s+/g, ' ').trim();
25
+ }
26
+
27
+ /** Render without printing. opts: {result, ms, status} (status defaults to ok). */
28
+ export function renderAnnounce(surface, verb, opts = {}) {
29
+ let s = line(surface, verb, { status: 'ok', ...opts });
30
+ if (!(process.stderr && process.stderr.isTTY)) s = s.replace(ANSI, '');
31
+ return tidy(s);
32
+ }
33
+
34
+ // No result: the grammar's third field is just the status glyph (avoid a double space after the separator).
35
+ const tidy = (s) => s.replace(/│ {2}(?=\S)/, '│ ');
36
+
37
+ export function announceMuted() { return muted(); }
38
+ function muted() {
39
+ const f = getOutputFormat();
40
+ return f === 'json' || f === 'quiet';
41
+ }
42
+
43
+ /** Print one announce line to stderr. Returns the rendered line (or null when muted). */
44
+ export function announce(surface, verb, opts = {}) {
45
+ if (muted()) return null;
46
+ const s = renderAnnounce(surface, verb, opts);
47
+ process.stderr.write(`${s}\n`);
48
+ return s;
49
+ }
50
+
51
+ /** The failure twin: one line, status fail, reason as the result. */
52
+ export function announceFailure(surface, verb, err) {
53
+ if (err && typeof err === 'object') { try { Object.defineProperty(err, 'quoxAnnounced', { value: true, configurable: true }); } catch { /* frozen */ } }
54
+ return announce(surface, verb, { result: errMessage(err), status: 'fail' });
55
+ }
56
+
57
+ /**
58
+ * Command-level wrapper: a verb whose SUCCESS announces itself inline gets its FAILURE exit announced here, once, so
59
+ * every exit (return or throw) of the verb prints exactly one QuoxGlyph line. `verb` is a string, or a function of the
60
+ * action's arguments. An error already announced (by an inner announceFailure) is not announced twice.
61
+ */
62
+ export function announceOnFail(surface, verb, fn) {
63
+ return async (...args) => {
64
+ try { return await fn(...args); } catch (err) {
65
+ if (err && typeof err === 'object' && !err.quoxAnnounced) {
66
+ announceFailure(surface, typeof verb === 'function' ? verb(...args) : verb, err);
67
+ } else if (!err || typeof err !== 'object') announceFailure(surface, typeof verb === 'function' ? verb(...args) : verb, err);
68
+ throw err;
69
+ }
70
+ };
71
+ }
72
+
73
+ // quox_event statuses arrive as QuoxGlyph status names (ok fail busy warn block).
74
+ const EVENT_STATUS = new Set(['ok', 'fail', 'busy', 'warn', 'block']);
75
+
76
+ /**
77
+ * The plain (no ANSI) QuoxGlyph line for a collector `quox_event` frame, so the TUI transcript
78
+ * and `--print` render a turn's Quox effects with the SAME renderer the verbs use.
79
+ * Returns null for a frame with nothing to say.
80
+ */
81
+ export function eventLine(ev) {
82
+ if (!ev || typeof ev !== 'object' || !ev.verb) return null;
83
+ const surface = String(ev.surface || 'quox').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'quox';
84
+ const status = EVENT_STATUS.has(ev.status) ? ev.status : 'ok';
85
+ return tidy(line(surface, String(ev.verb), { status, ...(ev.result ? { result: String(ev.result) } : {}) }).replace(ANSI, ''));
86
+ }
package/lib/core/auth.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { readFileSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
9
9
  import { QuoxAuthError, QuoxApiError, QuoxTransientAuthError } from './errors.js';
10
+ import { rateLimitHint, retryAfterMsFrom } from './loginGuard.js';
10
11
  import { loadResidentProfile, residentLogin, resolveResidentBaseUrl } from './residentAuth.js';
11
12
 
12
13
  // A resident agent has no refresh token: its expired-access-token seam is
@@ -159,6 +160,17 @@ async function acquireRefreshLock(file, { staleMs = 20000, waitMs = 20000 } = {}
159
160
  }
160
161
  }
161
162
 
163
+ // H4: tag a failed bare fetch with the server and profile so the central error mapping can name the exact
164
+ // `quox connect` command when the cause is a certificate this machine does not trust.
165
+ async function fetchTagged(config, baseUrl, url, opts) {
166
+ try { return await fetch(url, opts); } catch (e) {
167
+ if (e && typeof e === 'object') {
168
+ try { e.quoxUrl = baseUrl; e.quoxProfile = config?.getActiveProfileName?.(); } catch { /* tagging is best effort */ }
169
+ }
170
+ throw e;
171
+ }
172
+ }
173
+
162
174
  export function createAuth(config) {
163
175
  return {
164
176
  async login(baseUrl, email, password, orgId) {
@@ -170,7 +182,7 @@ export function createAuth(config) {
170
182
  const body = { email, password, client: 'cli' };
171
183
  if (orgId) body.org_id = orgId;
172
184
 
173
- const res = await fetch(url, {
185
+ const res = await fetchTagged(config, baseUrl, url, {
174
186
  method: 'POST',
175
187
  headers: { 'Content-Type': 'application/json' },
176
188
  body: JSON.stringify(body),
@@ -179,6 +191,12 @@ export function createAuth(config) {
179
191
  const data = await res.json();
180
192
 
181
193
  if (!res.ok) {
194
+ if (res.status === 429) {
195
+ const retryAfterMs = retryAfterMsFrom(res, data);
196
+ const err = new QuoxApiError(429, data.error, rateLimitHint({ retryAfterMs }));
197
+ err.retryAfterMs = retryAfterMs;
198
+ throw err;
199
+ }
182
200
  throw new QuoxApiError(res.status, data.error, data.message || 'Login failed');
183
201
  }
184
202
 
@@ -204,7 +222,7 @@ export function createAuth(config) {
204
222
 
205
223
  async verifyMfa(baseUrl, token, code) {
206
224
  const url = `${baseUrl}/api/mfa/verify`;
207
- const res = await fetch(url, {
225
+ const res = await fetchTagged(config, baseUrl, url, {
208
226
  method: 'POST',
209
227
  headers: {
210
228
  'Content-Type': 'application/json',
@@ -0,0 +1,118 @@
1
+ /**
2
+ * callMe: the shared core behind `quox approvals call-me` and the TUI
3
+ * /callmeforapprovals. It reads and flips the ApprovalsQ call-me policy (the
4
+ * switch for the watcher that phones a human when a high-risk approval sits
5
+ * unanswered). The server route is human-org-admin only; agent identities and
6
+ * service keys are refused there, and every write is audited (AEE).
7
+ *
8
+ * It NEVER approves or denies anything. on/off flip `enabled` only (plus the
9
+ * optional --after / --quiet knobs); every other stored field is preserved.
10
+ */
11
+
12
+ import { resolveEndpoint } from '../api/endpoints.js';
13
+ import { QuoxUserError } from './errors.js';
14
+
15
+ const HHMM = /^(\d{1,2}):(\d{2})$/;
16
+ const ok = (s) => { const m = HHMM.exec(s); return Boolean(m && Number(m[1]) <= 23 && Number(m[2]) <= 59); };
17
+
18
+ /** "22:00-08:00" -> { start, end }, or throws a QuoxUserError naming the fix. */
19
+ export function parseQuiet(text) {
20
+ const m = /^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/.exec(String(text || '').trim());
21
+ if (!m || !ok(m[1]) || !ok(m[2])) {
22
+ throw new QuoxUserError(`--quiet must look like 22:00-08:00 (got "${text}")`, { code: 'bad_quiet_hours' });
23
+ }
24
+ return { start: m[1], end: m[2] };
25
+ }
26
+
27
+ /** --after <minutes>: integer 1-1440, or throws. */
28
+ export function parseAfter(text) {
29
+ const n = Number(text);
30
+ if (!Number.isInteger(n) || n < 1 || n > 1440) {
31
+ throw new QuoxUserError(`--after must be whole minutes between 1 and 1440 (got "${text}")`, { code: 'bad_after' });
32
+ }
33
+ return n;
34
+ }
35
+
36
+ /** Build the PUT body for an action (on|off) plus options { after, quiet }. */
37
+ export function buildChange(action, { after, quiet } = {}) {
38
+ if (action !== 'on' && action !== 'off') {
39
+ throw new QuoxUserError(`Unknown action "${action}". Use status, on or off.`, { code: 'bad_action' });
40
+ }
41
+ const body = { enabled: action === 'on' };
42
+ if (after !== undefined && after !== null && after !== '') body.age_threshold_minutes = parseAfter(after);
43
+ if (quiet) body.quiet_hours = parseQuiet(quiet);
44
+ return body;
45
+ }
46
+
47
+ /** One honest line about the watcher. Unknown is said as unknown, never guessed as alive. */
48
+ export function watcherLine(w) {
49
+ if (!w || typeof w !== 'object') return 'watcher: unknown (the server did not report it)';
50
+ if (w.running === false) {
51
+ return w.reason === 'env_flag_off'
52
+ ? 'watcher: NOT RUNNING (QUOX_APPROVALSQ_WATCHER is off on the collector, so no call will be placed)'
53
+ : 'watcher: NOT RUNNING (it did not start, so no call will be placed)';
54
+ }
55
+ if (w.running === true) {
56
+ if (!w.last_tick_at) return 'watcher: started, no check completed yet';
57
+ const tail = w.stale ? ' STALE, it has stopped checking' : (w.last_tick_ok === false ? ' (last check had errors)' : '');
58
+ return `watcher: alive, last check ${w.last_tick_at}${tail}`;
59
+ }
60
+ return 'watcher: unknown (the server did not report it)';
61
+ }
62
+
63
+ /** Human-readable status lines from a { policy, watcher } response. */
64
+ export function statusLines(data) {
65
+ const p = (data && data.policy) || {};
66
+ const q = p.quiet_hours || {};
67
+ const lines = [
68
+ `call me for approvals: ${p.enabled ? 'ON' : 'OFF'}`,
69
+ `rings after: ${p.age_threshold_minutes} min unanswered`,
70
+ `quiet hours: ${q.start}-${q.end} ${q.timezone || ''}`.trimEnd(),
71
+ `min tier: ${p.min_tier}`,
72
+ watcherLine(data && data.watcher),
73
+ ];
74
+ if (p.enabled && p.destination_configured === false) lines.push('calls go to: the owner mobile (QuoxVoice default; no destination number set on this policy)');
75
+ return lines;
76
+ }
77
+
78
+ /** Plain-words refusal for a 401/403 from the server. */
79
+ function refusal(e) {
80
+ if (e && e.status === 402) {
81
+ return new QuoxUserError('QuoxVoice is not licensed for this org, so call-me for approvals is unavailable.', { code: 'license_required', status: 402 });
82
+ }
83
+ if (e && (e.status === 403 || e.status === 401)) {
84
+ return new QuoxUserError('Only a signed-in human org admin can change or read the call-me policy. Agent identities and service keys are refused.', { code: 'admin_only', status: e.status });
85
+ }
86
+ return e;
87
+ }
88
+
89
+ export async function getCallMe(client) {
90
+ const ep = resolveEndpoint('v1.approvals.call-me.get');
91
+ try { return await client.get(ep.path, { service: ep.service, retries: 0 }); } catch (e) { throw refusal(e); }
92
+ }
93
+
94
+ export async function setCallMe(client, change) {
95
+ const ep = resolveEndpoint('v1.approvals.call-me.put');
96
+ try { return await client.put(ep.path, { service: ep.service, body: change, retries: 0 }); } catch (e) { throw refusal(e); }
97
+ }
98
+
99
+ /** Parse TUI args: "[on|off|status] [--after N] [--quiet HH:MM-HH:MM]" -> { action, after, quiet }. */
100
+ export function parseSlashArgs(text) {
101
+ const toks = String(text || '').trim().split(/\s+/).filter(Boolean);
102
+ const out = { action: 'status' };
103
+ for (let i = 0; i < toks.length; i++) {
104
+ const t = toks[i];
105
+ if (t === 'on' || t === 'off' || t === 'status') out.action = t;
106
+ else if (t === '--after') out.after = toks[++i];
107
+ else if (t === '--quiet') out.quiet = toks[++i];
108
+ else throw new QuoxUserError(`Unknown argument "${t}". Usage: /callmeforapprovals [on|off|status] [--after <minutes>] [--quiet HH:MM-HH:MM]`, { code: 'bad_args' });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Run an action end to end -> { data, changed }. status reads; on/off write. */
114
+ export async function runCallMe(client, { action = 'status', after, quiet } = {}) {
115
+ if (action === 'status') return { data: await getCallMe(client), changed: false };
116
+ const body = buildChange(action, { after, quiet });
117
+ return { data: await setCallMe(client, body), changed: true };
118
+ }
@@ -12,6 +12,7 @@
12
12
  import { resolveActing, resolveActingToken } from './actingAs.js';
13
13
  import { detectCallerSource } from './callerSource.js';
14
14
  import { QuoxApiError, QuoxNetworkError, QuoxAuthError, QuoxTransientAuthError } from './errors.js';
15
+ import { tlsFailureSentence } from './profileTrust.js';
15
16
  import { joinServiceUrl, isLocalServiceTarget } from './config.js';
16
17
 
17
18
  const RETRY_STATUSES = new Set([429, 502, 503]);
@@ -30,14 +31,21 @@ const TLS_TRUST_CODES = new Set([
30
31
  'ERR_TLS_CERT_ALTNAME_INVALID',
31
32
  ]);
32
33
 
34
+ function tlsConnectUrl(u) {
35
+ try { return new URL(u).origin; } catch { return '<url>'; }
36
+ }
37
+
33
38
  export function describeNetworkError(service, baseUrl, err) {
34
39
  const code = err?.cause?.code || err?.code;
35
40
  let msg = `Could not connect to ${service} service at ${baseUrl}: ${err.message}`;
36
41
  if (code && TLS_TRUST_CODES.has(code)) {
37
42
  msg += `\n TLS: certificate not trusted (${code}). A new QuoxCORE uses its own self-signed certificate; trust that file`
43
+ + `\n Trust this server for one profile with: quox connect ${tlsConnectUrl(baseUrl)} --profile <name>`
44
+ + `\n (it shows the certificate SHA256 and asks y/N; with no terminal add --fingerprint <sha256>). Or trust the file for the whole shell`
38
45
  + `\n (on the server, by default ~/quox-install/quox/ssl/quox.crt; copy it here if the server is another machine):`
39
46
  + `\n export NODE_EXTRA_CA_CERTS=<path to quox.crt>`
40
- + `\n Never disable TLS verification (NODE_TLS_REJECT_UNAUTHORIZED=0) instead.`;
47
+ + `\n Never disable TLS verification (NODE_TLS_REJECT_UNAUTHORIZED=0) instead.`
48
+ + `\n ${tlsFailureSentence(code, baseUrl)}`;
41
49
  } else if (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO') {
42
50
  // quox#532 defect 1: the profile says https:// but the service answered
43
51
  // PLAINTEXT (classic: url stamped https://localhost:3000 against plain
@@ -68,6 +76,20 @@ function generateCorrelationId() {
68
76
  return `cli-${t}-${r}`;
69
77
  }
70
78
 
79
+ /**
80
+ * PROFILE-NO-FALLBACK (2026-10-29): an explicitly chosen profile (-p / --profile / QUOX_PROFILE) with no
81
+ * session must never borrow this box's ambient service key: a memory create ran as the SERVICE, not objeng.
82
+ * The implicit fallback stays for the no-profile-chosen case. Throws before any request is sent.
83
+ */
84
+ function assertNoExplicitProfileFallback(config) {
85
+ const explicit = Boolean(config?._explicitProfile) || Boolean(String(process.env.QUOX_PROFILE || '').trim());
86
+ if (!explicit) return;
87
+ let name = null;
88
+ try { name = typeof config.getActiveProfileName === 'function' ? config.getActiveProfileName() : null; } catch { name = null; }
89
+ name = name || config._effectiveProfile || process.env.QUOX_PROFILE;
90
+ throw new QuoxAuthError(`Profile ${name} is signed out. Sign in: quox -p ${name} login`);
91
+ }
92
+
71
93
  /**
72
94
  * HITL-CONTEXT (owner 2026-10-07): name the calling session and profile so an
73
95
  * approval this call triggers says "VULCAN (CLI) · profile moo" instead of a
@@ -84,6 +106,8 @@ export function callerContextHeaders(config, env = process.env, detectDeps = {})
84
106
  try { session = detectCallerSource(env, detectDeps).label; } catch { session = null; }
85
107
  }
86
108
  if (session) headers['X-Quox-Session'] = clean(session, 48);
109
+ // The proof engine marks its turns: the server then files no approval for them (a read case must never page the owner).
110
+ if (env.QUOX_PROOF_TURN === '1') headers['X-Quox-Proof-Turn'] = '1';
87
111
  let profile = null;
88
112
  try { profile = typeof config?.getActiveProfileName === 'function' ? config.getActiveProfileName() : null; } catch { profile = null; }
89
113
  if (profile) {
@@ -209,6 +233,7 @@ export function createClient(config, auth) {
209
233
  } else if (tokens?.access_token) {
210
234
  fetchHeaders['Authorization'] = `Bearer ${tokens.access_token}`;
211
235
  } else if (serviceKey && isLocalServiceTarget(url.href)) {
236
+ assertNoExplicitProfileFallback(config);
212
237
  // QUOXON-Z2H lane R: the implicit fallback is THIS box's key; never
213
238
  // offer it to another host (a remote profile with no login). A caller
214
239
  // that owns a remote key says so with QUOX_FORCE_SERVICE_KEY.
@@ -538,6 +563,7 @@ export function createClient(config, auth) {
538
563
  } else if (streamTokens?.access_token) {
539
564
  fetchHeaders['Authorization'] = `Bearer ${streamTokens.access_token}`;
540
565
  } else if (process.env.QUOX_SERVICE_KEY && isLocalServiceTarget(url.href)) {
566
+ assertNoExplicitProfileFallback(config);
541
567
  streamServiceKey = process.env.QUOX_SERVICE_KEY;
542
568
  fetchHeaders['X-Service-Key'] = streamServiceKey;
543
569
  }
@@ -11,6 +11,7 @@ export const COMMAND_REGISTRARS = [
11
11
  ['../commands/status.js', 'registerStatusCommand'],
12
12
  ['../commands/doctor.js', 'registerDoctorCommand'],
13
13
  ['../commands/server.js', 'registerServerCommands'],
14
+ ['../commands/connect.js', 'registerConnectCommand'],
14
15
  ['../commands/update.js', 'registerUpdateCommand'],
15
16
  ['../commands/shortcut.js', 'registerShortcutCommand'],
16
17
  ['../commands/open.js', 'registerOpenCommand'],
@@ -45,6 +46,11 @@ export const COMMAND_REGISTRARS = [
45
46
  ['../commands/notes.js', 'registerNotesCommands'],
46
47
  ['../commands/blog.js', 'registerBlogCommands'],
47
48
  ['../commands/contacts.js', 'registerContactsCommands'],
49
+ // `quox inboxq watch` was consolidated into `quox mail watch` (the canonical
50
+ // InboxQ namespace: accounts/zones/sync/watches/proposals). The command file
51
+ // and its test are kept but unregistered; full deletion is a follow-up that
52
+ // needs a test-removal approval. Do not re-register without re-consolidating.
53
+ // ['../commands/inboxq.js', 'registerInboxqCommands'],
48
54
  ['../commands/licence.js', 'registerLicenceCommands'],
49
55
  ['../commands/run.js', 'registerRunCommands'],
50
56
  ['../commands/file.js', 'registerFileCommands'],
@@ -65,6 +71,7 @@ export const COMMAND_REGISTRARS = [
65
71
  ['../commands/vault.js', 'registerVaultCommands'],
66
72
  ['../commands/volt.js', 'registerVoltCommands'],
67
73
  ['../commands/governance.js', 'registerGovernanceCommands'],
74
+ ['../commands/compliance.js', 'registerComplianceCommands'],
68
75
  ['../commands/skills.js', 'registerSkillsCommands'],
69
76
  ['../commands/claude.js', 'registerClaudeCommands'],
70
77
  ['../commands/monitoring.js', 'registerMonitoringCommands'],
@@ -90,6 +97,7 @@ export const COMMAND_REGISTRARS = [
90
97
  ['../commands/trace.js', 'registerTraceCommands'],
91
98
  ['../commands/mon.js', 'registerMonCommands'],
92
99
  ['../commands/kuma.js', 'registerKumaCommands'],
100
+ ['../commands/wazuh.js', 'registerWazuhCommands'],
93
101
  ['../commands/quoxbox.js', 'registerQuoxboxCommands'],
94
102
  ['../commands/plan.js', 'registerPlanCommands'],
95
103
  ['../commands/engagement.js', 'registerEngagementCommands'],
@@ -9,8 +9,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, renameSy
9
9
  import { join } from 'node:path';
10
10
  import { homedir, networkInterfaces, hostname as osHostname } from 'node:os';
11
11
 
12
- function getConfigDir() { return join(homedir(), '.config', 'quox'); }
13
- function getConfigFile() { return join(getConfigDir(), 'config.json'); }
12
+ export function getConfigDir() { return join(homedir(), '.config', 'quox'); }
13
+ export function getConfigFile() { return join(getConfigDir(), 'config.json'); }
14
14
 
15
15
  // quox#713: cross-process lock around read-merge-write of config.json.
16
16
  const LOCK_STALE_MS = 10000;
@@ -346,6 +346,32 @@ export function loadConfig(profileOverride) {
346
346
  this.save();
347
347
  },
348
348
 
349
+ // ONBOARD-P1 (quox connect): a named profile that is not the active one, created or updated in place.
350
+ // Never moves the persisted default.
351
+ getProfile(name) { return this._data.profiles[name] || null; },
352
+ upsertProfile(name, patch = {}) {
353
+ let prof = this._data.profiles[name];
354
+ if (!prof) {
355
+ prof = this._data.profiles[name] = { url: patch.url || DEFAULT_CONFIG.profiles.local.url, auth: null };
356
+ this._removedProfiles?.delete(name);
357
+ }
358
+ Object.assign(prof, patch);
359
+ this.save();
360
+ return prof;
361
+ },
362
+ // Put a profile back exactly as it was before upsertProfile (snapshot from getProfile, null = did not exist).
363
+ restoreProfile(name, snapshot) {
364
+ if (snapshot == null) {
365
+ delete this._data.profiles[name];
366
+ this._removedProfiles.add(name);
367
+ } else {
368
+ const cur = this._data.profiles[name] || (this._data.profiles[name] = {});
369
+ for (const k of Object.keys(cur)) if (!(k in snapshot)) delete cur[k];
370
+ Object.assign(cur, JSON.parse(JSON.stringify(snapshot)));
371
+ }
372
+ this.save();
373
+ },
374
+
349
375
  removeProfile(name) {
350
376
  if (name === this._effectiveProfile) {
351
377
  throw new Error('Cannot remove the active profile. Switch first.');
@@ -17,6 +17,51 @@
17
17
  * @param {string} message - confirmation prompt text
18
18
  * @returns {Promise<boolean>}
19
19
  */
20
+ import { QuoxUserError } from './errors.js';
21
+
22
+ /**
23
+ * Ask y/N, but treat a closed stdin (EOF) as "no answer" (resolves false)
24
+ * instead of leaving the prompt pending, which Node reports as a leaked
25
+ * "Detected unsettled top-level await" warning.
26
+ */
27
+ async function askConfirm(message) {
28
+ const { default: inquirer } = await import('inquirer');
29
+ let onEnd;
30
+ const eof = new Promise((resolve) => {
31
+ onEnd = () => resolve(false);
32
+ process.stdin.once('end', onEnd);
33
+ process.stdin.once('close', onEnd);
34
+ });
35
+ try {
36
+ const ans = await Promise.race([
37
+ inquirer.prompt([{ type: 'confirm', name: 'confirm', message, default: false }]),
38
+ eof,
39
+ ]);
40
+ return ans === false ? false : ans.confirm;
41
+ } finally {
42
+ process.stdin.off('end', onEnd);
43
+ process.stdin.off('close', onEnd);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * y/N for verbs that have their own non-interactive flag (e.g. `--i-accept`).
49
+ * With no terminal it does not open a prompt: it throws a one-line
50
+ * QuoxUserError naming the flag, exit code 2.
51
+ * @param {string} message
52
+ * @param {{ flag?: string, purpose?: string }} [opts]
53
+ * @returns {Promise<boolean>}
54
+ */
55
+ export async function confirmInteractive(message, { flag = '--yes', purpose = 'confirm' } = {}) {
56
+ if (!process.stdin.isTTY) {
57
+ throw new QuoxUserError(
58
+ `No terminal to answer y/N here. Read the output above, then re-run with ${flag} to ${purpose}.`,
59
+ { exitCode: 2, code: 'no_terminal' },
60
+ );
61
+ }
62
+ return askConfirm(message);
63
+ }
64
+
20
65
  export async function confirmDestructive(program, options, message) {
21
66
  if (options.force === true || program.opts().yes === true) {
22
67
  return true;
@@ -26,12 +71,28 @@ export async function confirmDestructive(program, options, message) {
26
71
  throw new Error(`${message} — this needs confirmation; re-run with --force (or -y) in a non-interactive shell`);
27
72
  }
28
73
 
74
+ return askConfirm(message);
75
+ }
76
+
77
+ /**
78
+ * Ask for a typed line (e.g. the name of the object about to be deleted). EOF settles as null.
79
+ * Callers check the terminal first; this never opens a prompt without one.
80
+ * @param {string} message
81
+ * @returns {Promise<string|null>}
82
+ */
83
+ export async function askTyped(message) {
29
84
  const { default: inquirer } = await import('inquirer');
30
- const ans = await inquirer.prompt([{
31
- type: 'confirm',
32
- name: 'confirm',
33
- message,
34
- default: false,
35
- }]);
36
- return ans.confirm;
85
+ let onEnd;
86
+ const eof = new Promise((resolve) => {
87
+ onEnd = () => resolve(null);
88
+ process.stdin.once('end', onEnd);
89
+ process.stdin.once('close', onEnd);
90
+ });
91
+ try {
92
+ const ans = await Promise.race([inquirer.prompt([{ type: 'input', name: 'typed', message }]), eof]);
93
+ return ans === null ? null : String(ans.typed ?? '');
94
+ } finally {
95
+ process.stdin.off('end', onEnd);
96
+ process.stdin.off('close', onEnd);
97
+ }
37
98
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * crossOrgApprovals: "N more waiting in <org>" for the orgs the user belongs to OTHER than the active one.
3
+ *
4
+ * Read-only and counts only. orgs.list is scoped to the caller, and each count is the user's own
5
+ * GET /api/orgs/:orgId/inbox/stats (server: active membership of THAT org, owner/admin see the whole org,
6
+ * members only their own items), read with the caller's OWN token: the server checks membership of the URL org
7
+ * in the database, not the token's org claim, so no per-org token is minted (a mint is an auth.org_switch audit
8
+ * event and ~2s each). No card content crosses orgs and nothing here can decide anything; deciding in another org
9
+ * is an explicit `--org <id>` on the verb. Counts (never tokens) are cached 60s in-process.
10
+ */
11
+ import { resolveEndpoint } from '../api/endpoints.js';
12
+
13
+ const listOf = (d) => (Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || []));
14
+
15
+ const CACHE_MS = 60 * 1000;
16
+ const cache = new Map(); // `${userScope}|${currentOrgId}` -> { at, res }
17
+ export const _clearCrossOrgCache = () => cache.clear();
18
+
19
+ /**
20
+ * @param {object} a
21
+ * @param {object} a.client the caller's client (lists memberships, reads each org's counts)
22
+ * @param {string|null} a.currentOrgId
23
+ * @param {()=>number} [a.now]
24
+ * @returns {Promise<{ waiting: {id:string,name:string,count:number}[], failed: number, checked: boolean }>}
25
+ * failed counts orgs that could not be read: an unreadable org is NOT "nothing waiting".
26
+ */
27
+ export async function otherOrgsWaiting({ client, currentOrgId, now = Date.now }) {
28
+ if (!client) return { waiting: [], failed: 0, checked: false };
29
+ const key = `${currentOrgId}`;
30
+ const hit = cache.get(key);
31
+ if (hit && now() - hit.at < CACHE_MS) return hit.res;
32
+ let orgs;
33
+ try { const ep = resolveEndpoint('orgs.list'); orgs = listOf(await client.get(ep.path, { service: ep.service })); }
34
+ catch { return { waiting: [], failed: 1, checked: true }; }
35
+ const others = orgs.filter((o) => o?.id && o.id !== currentOrgId);
36
+ const waiting = []; let failed = 0;
37
+ await Promise.all(others.map(async (o) => {
38
+ try {
39
+ const ep = resolveEndpoint('inbox.stats.list', { orgId: o.id });
40
+ const d = await client.get(ep.path, { service: ep.service, retries: 0 });
41
+ const count = Number(d?.awaiting_human ?? d?.pending_approvals ?? 0);
42
+ if (count > 0) waiting.push({ id: o.id, name: o.name || o.slug || o.id, count });
43
+ } catch { failed += 1; }
44
+ }));
45
+ waiting.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
46
+ const res = { waiting, failed, checked: true };
47
+ if (!failed) cache.set(key, { at: now(), res }); // a partial answer is never cached
48
+ return res;
49
+ }
50
+
51
+ /** One line per org (plain terminal): the exact command to run. */
52
+ export function crossOrgLines(res, verb = 'list') {
53
+ const out = (res?.waiting || []).map((w) => `${w.count} more waiting in ${w.name}: quox approvals ${verb} --org ${w.id}`);
54
+ if (res?.failed) out.push(`Could not check ${res.failed} other org${res.failed === 1 ? '' : 's'} for waiting approvals; that does not mean nothing is waiting there.`);
55
+ return out;
56
+ }
57
+
58
+ /** TUI wording: switching is the TUI's own move. */
59
+ export function crossOrgTuiLines(res) {
60
+ const out = (res?.waiting || []).map((w) => `${w.count} more waiting in ${w.name}: /org switch ${w.name}`);
61
+ if (res?.failed) out.push(`Could not check ${res.failed} other org${res.failed === 1 ? '' : 's'} for waiting approvals; that does not mean nothing is waiting there.`);
62
+ return out;
63
+ }
64
+
65
+ /** The client for a verb: the profile client for the session org, a minted same-user token client for another. */
66
+ export async function clientForTarget({ client, clientForOrg, sessionOrgId, orgId }) {
67
+ if (!orgId || orgId === sessionOrgId || typeof clientForOrg !== 'function') return client;
68
+ return clientForOrg(orgId);
69
+ }