quoxcli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +110 -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/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -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
+ }
@@ -214,6 +214,14 @@ export function explainError(err, ctx = null) {
214
214
  const raw = RAW_HEADLINE.test(String(err.message || '').split('\n')[0]) || err.reasonFromError === true;
215
215
  const reason = serverReason(err);
216
216
 
217
+ // SKILLRIGHTS #478: require-acceptance refusal is not a generic 409 conflict.
218
+ if (status === 409 && (code === 'acceptance_required' || body.error === 'acceptance_required')) {
219
+ const pack = typeof body.packId === 'string' && body.packId ? body.packId : 'quox-core-dev-pack';
220
+ return {
221
+ headline: sentence(reason || `This org's distribution policy is require-acceptance, and nobody has accepted the current terms for pack '${pack}'`),
222
+ next: `accept the terms as yourself: quox dev skills accept ${pack} (add --i-accept with no terminal), then re-run the sync.`,
223
+ };
224
+ }
217
225
  // #8 service-key fallback: the real problem is "no stored session", say it once.
218
226
  if (err.serviceKeyFallback && (status === 401 || status === 403)) {
219
227
  return {
@@ -268,6 +276,28 @@ export function explainError(err, ctx = null) {
268
276
  if (status === 404 && /^approval not found$/i.test(reason || '')) {
269
277
  return { headline: 'No approval with that id in this org; it may belong to another org or be past retention.', next: notFoundNext(ctx) || '`quox approvals --help` shows how to list what exists.' };
270
278
  }
279
+ // DS-010 / #1335: a step-up refusal is about WHO is at the keyboard, not about org or role. The old fall-through
280
+ // sent people to "switch org / ask an admin", which cannot help: only the approver's own PIN does.
281
+ if (status === 403 && /step_up_required/.test(all)) {
282
+ if (body.reason === 'non_human') {
283
+ return { headline: 'This approval needs a person, and this login is an agent or service identity.', next: 'approve it from a person\'s own login (the dashboard, or `quox` in their terminal).' };
284
+ }
285
+ return {
286
+ headline: 'This approval needs your step-up PIN.',
287
+ next: body.pin_set === false
288
+ ? 'set a PIN in the dashboard (Settings > Account > Approval PIN), then run this again in a terminal.'
289
+ : 'run this command in an interactive terminal; it will ask for the PIN with hidden input. The PIN cannot be passed as a flag or environment variable.',
290
+ };
291
+ }
292
+ // the auth service's requireInstanceAdmin refusal (code instance_admin_required): instance admin is instance-wide,
293
+ // so neither switching org nor asking an org admin can help
294
+ if (status === 403 && /instance_admin_required|instance administrator access required/i.test(all)) {
295
+ return { headline: sentence(reason || 'Instance administrator access required'), next: 'this needs an instance administrator account. Run it with a profile that is one (quox -p <profile> ...), or ask your instance admin.' };
296
+ }
297
+ // an instance-admin gate: switching org cannot help, only an instance admin can
298
+ if (status === 403 && /instance admin required/i.test(all)) {
299
+ return { headline: 'This needs an instance admin, and this login is not one.', next: 'ask an instance admin of this Quox server to run it, or to make your login an instance admin.' };
300
+ }
271
301
  // #5 a role refusal is not an org-context problem
272
302
  if (status === 403 && /insufficient permissions?|lacks?[ _]capability|requires? (an? |the )?(admin|owner) role|not an? (org )?(admin|owner)/i.test(all)) {
273
303
  return { headline: sentence(reason || 'Your role does not allow this'), next: 'your role in this org does not allow this; ask an org owner or admin to do it or to raise your role.' };
@@ -332,6 +362,7 @@ function fallbackExplain(err, ctx) {
332
362
  }
333
363
 
334
364
  // CLI-ERRORS: an instruction sentence (the step the user takes) vs the rest (what happened).
365
+ const TLS_HINT_CODES = ['DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED'];
335
366
  const INSTRUCTION = /^(run|check|use|retry|try|pass|fix|set|pick|switch|ask|log ?in|re-?run|please)\b/i;
336
367
 
337
368
  function splitInstruction(text) {
@@ -351,6 +382,10 @@ function splitInstruction(text) {
351
382
  * CLI-ERRORS: errors the CLI raises itself (auth, network, validation, anything else) in the same
352
383
  * contract as API errors: one headline, at most one Next, the rest (`more`) only with --verbose.
353
384
  */
385
+ function connectUrl(u) {
386
+ try { return new URL(u).origin; } catch { return '<url>'; }
387
+ }
388
+
354
389
  export function explainLocal(err) {
355
390
  if (!err || err instanceof QuoxApiError || err instanceof QuoxUserError) return null;
356
391
  const message = String(err.message || '');
@@ -362,7 +397,7 @@ export function explainLocal(err) {
362
397
  const more = message.split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
363
398
  if (m && code && /CERT|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code)) {
364
399
  return { headline: `Could not reach the ${svc} service at ${url}: its certificate is not trusted (${code}).`,
365
- next: 'trust it with export NODE_EXTRA_CA_CERTS=<path to quox.crt> (on the server: ~/quox-install/quox/ssl/quox.crt); never disable TLS verification.', more };
400
+ next: `trust this server for one profile with quox connect ${connectUrl(url)} --profile <name> (it shows the certificate SHA256 and asks y/N; with no terminal add --fingerprint <sha256>); never disable TLS verification.`, more };
366
401
  }
367
402
  if (m && (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO')) {
368
403
  return { headline: `The ${svc} service at ${url} speaks plain http, but this profile's URL says https://.`,
@@ -389,6 +424,20 @@ export function explainLocal(err) {
389
424
  return sp;
390
425
  }
391
426
  if (err instanceof QuoxValidationError) return splitInstruction(message);
427
+ // H4: a bare fetch (login, MFA, refresh) that died on a certificate this machine does not trust. undici reports it
428
+ // as "fetch failed"; name the problem and the exact next command. Never auto-trust, never TLS-off.
429
+ const tlsCode = TLS_HINT_CODES.find((c) => c === (err.cause?.cause?.code || err.cause?.code || err.code));
430
+ if (tlsCode && err.quoxUrl) {
431
+ const profile = err.quoxProfile ? ` --profile ${err.quoxProfile}` : '';
432
+ const expired = tlsCode === 'CERT_HAS_EXPIRED' || tlsCode === 'ERR_TLS_CERT_ALTNAME_INVALID';
433
+ return { headline: expired
434
+ ? `The server at ${err.quoxUrl} presents a certificate that ${tlsCode === 'CERT_HAS_EXPIRED' ? 'has expired' : 'does not name the address you used'} (${tlsCode}).`
435
+ : `The server at ${err.quoxUrl} uses a certificate this machine does not trust yet.`,
436
+ next: expired
437
+ ? 'the server needs a certificate that is current and names this address; trusting it would not help. Never disable TLS verification.'
438
+ : `run quox connect ${err.quoxUrl}${profile} (it shows the SHA256 fingerprint and asks y/N); with no terminal use quox connect ${err.quoxUrl}${profile} --trust-cert, or --fingerprint <sha256> to pin it. Never disable TLS verification.`,
439
+ more: [`TLS error: ${tlsCode}`] };
440
+ }
392
441
  const first = message.split('\n')[0];
393
442
  return { headline: sentence(first || 'Something went wrong'), next: null, more: message.split('\n').slice(1), stack: true };
394
443
  }
@@ -404,6 +453,7 @@ function renderContract(err, ex, ctx) {
404
453
  }
405
454
  if (err.detail) console.error(chalk.dim(` Detail: ${renderDetail(err.detail)}`));
406
455
  if (isVerbose(ctx)) {
456
+ if (err.debug) console.error(chalk.dim(` Debug: ${renderDetail(err.debug)}`));
407
457
  if (err.status) console.error(chalk.dim(` Status: ${err.status} ${typeof err.code === 'object' && err.code ? renderDetail(err.code) : (err.code || '')}`));
408
458
  if (err instanceof QuoxApiError && err.message && err.message !== ex.headline) console.error(chalk.dim(` Server said: ${err.message}`));
409
459
  for (const line of ex.more || []) if (line) console.error(chalk.dim(` More: ${line}`));
@@ -420,6 +470,7 @@ export function handleError(err, outputFormat = 'table', ctx = null) {
420
470
  };
421
471
  if (err.status) obj.status = err.status;
422
472
  if (err.detail) obj.detail = err.detail;
473
+ if (err.debug) obj.debug = err.debug; // plumbing kept out of the headline, still there for scripts
423
474
  // LL-3d: scripts parsing --json get the server's own body verbatim, not
424
475
  // our prose rendering of it.
425
476
  if (err.body && typeof err.body === 'object') obj.body = err.body;
@@ -12,7 +12,18 @@ import os from 'node:os';
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { spawnSync as realSpawnSync } from 'node:child_process';
15
- import { qArt, colorOn } from './banner.js';
15
+ import { colorOn } from './banner.js';
16
+ import { qLines, qDims } from '../quoxon/qart.js';
17
+
18
+ /**
19
+ * The session banner's Q is quoxon's settled vector horned-Q (the face of the app, same mark as
20
+ * bare `quox` and cli.quox.ai), not the old half-block bitmap. 'small' for a narrow terminal, 'mid'
21
+ * otherwise. Returns the art rows and their visible width (used to pad the text column alignment).
22
+ */
23
+ function introArt(color, cols) {
24
+ const size = cols && cols < 72 ? 'small' : 'mid';
25
+ return { lines: qLines(size, { color }), width: qDims(size).cols };
26
+ }
16
27
  import { startHeartbeat as realStartHeartbeat } from './sessionHeartbeat.js';
17
28
 
18
29
  const NAME_RE = /^[A-Za-z0-9._-]{1,48}$/;
@@ -160,10 +171,10 @@ export function howItWorksLines({ name, program = 'claude', bare }) {
160
171
  ];
161
172
  }
162
173
 
163
- export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false } = {}) {
174
+ export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false, cols = process.stdout?.columns } = {}) {
164
175
  const b = (s) => (color ? `\x1b[1m${s}\x1b[0m` : s);
165
176
  const d = (s) => (color ? `\x1b[2m${s}\x1b[0m` : s);
166
- const art = qArt(color);
177
+ const { lines: art, width: artW } = introArt(color, cols);
167
178
  const modeText = {
168
179
  NEW: 'NEW',
169
180
  REATTACH: 'REATTACH (same process, no context reload)',
@@ -182,7 +193,7 @@ export function renderIntro({ name, host, mode, tmuxSession, note, program = 'cl
182
193
  b('How this works'),
183
194
  ...howItWorksLines({ name, program }).map(([k, v]) => `${d(k.padEnd(19))}${v}`),
184
195
  ];
185
- const w = art[0] ? 14 : 0;
196
+ const w = art[0] ? artW : 0;
186
197
  const lines = [];
187
198
  for (let i = 0; i < Math.max(art.length, text.length); i++) {
188
199
  lines.push(` ${art[i] || ' '.repeat(w)} ${text[i] || ''}`);
@@ -254,7 +265,108 @@ export function ensureHelpBinding(d) {
254
265
  return r && r.status === 0 ? 'bound' : 'failed';
255
266
  }
256
267
 
257
- export function runGoverned({ program = 'claude', name, args = [], resume = false, noTmux = false, bin, preattach }, over) {
268
+ /**
269
+ * SESSION-LAUNCH-DEDUPE: tmux session names are case-sensitive but conversation names are not, so
270
+ * `--resume quoxon` must find `quox-QUOXON`. Returns the real session name or null.
271
+ */
272
+ export function findSessionCI(d, wanted) {
273
+ const r = d.spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' });
274
+ if (!r || r.error || r.status !== 0) return null;
275
+ const w = String(wanted).toLowerCase();
276
+ const names = String(r.stdout || '').split('\n').map((l) => l.split('\t')[0].trim()).filter(Boolean);
277
+ return names.find((n) => n.toLowerCase() === w) || null;
278
+ }
279
+
280
+ const PS_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/;
281
+ const base = (t) => String(t || '').split('/').pop();
282
+
283
+ /** True when argv is a claude process resuming `name` (case-insensitive), also via `node .../claude`. */
284
+ export function resumesName(args, name) {
285
+ const t = String(args).trim().split(/\s+/);
286
+ let i = 0;
287
+ if (base(t[0]) === 'node' && base(t[1]) === 'claude') i = 1;
288
+ else if (base(t[0]) !== 'claude') return false;
289
+ const want = String(name).toLowerCase();
290
+ for (let k = i + 1; k < t.length; k++) {
291
+ if ((t[k] === '--resume' || t[k] === '-r') && String(t[k + 1] || '').toLowerCase() === want) return true;
292
+ if (t[k].startsWith('--resume=') && t[k].slice(9).toLowerCase() === want) return true;
293
+ }
294
+ return false;
295
+ }
296
+
297
+ /** Live claude processes already holding conversation `name`: [{ pid, tty, tmux }]. */
298
+ export function findLiveHolders(d, name) {
299
+ const ps = d.spawnSync('ps', ['-eo', 'pid=,ppid=,tty=,args='], { encoding: 'utf8' });
300
+ if (!ps || ps.error || ps.status !== 0) return [];
301
+ const procs = new Map();
302
+ for (const line of String(ps.stdout || '').split('\n')) {
303
+ const m = PS_RE.exec(line);
304
+ if (m) procs.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], args: m[4] });
305
+ }
306
+ const self = d.pid ?? process.pid;
307
+ const hits = [...procs.values()].filter((p) => p.pid !== self && p.pid !== process.ppid && resumesName(p.args, name));
308
+ if (!hits.length) return [];
309
+ const panes = new Map();
310
+ const lp = d.spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #{session_name}'], { encoding: 'utf8' });
311
+ if (lp && !lp.error && lp.status === 0) {
312
+ for (const l of String(lp.stdout || '').split('\n')) {
313
+ const m = /^(\d+)\s+(\S+)$/.exec(l.trim());
314
+ if (m) panes.set(Number(m[1]), m[2]);
315
+ }
316
+ }
317
+ return hits.map((p) => {
318
+ let tmux = null;
319
+ for (let pid = p.pid, hops = 0; pid > 1 && hops < 32 && !tmux; hops++) { tmux = panes.get(pid) || null; pid = procs.get(pid)?.ppid ?? (hops === 0 ? p.ppid : 0); }
320
+ return { pid: p.pid, tty: p.tty, tmux };
321
+ });
322
+ }
323
+
324
+ /** The conversation name a claude argv resumes (`--resume X`, `-r X`, `--resume=X`), or null. */
325
+ function resumedName(args) {
326
+ const t = String(args).trim().split(/\s+/);
327
+ let i = 0;
328
+ if (base(t[0]) === 'node' && base(t[1]) === 'claude') i = 1;
329
+ else if (base(t[0]) !== 'claude') return null;
330
+ for (let k = i + 1; k < t.length; k++) {
331
+ if ((t[k] === '--resume' || t[k] === '-r') && t[k + 1]) return t[k + 1];
332
+ if (t[k].startsWith('--resume=') && t[k].length > 9) return t[k].slice(9);
333
+ }
334
+ return null;
335
+ }
336
+
337
+ const PS_AGE_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(.*)$/;
338
+
339
+ /**
340
+ * Every live `claude --resume <name>` on this box (same argv detection as findLiveHolders):
341
+ * [{ name, pid, tty, tmux, ageSec }]. tmux is the enclosing tmux session or null. [] if ps fails.
342
+ */
343
+ export function listLiveSessions(d) {
344
+ const ps = d.spawnSync('ps', ['-eo', 'pid=,ppid=,tty=,etimes=,args='], { encoding: 'utf8' });
345
+ if (!ps || ps.error || ps.status !== 0) return [];
346
+ const procs = new Map();
347
+ for (const line of String(ps.stdout || '').split('\n')) {
348
+ const m = PS_AGE_RE.exec(line);
349
+ if (m) procs.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], ageSec: Number(m[4]), args: m[5] });
350
+ }
351
+ const self = d.pid ?? process.pid;
352
+ const hits = [...procs.values()].filter((p) => p.pid !== self && resumedName(p.args));
353
+ if (!hits.length) return [];
354
+ const panes = new Map();
355
+ const lp = d.spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #{session_name}'], { encoding: 'utf8' });
356
+ if (lp && !lp.error && lp.status === 0) {
357
+ for (const l of String(lp.stdout || '').split('\n')) {
358
+ const m = /^(\d+)\s+(\S+)$/.exec(l.trim());
359
+ if (m) panes.set(Number(m[1]), m[2]);
360
+ }
361
+ }
362
+ return hits.map((p) => {
363
+ let tmux = null;
364
+ for (let pid = p.pid, hops = 0; pid > 1 && hops < 32 && !tmux; hops++) { tmux = panes.get(pid) || null; pid = procs.get(pid)?.ppid ?? (hops === 0 ? p.ppid : 0); }
365
+ return { name: resumedName(p.args), pid: p.pid, tty: p.tty, tmux, ageSec: p.ageSec };
366
+ });
367
+ }
368
+
369
+ export function runGoverned({ program = 'claude', name, args = [], resume = false, noTmux = false, force = false, bin, preattach }, over) {
258
370
  const d = defaults(over);
259
371
  const n = name || autoName(d.now());
260
372
  if (program === 'run') {
@@ -281,6 +393,24 @@ export function runGoverned({ program = 'claude', name, args = [], resume = fals
281
393
  return 1;
282
394
  }
283
395
  alive = r.status === 0;
396
+ if (!alive && resume && program === 'claude') {
397
+ const real = findSessionCI(d, PREFIX + full);
398
+ if (real) {
399
+ // Same conversation, different case: attach, never start a second Claude.
400
+ const attach = `tmux attach -t ${real}`;
401
+ if (!(d.isTTY ?? process.stdout.isTTY)) { d.write(`${n} is already running in tmux ${real}. Attach: ${attach}\n`); return 0; }
402
+ const a = d.spawnSync('tmux', ['attach-session', '-t', real], { stdio: 'inherit' });
403
+ return a && typeof a.status === 'number' ? a.status : 0;
404
+ }
405
+ }
406
+ }
407
+ if (!alive && resume && program === 'claude' && !force) {
408
+ const [h] = findLiveHolders(d, n);
409
+ if (h) {
410
+ const where = h.tmux ? `tmux ${h.tmux}` : `not in tmux on ${h.tty}`;
411
+ d.write(`${n} is already running (pid ${h.pid}, ${where}). ${h.tmux ? `Attach: tmux attach -t ${h.tmux}. ` : ''}To start a second copy anyway: --force.\n`);
412
+ return 1;
413
+ }
284
414
  }
285
415
  const plan = planLaunch({ name: n, args, env: d.env, alive, resume, noTmux, program, bin, preattach });
286
416
  const shown = program === 'run' ? bin : program;
@@ -41,13 +41,14 @@ export function humanUtc(iso) {
41
41
  export function resolutionFromItem(item, requestedAction) {
42
42
  if (!item || !item.id) return null;
43
43
  const response = item.response || null;
44
- const outcome = item.status === 'expired' ? 'expired' : item.status === 'cancelled' ? 'cancelled' : (outcomeOf(response) || 'resolved');
44
+ const outcome = item.status === 'expired' ? 'expired' : item.status === 'cancelled' ? 'cancelled' : item.status === 'withdrawn' ? 'withdrawn' : (outcomeOf(response) || 'resolved');
45
45
  const name = item.resolved_by_name || response?.responded_by_name || null;
46
46
  const at = toIso(item.resolved_at) || toIso(response?.responded_at) || null;
47
47
  const requested = String(requestedAction || '').trim().toLowerCase() || null;
48
- const matches = Boolean(requested) && outcome !== 'expired' && outcome !== 'cancelled' && normalise(requested) === outcome;
48
+ const matches = Boolean(requested) && outcome !== 'expired' && outcome !== 'cancelled' && outcome !== 'withdrawn' && normalise(requested) === outcome;
49
49
  let message;
50
50
  if (outcome === 'expired') message = `This approval expired at ${humanUtc(at)}; nothing was approved. Ask for it again.`;
51
+ else if (outcome === 'withdrawn') message = `This request was withdrawn at ${humanUtc(at)}${name ? ` by ${name}` : ''}; nothing was decided and nothing ran.`;
51
52
  else if (outcome === 'cancelled') message = `This item was cancelled at ${humanUtc(at)}${name ? ` by ${name}` : ''}; there is nothing to respond to.`;
52
53
  else {
53
54
  const head = `Already ${outcome}${name ? ` by ${name}` : ''} at ${humanUtc(at)}`;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Login guards shared by `quox login` and the TUI /login.
3
+ * - prefill comes ONLY from the active profile's own last_email
4
+ * - a non-default profile signing in as an email another profile already holds gets a y/N warning
5
+ * - a 429 says how long to wait (or that retrying may extend the limit)
6
+ */
7
+
8
+ /** The active profile's own last_email, or ''. Never another profile's, never a global. */
9
+ export function loginPrefillEmail(config) {
10
+ try { return String(config.getActiveProfile?.()?.last_email || '').trim(); } catch { return ''; }
11
+ }
12
+
13
+ /** A server URL reduced to scheme://host:port so https://H, https://h/ and https://h:443 compare equal. '' when not a URL. */
14
+ export function normalizeServerUrl(u) {
15
+ try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname.toLowerCase()}:${x.port || (x.protocol === 'https:' ? '443' : '80')}`; } catch { return ''; }
16
+ }
17
+
18
+ /**
19
+ * Name of ANOTHER profile currently signed in as `email` (case-insensitive), else null.
20
+ * H2: the same email on a DIFFERENT server is a different account. When `url` (the server this sign-in targets) is
21
+ * given and the other profile has its own server URL that differs, it does not count. A profile with no URL of its
22
+ * own is unknown, so it still counts (the safe side for an identity guard).
23
+ */
24
+ export function profileHoldingEmail(config, email, url = null) {
25
+ const want = String(email || '').trim().toLowerCase();
26
+ if (!want) return null;
27
+ const me = config.getActiveProfileName?.();
28
+ for (const [name, p] of Object.entries(config._data?.profiles || {})) {
29
+ if (name === me) continue;
30
+ if (String(p?.auth?.email || '').trim().toLowerCase() !== want) continue;
31
+ const mine = normalizeServerUrl(url), theirs = normalizeServerUrl(p?.url);
32
+ if (mine && theirs && mine !== theirs) continue;
33
+ return name;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** Only a non-default profile needs the warning: it is how agent-only profiles become the owner. */
39
+ export function isNonDefaultProfile(config) {
40
+ return Boolean(config._data?.activeProfile) && config.getActiveProfileName?.() !== config._data.activeProfile;
41
+ }
42
+
43
+ export function sameIdentityWarning(email, holder, profile) {
44
+ return `${email} is already signed in as profile '${holder}'. Sign profile '${profile}' in as the same user? y/N`;
45
+ }
46
+
47
+ /** Plain-language wait guidance for a rate-limited sign-in. */
48
+ export function rateLimitHint({ retryAfterMs } = {}) {
49
+ if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
50
+ const min = Math.max(1, Math.ceil(retryAfterMs / 60000));
51
+ return `Too many authentication attempts: try again in ~${min} min; each retry may extend the limit.`;
52
+ }
53
+ return 'Too many authentication attempts: wait a few minutes; retrying now may extend the limit.';
54
+ }
55
+
56
+ /** Retry-After (seconds or HTTP date) header and common body fields -> ms, else null. */
57
+ export function retryAfterMsFrom(res, data) {
58
+ const h = res?.headers?.get?.('retry-after');
59
+ if (h != null && h !== '') {
60
+ const n = Number(h);
61
+ if (Number.isFinite(n)) return n * 1000;
62
+ const t = Date.parse(h);
63
+ if (Number.isFinite(t)) return Math.max(0, t - Date.now());
64
+ }
65
+ const s = data?.retry_after ?? data?.retryAfter ?? data?.retry_after_seconds;
66
+ if (Number.isFinite(Number(s)) && s != null) return Number(s) * 1000;
67
+ const reset = data?.reset_at ?? data?.resetAt ?? data?.reset;
68
+ if (reset != null) {
69
+ const t = typeof reset === 'number' ? (reset < 1e12 ? reset * 1000 : reset) : Date.parse(reset);
70
+ if (Number.isFinite(t)) return Math.max(0, t - Date.now());
71
+ }
72
+ return null;
73
+ }