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/quoxon/api.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * QUOXON server calls. Thin wrapper over the CLI client so the app can be
3
3
  * driven by a fake (demo + tests). All routes per docs/QUOXON_EVENTS_v1.md.
4
4
  */
5
+ import { otherOrgsWaiting } from '../core/crossOrgApprovals.js';
5
6
  import { resolveEndpoint } from '../api/endpoints.js';
6
7
  import { rememberEmail } from '../core/auth.js';
7
8
  import { isSessionDead } from './sessionDead.js'; // not signin.js: that loads React + Ink (QUOXON-LATENCY)
@@ -9,7 +10,8 @@ import { buildStreamQuery } from '../commands/chat.js';
9
10
  import { loadBundledAssistantsRoster } from '../core/agentAlias.js';
10
11
  import { bundledIdentities } from './objects/fetch.js';
11
12
  import { recordHeldSlot } from '../commands/slot.js';
12
- import { askGripe, settleGripe } from '../commands/gripe.js';
13
+ import { askGripe, settleGripe, fileDeterministically } from '../commands/gripe.js';
14
+ import { gripeQueue, fileOrQueue, makeRetryFiler } from './gripeQueue.js';
13
15
  import { sendFeedback } from './feedback.js';
14
16
  import { fromObjective, fromExecution, fromApproval, sortRows } from './roster.js';
15
17
  import { normChoice } from './model.js';
@@ -21,6 +23,7 @@ import { createKillerApi } from './slash/killerApi.js';
21
23
  import { createUndoOps } from './slash/undoApi.js';
22
24
  import { resolveDashboardBase, resolveCollectorBase, detectLanIp } from './hostUrls.js';
23
25
  import { grantBody } from './consent.js';
26
+ import { runCallMe } from '../core/callMe.js';
24
27
  import { loadJourney } from './onboarding/journey.js';
25
28
  import { joinServiceUrl } from '../core/config.js';
26
29
  import { runVmCommand } from '../core/vmTui.js';
@@ -92,6 +95,7 @@ async function createNoteFrom(client, text) {
92
95
  } catch (e) {
93
96
  if (e?.status === 402) return { ok: false, message: 'NotesQ is not licensed for this org. ' + NOTES_OFF };
94
97
  if (e?.status === 403 || e?.status === 404 || e?.status === 503) return { ok: false, message: NOTES_OFF };
98
+ if (!e?.status && /timed out|timeout|ECONNRESET|socket hang up|network|fetch failed|ETIMEDOUT/i.test(e?.message || '')) return { ok: false, maybeSaved: true, message: 'The save may still have gone through (the server was slow). /notes shows your recent notes; I won\'t retry automatically so you don\'t get a duplicate.' }; // request was sent: the outcome is unknown, not a failure
95
99
  return { ok: false, message: `Could not save the note: ${e?.message || 'request failed'}${e?.status ? ` (HTTP ${e.status})` : ''}.` };
96
100
  }
97
101
  }
@@ -102,9 +106,10 @@ export function isInteractiveHuman({ stdin = process.stdin, stdout = process.std
102
106
  /** A one-line title within the tasks service's 200 character cap. */
103
107
  const clipTitle = (g) => { const t = String(g ?? '').replace(/\s+/g, ' ').trim(); return t.length > 200 ? `${t.slice(0, 199)}…` : t; };
104
108
 
105
- export function createApi({ client, config, auth, windowOrg = null, clientForOrg = null, reattachBackoff = REATTACH_BACKOFF_MS }) {
109
+ export function createApi({ client, config, auth, windowOrg = null, clientForOrg = null, reattachBackoff = REATTACH_BACKOFF_MS, gripeQueuePath = undefined, listSessions = null, awayRetryMs = 3000 }) {
106
110
  let holderSessionFn = null;
107
111
  const orgId = () => { try { return auth.getOrgId(); } catch { return null; } };
112
+ const gripeStore = gripeQueue(gripeQueuePath);
108
113
  const rowsStore = createRowsStore({ client, config, orgId });
109
114
  let credsSeen = null; // credentials-file mtime last seen (credentialsChanged)
110
115
  const userId = () => { try { return config.getAuth?.()?.user_id || null; } catch { return null; } };
@@ -161,12 +166,13 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
161
166
  err.code = 'reattach_failed';
162
167
  throw err;
163
168
  }
169
+ const HUMAN_TUI = { 'X-Quox-Human-Action': 'tui' }; // DND contract: only a human action in the TUI sends this, never a restart or internal path
164
170
  /** PRESENCE auto-clear: the human's own first real action. -> { ok, cleared, welcome } (welcome once: { away_text, via }). Never throws. */
165
171
  async function noteActivity(action, { quiet = false, interactive = isInteractiveHuman() } = {}) {
166
172
  if (!interactive) return { ok: false, cleared: false, welcome: null, skipped: 'not interactive' };
167
173
  try {
168
174
  const ep = resolveEndpoint('presence.activity');
169
- const d = await client.post(ep.path, { service: ep.service, retries: 0, timeout: 6000, body: { action, org_id: orgId(), ...(quiet ? { quiet: true } : {}) } });
175
+ const d = await client.post(ep.path, { service: ep.service, retries: 0, timeout: 6000, headers: HUMAN_TUI, body: { action, org_id: orgId(), ...(quiet ? { quiet: true } : {}) } });
170
176
  return { ok: true, cleared: d?.cleared === true, welcome: d?.welcome || null };
171
177
  } catch (e) { return { ok: false, cleared: false, welcome: null, status: e?.status || null }; }
172
178
  }
@@ -550,16 +556,23 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
550
556
  * file when the agent did not). The client asks the user first, so the agent is told to file without asking.
551
557
  * -> { ok:true, response, url, superseded, notes } | { ok:false, message }.
552
558
  */
553
- async gripe(text, { yes = true } = {}) {
554
- try {
555
- const options = { yes };
556
- const data = await askGripe({ client, complaint: text, yes, orgId: orgId() });
557
- const r = await settleGripe({ client, data, complaint: text, options, orgId: orgId() });
558
- return { ok: true, response: r.response, url: r.url, superseded: r.superseded, notes: r.notes };
559
- } catch (e) {
560
- return { ok: false, message: e?.status ? `HTTP ${e.status}: ${e.message}` : (e?.message || 'failed') };
561
- }
559
+ async gripe(text, { yes = true, repo = null } = {}) {
560
+ return fileOrQueue({
561
+ text, repo, queue: gripeStore,
562
+ run: async (key) => {
563
+ const options = { yes, idempotencyKey: key, queueRetryable: true, ...(repo ? { repo } : {}) };
564
+ const data = await askGripe({ client, complaint: text, yes, orgId: orgId() });
565
+ const r = await settleGripe({ client, data, complaint: text, options, orgId: orgId() });
566
+ return { response: r.response, url: r.url, superseded: r.superseded, notes: r.notes };
567
+ },
568
+ });
569
+ },
570
+ /** GRIPE-QUEUE: file what is queued (at most once per 2 minutes). -> { ran, filed:[{text,url}], expired:[{text}], pending }. */
571
+ async gripeRetry({ force = false } = {}) {
572
+ return gripeStore.retry(makeRetryFiler({ fileDeterministically, client, orgId: orgId() }), { force });
562
573
  },
574
+ /** GRIPE-QUEUE: queued plus recently filed gripes, oldest first. */
575
+ gripes() { return gripeStore.list(); },
563
576
  /**
564
577
  * /feedback: POST the previewed payload to the public quox.ai feedback API (not the org's server, not GitHub).
565
578
  * Anonymous by design: no token, cookie, org or profile goes with it. -> { ok:true, id, url } | { ok:false, message }.
@@ -581,6 +594,8 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
581
594
  async todoList() { return listBoard(client); },
582
595
  /** /todo move: move one item (real id) to todo, doing or done. -> the item. */
583
596
  async todoMove(id, status) { return moveActionItem(client, id, status); },
597
+ /** /callmeforapprovals: { action, after, quiet } -> { data, changed } (same core as `quox approvals call-me`). Throws on failure. */
598
+ async callMe(args) { return runCallMe(client, args); },
584
599
  /** -> [{ plugin_id, version, is_enabled, ... }] every plugin installed for the org (GET plugins.list, as `quox plugin list`). Throws on failure. */
585
600
  async pluginList() {
586
601
  const id = orgId();
@@ -617,6 +632,35 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
617
632
  * ("quox1 (Adam)"); `fast` answers once the line is in the slot, without waiting on the chat-app bridges.
618
633
  * -> { ok:true, message } | { ok:false, message, status }.
619
634
  */
635
+ /**
636
+ * Does this slot name exist in one of the user's OWN orgs? Reads only, never sends. Same rules as slotSay: name@org
637
+ * targets one membership; two other orgs holding the name is { found:true, ambiguous:true } (the send path then asks).
638
+ * -> { found:boolean, ambiguous?:true }. Throws when the membership list itself cannot be read.
639
+ */
640
+ async slotLocate(name) {
641
+ const slug = (x) => String(x || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
642
+ const clientFor = async (oid) => (oid === orgId() ? client : clientForOrg(oid));
643
+ const listed = async (oid, nm) => {
644
+ const ep = resolveEndpoint('v1.slots.list');
645
+ const rows = listOf(await (await clientFor(oid)).get(ep.path, { service: ep.service, retries: 0 }), 'slots') || [];
646
+ return rows.some(r => String(r?.name || '').toLowerCase() === String(nm).toLowerCase());
647
+ };
648
+ const oe = resolveEndpoint('orgs.list'); const d = await client.get(oe.path, { service: oe.service });
649
+ const orgs = (Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || [])).filter(o => o?.id).map(o => ({ id: o.id, name: o.name || o.id }));
650
+ const at = String(name).indexOf('@');
651
+ if (at > 0) {
652
+ const raw = String(name).slice(at + 1); const frag = slug(raw);
653
+ const hit = frag ? orgs.filter(o => o.id === raw || slug(o.name) === frag) : [];
654
+ const pick = hit.length ? hit : orgs.filter(o => frag && slug(o.name).startsWith(frag));
655
+ if (pick.length !== 1 || !clientForOrg) return { found: false };
656
+ return { found: await listed(pick[0].id, String(name).slice(0, at)) };
657
+ }
658
+ if (await listed(orgId(), name)) return { found: true };
659
+ if (!clientForOrg) return { found: false };
660
+ const hits = [];
661
+ for (const o of orgs) { if (o.id === orgId()) continue; try { if (await listed(o.id, name)) hits.push(o); } catch { /* an org that cannot be listed is not a candidate */ } }
662
+ return hits.length > 1 ? { found: true, ambiguous: true } : { found: hits.length === 1 };
663
+ },
620
664
  /** SLOTS-REPLY2: the window registers how to name its checkout session; slotSay then sends it as holder_session. */
621
665
  bindHolderSession(fn) { holderSessionFn = typeof fn === 'function' ? fn : null; },
622
666
  async slotSay(name, text, { from = null, fast = false, roomSession = null, fromSlot = null, holderSession = holderSessionFn ? holderSessionFn() : null, anyOrg = true } = {}) {
@@ -630,33 +674,66 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
630
674
  if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
631
675
  return { ok: false, message: e?.status === 404 ? `no slot named ${name}` : (e?.message || 'failed'), status: e?.status || null };
632
676
  };
677
+ // The user's own memberships (orgs.list is scoped to the caller); nothing outside this list is ever tried.
678
+ const memberOrgs = async () => {
679
+ try {
680
+ const oe = resolveEndpoint('orgs.list'); const d = await client.get(oe.path, { service: oe.service });
681
+ return (Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || [])).filter(o => o?.id).map(o => ({ id: o.id, name: o.name || o.id }));
682
+ } catch { return []; }
683
+ };
684
+ const slug = (x) => String(x || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
685
+ const clientFor = async (oid) => (oid === orgId() ? client : clientForOrg(oid));
686
+ const at = String(name).indexOf('@');
687
+ if (at > 0) { // name@org: exactly that membership, never the others
688
+ const base = String(name).slice(0, at); const frag = slug(String(name).slice(at + 1));
689
+ const orgs = anyOrg && clientForOrg ? await memberOrgs() : [];
690
+ const hit = frag ? orgs.filter(o => o.id === String(name).slice(at + 1) || slug(o.name) === frag) : [];
691
+ const pick = hit.length ? hit : orgs.filter(o => frag && slug(o.name).startsWith(frag));
692
+ if (pick.length !== 1) return { ok: false, status: 404, message: pick.length ? `"${name}" matches several of your orgs: ${pick.map(o => `${base}@${slug(o.name)}`).join(', ')}` : `no slot named ${name}` };
693
+ try { const r = await send(await clientFor(pick[0].id), pick[0].id); return { ...r, viaOrgId: pick[0].id, viaOrgName: pick[0].name }; } catch (e) { return { ...fail(e), viaOrgId: pick[0].id, viaOrgName: pick[0].name }; }
694
+ }
633
695
  let first;
634
696
  try { return await send(client, orgId()); } catch (e) { first = fail(e); }
635
697
  // HITL-INBOX-BAR: a window in another org cannot see a slot that lives in the profile's home org. Same user, same
636
- // sessions (not a cross-tenant leak): try the home org, then each of the user's orgs, each on a token for THAT org.
698
+ // sessions (not a cross-tenant leak): look in the user's other orgs only, each on a token for THAT org.
637
699
  if (!anyOrg || first.status !== 404 || !clientForOrg) return first;
638
700
  const tried = new Set([orgId()]);
639
- const order = [];
640
- try { const home = config.getAuth?.()?.org_id; if (home) order.push(home); } catch { /* no home org known */ }
641
- try {
642
- const oe = resolveEndpoint('orgs.list'); const d = await client.get(oe.path, { service: oe.service });
643
- for (const o of (Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || []))) if (o?.id) order.push(o.id);
644
- } catch { /* the home org alone */ }
701
+ const orgs = await memberOrgs();
702
+ const nameOf = (oid) => orgs.find(o => o.id === oid)?.name || null;
703
+ // 1. Find it without sending: list slots per other org. Two orgs hold the name: ask, never pick.
704
+ const hits = [];
705
+ for (const o of orgs) {
706
+ if (tried.has(o.id)) continue;
707
+ try {
708
+ const ep = resolveEndpoint('v1.slots.list');
709
+ const rows = listOf(await (await clientFor(o.id)).get(ep.path, { service: ep.service, retries: 0 }), 'slots') || [];
710
+ if (rows.some(r => String(r?.name || '').toLowerCase() === String(name).toLowerCase())) hits.push(o);
711
+ } catch { /* an org that cannot be listed is not a candidate here; the probe below still covers it */ }
712
+ }
713
+ if (hits.length > 1) {
714
+ const choices = hits.map(o => ({ orgId: o.id, orgName: o.name, ref: `${name}@${slug(o.name)}` }));
715
+ return { ok: false, ambiguous: true, choices, status: null, message: `"${name}" exists in more than one of your orgs: ${choices.map(c => `${c.ref} (${c.orgName})`).join(', ')}. Say which, e.g. /say ${choices[0].ref} ...` };
716
+ }
717
+ const order = hits.map(o => o.id);
718
+ if (!order.length) { // nothing listed it: fall back to trying the home org, then each membership
719
+ try { const home = config.getAuth?.()?.org_id; if (home) order.push(home); } catch { /* no home org known */ }
720
+ for (const o of orgs) order.push(o.id);
721
+ }
645
722
  for (const oid of order) {
646
723
  if (tried.has(oid)) continue; tried.add(oid);
647
724
  try {
648
- const cl = await clientForOrg(oid);
725
+ const cl = await clientFor(oid);
649
726
  const r = await send(cl, oid);
650
- return { ...r, viaOrgId: oid };
651
- } catch (e) { const f = fail(e); if (f.status !== 404) return { ...f, viaOrgId: oid }; } // the slot is there but refused: that is the real answer
727
+ return { ...r, viaOrgId: oid, viaOrgName: nameOf(oid) };
728
+ } catch (e) { const f = fail(e); if (f.status !== 404) return { ...f, viaOrgId: oid, viaOrgName: nameOf(oid) }; } // the slot is there but refused: that is the real answer
652
729
  }
653
730
  return first;
654
731
  },
655
732
  /** QUOXON-BROADCAST: fan a message out to the org's held slots. -> { ok:true, data } | { ok:false, message, status }. */
656
- async slotBroadcast({ text, slots = null, priority = 'normal', ack = false, from = null } = {}) {
733
+ async slotBroadcast({ text, slots = null, priority = 'normal', ack = false, active = false, dryRun = false, from = null } = {}) {
657
734
  const ep = resolveEndpoint('v1.slots.broadcast');
658
735
  try {
659
- const data = await client.post(ep.path, { service: ep.service, retries: 0, body: { text, priority, ack, org_id: orgId(), ...(slots ? { slots } : {}), ...(from ? { from } : {}) } });
736
+ const data = await client.post(ep.path, { service: ep.service, retries: 0, body: { text, priority, ack, ...(active ? { active: true } : {}), ...(dryRun ? { dry_run: true } : {}), org_id: orgId(), ...(slots ? { slots } : {}), ...(from ? { from } : {}) } });
660
737
  return { ok: true, data };
661
738
  } catch (e) {
662
739
  if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
@@ -859,8 +936,26 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
859
936
  return l.map(e => fromExecution(e, now, { userId: userId() })).filter(Boolean);
860
937
  })
861
938
  ]);
939
+ // Attached agents (org list, owner/admin) and this box's live `claude --resume` processes. Neither is a work row:
940
+ // they ride on the source as `items` and /roster prints them (rosterSessions.js).
941
+ const itemsSrc = (id, label, fn) => fn().then(
942
+ (items) => ({ id, label, state: 'connected', rows: [], items, count: items.length }),
943
+ (e) => ({ id, label, rows: [], items: [], count: 0, state: missing(e) ? 'unavailable' : 'error', note: missing(e) ? 'no list endpoint on this server' : (e?.message || 'failed') }));
944
+ const extra = await Promise.all([
945
+ itemsSrc('agents', 'attached agents', async () => {
946
+ const ep = resolveEndpoint('agent-attach.list');
947
+ const d = await client.get(ep.path, { service: ep.service, retries: 0 });
948
+ const l = listOf(d, 'attaches'); if (!l) throw new Error('unexpected response');
949
+ return l;
950
+ }),
951
+ itemsSrc('boxsessions', "this box's sessions", async () => {
952
+ if (listSessions) return await listSessions();
953
+ const [{ listLiveSessions }, { spawnSync }] = await Promise.all([import('../core/governedSession.js'), import('node:child_process')]);
954
+ return listLiveSessions({ spawnSync, pid: process.pid });
955
+ })
956
+ ]);
862
957
  const unlisted = [
863
- { id: 'agents', label: 'attached agents', state: 'unavailable', count: 0, note: 'no org-wide list endpoint' },
958
+ ...extra.map(({ rows, ...rest }) => rest),
864
959
  { id: 'devsessions', label: 'dev sessions', state: 'unavailable', count: 0, note: 'listed per host only (quox dev sessions <host>)' }
865
960
  ];
866
961
  return { rows: sortRows(parts.flatMap(p => p.rows)), sources: [...parts.map(({ rows, ...rest }) => rest), ...unlisted] };
@@ -896,18 +991,31 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
896
991
  return { situation, approvals };
897
992
  },
898
993
  /** PRESENCE: PUT /api/v1/presence (/afk, /sleep, /back, a typed turn). -> { ok:true, presence, notice } | { ok:false, message, status }. */
899
- async setPresence(state, { note, slots } = {}) {
994
+ async setPresence(state, { note, slots, dnd = false, human = false } = {}) {
900
995
  const ep = resolveEndpoint('presence.set');
901
996
  try {
902
- const d = await client.put(ep.path, { service: ep.service, retries: 0, timeout: 8000, body: { state, org_id: orgId(), ...(note ? { note } : {}), ...(Array.isArray(slots) && slots.length ? { slots } : {}) } });
997
+ const d = await client.put(ep.path, { service: ep.service, retries: 0, timeout: 8000, ...(human ? { headers: HUMAN_TUI } : {}), body: { state, org_id: orgId(), ...(dnd ? { dnd: true } : {}), ...(note ? { note } : {}), ...(Array.isArray(slots) && slots.length ? { slots } : {}) } });
903
998
  return { ok: true, presence: d?.presence || null, notice: d?.notice || null };
904
999
  } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
905
1000
  },
1001
+ /** AFK-SURVIVES-RESTART: read-only GET /api/v1/presence/me. -> { ok:true, presence } | { ok:false, status }. Never throws, never writes. */
1002
+ async getPresence() {
1003
+ try {
1004
+ const ep = resolveEndpoint('presence.get', { who: 'me' });
1005
+ const d = await client.get(ep.path, { service: ep.service, query: { org_id: orgId() }, retries: 0, timeout: 6000 });
1006
+ return { ok: true, presence: d?.presence || null };
1007
+ } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
1008
+ },
906
1009
  noteActivity,
907
1010
  /** E5 /away: GET /api/v1/away?since=<iso> -> { since, until, items, activity, blind }. Throws on non-2xx. */
908
1011
  async away(since) {
909
1012
  const ep = resolveEndpoint('away.digest');
910
- return client.get(ep.path, { service: ep.service, query: { since }, retries: 0, timeout: 15000 });
1013
+ const read = () => client.get(ep.path, { service: ep.service, query: { since }, retries: 0, timeout: 15000 });
1014
+ const first = await read();
1015
+ // AWAY-RETRY: the first calls after a fresh sign-in can time out; try those sources once more before saying "Can't see".
1016
+ if (!(first?.blind || []).some((b) => /timed? ?out|timeout/i.test(String(b?.reason || '')))) return first;
1017
+ await new Promise((r) => setTimeout(r, awayRetryMs));
1018
+ try { return (await read()) || first; } catch { return first; } // the retry also failing leaves the honest first answer
911
1019
  },
912
1020
  /** Facts for /observers, each part on its own: { lucid, learning:[{ id, label, state, text }] }. */
913
1021
  async observers() {
@@ -1074,12 +1182,42 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
1074
1182
  async hyperion() { const data = await fetchActivity(client); return { lines: heldLines(data), tone: data.nodes_error || data.pool_error ? 'warn' : undefined }; },
1075
1183
  approvals: listApprovals,
1076
1184
  /** HITL-INBOX-BAR: the owner's latest inbox items (any kind, active), newest first. Throws on failure, never reads as empty. */
1077
- async inboxItems({ limit = 30 } = {}) {
1185
+ async inboxItems({ limit = 30, page = 1 } = {}) {
1078
1186
  const ep = resolveEndpoint('inbox.list', { orgId: orgId() });
1079
- const data = await client.get(ep.path, { service: ep.service, query: { limit } });
1187
+ const data = await client.get(ep.path, { service: ep.service, query: page > 1 ? { limit, page } : { limit } });
1080
1188
  const items = data?.items || data?.data || data || [];
1081
1189
  return Array.isArray(items) ? items : [];
1082
1190
  },
1191
+ /** TUI-MAIL: connected InboxQ mailboxes (GET /api/v1/inboxq/accounts, the `quox mail accounts` read). Throws on failure, never reads as none. */
1192
+ async mailAccounts() {
1193
+ const ep = resolveEndpoint('inboxq.accounts.list');
1194
+ const data = await client.get(ep.path, { service: ep.service, retries: 0 });
1195
+ return Array.isArray(data?.accounts) ? data.accounts : [];
1196
+ },
1197
+ /** TUI-MAIL: triaged messages (GET /api/v1/inboxq/messages, the `quox mail list` read). Throws on failure. */
1198
+ async mailMessages({ limit = 10, zone } = {}) {
1199
+ const ep = resolveEndpoint('inboxq.messages.list');
1200
+ const data = await client.get(ep.path, { service: ep.service, retries: 0, query: { limit, offset: 0, ...(zone ? { zone } : {}) } });
1201
+ return Array.isArray(data?.messages) ? data.messages : [];
1202
+ },
1203
+ /** TUI-MAIL: one message with its stored body (GET /api/v1/inboxq/messages/:id, read only: no body fetch from the mailbox). */
1204
+ async mailMessage(id) {
1205
+ const ep = resolveEndpoint('inboxq.messages.get', { id });
1206
+ const data = await client.get(ep.path, { service: ep.service, retries: 0 });
1207
+ return data?.message || null;
1208
+ },
1209
+ /** Every inbox item, paged (100 per page, capped at maxPages). Throws on failure, never returns a partial list as the whole. */
1210
+ async inboxAllItems({ pageSize = 100, maxPages = 20 } = {}) {
1211
+ const ep = resolveEndpoint('inbox.list', { orgId: orgId() });
1212
+ const all = [];
1213
+ for (let page = 1; page <= maxPages; page++) {
1214
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: pageSize, page } });
1215
+ const items = data?.items || data?.data || (Array.isArray(data) ? data : []);
1216
+ all.push(...items);
1217
+ if (!(data?.pagination?.has_more) || !items.length) break;
1218
+ }
1219
+ return all;
1220
+ },
1083
1221
  /** One inbox item in full (the server marks it read for this user as a side effect). */
1084
1222
  async inboxItem(itemId) {
1085
1223
  const ep = resolveEndpoint('inbox.get', { orgId: orgId(), itemId: encodeURIComponent(itemId) });
@@ -1097,6 +1235,14 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
1097
1235
  const d = await client.get(ep.path, { service: ep.service });
1098
1236
  return { pending: Number(d?.pending_approvals ?? 0), awaiting: Number(d?.awaiting_human ?? 0) };
1099
1237
  },
1238
+ /** Approvals waiting in the user's OTHER orgs: counts only, per-org stats on that org's own token (core/crossOrgApprovals). */
1239
+ async otherOrgApprovals() { return otherOrgsWaiting({ client, currentOrgId: orgId() }); },
1240
+ /** APPROVALS-AUTO: another org's open approvals, read with the caller's OWN token (auth checks membership of the URL org, not the token's org claim). */
1241
+ async approvalsInOrg(org) {
1242
+ const ep = resolveEndpoint('inbox.approvals.open', { orgId: org });
1243
+ const data = await client.get(ep.path, { service: ep.service, retries: 0 });
1244
+ return Array.isArray(data?.approvals) ? data.approvals.map((a) => a.item).filter(Boolean) : [];
1245
+ },
1100
1246
  /** GET /api/v1/approvals/:id/result -> { status, result, executed_at } ; null when unknown (404) */
1101
1247
  async approvalResult(approvalId) {
1102
1248
  const ep = resolveEndpoint('v1.approvals.result', { id: approvalId });
@@ -1104,14 +1250,14 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
1104
1250
  catch (e) { if (e?.status === 404) return null; throw e; }
1105
1251
  },
1106
1252
  /** quox ask: answer a decision with one of its option keys (and an optional note). */
1107
- async answerDecision(itemId, option, { note = null, stepUp = null, bulk = false } = {}) {
1108
- const ep = resolveEndpoint('inbox.respond', { orgId: orgId(), itemId });
1253
+ async answerDecision(itemId, option, { note = null, stepUp = null, bulk = false, org = null } = {}) {
1254
+ const ep = resolveEndpoint('inbox.respond', { orgId: org || orgId(), itemId }); // org: APPROVALS-AUTO answers another org's card by its URL org (the server checks membership of THAT org)
1109
1255
  const r = await client.post(ep.path, { service: ep.service, headers: stepUp || {}, body: { action: 'answer', option, ...(bulk ? { bulk: true } : {}), ...(note ? { comment: String(note).slice(0, 4000) } : {}) } });
1110
1256
  if (stepUp && Object.keys(stepUp).length) noteActivity('answer', { quiet: true }); // PRESENCE: answering is the owner being back
1111
1257
  return r;
1112
1258
  },
1113
- async respond(itemId, action, { via = null, stepUp = null, reason = null, bulk = false } = {}) {
1114
- const ep = resolveEndpoint('inbox.respond', { orgId: orgId(), itemId });
1259
+ async respond(itemId, action, { via = null, stepUp = null, reason = null, bulk = false, org = null } = {}) {
1260
+ const ep = resolveEndpoint('inbox.respond', { orgId: org || orgId(), itemId });
1115
1261
  const tag = via ? `via quoxon ${via}` : 'via quoxon';
1116
1262
  const why = reason ? String(reason).trim().slice(0, 4000) : '';
1117
1263
  const r = await client.post(ep.path, { service: ep.service, headers: stepUp || {}, body: { action, ...(bulk ? { bulk: true } : {}), comment: why ? `${why} (${tag})` : tag, ...(why ? { reason: why } : {}) } });
@@ -1129,6 +1275,11 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
1129
1275
  const ep = resolveEndpoint('stepup.status');
1130
1276
  return client.get(ep.path, { service: ep.service });
1131
1277
  },
1278
+ /** /verify-email: the SAME endpoint `quox admin users verify-email` calls. The server's checks are the only gate; the caller supplies a fresh one-shot step-up token. */
1279
+ async verifyEmail({ email, reason, userId = null, stepUp = {} }) {
1280
+ const ep = resolveEndpoint('admin.users.verify_email');
1281
+ return client.post(ep.path, { service: ep.service, headers: stepUp, body: { email, reason, ...(userId ? { user_id: userId } : {}) } });
1282
+ },
1132
1283
  /** QUOXON-STEPUP part B: override ONE blocked action. Needs a live override-scoped step-up token. */
1133
1284
  async override({ blockId, blockType, reason, agentId = null, action = null, stepUp = {} }) {
1134
1285
  const ep = resolveEndpoint('safety.override');