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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher): which one, whether to show it, and where its art lives.
2
+ * Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): which one, whether to show it, and where its art lives.
3
3
  * Pure. The art, animation and activity reducer stay in quoxai/dragonlab (integration/companions-v1);
4
4
  * Quox only chooses and loads. See docs/dragons/COMPANION-UX.md for the decisions behind each rule.
5
5
  */
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  export const VENDORED_DRAGONLAB = fileURLToPath(new URL('../../vendor/dragonlab', import.meta.url));
11
11
 
12
12
  /** Registry agent ids (and display names) to approved dragon. Others, including quox/commanderq, get none. */
13
- export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher' });
13
+ export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher', vulcan: 'vulcan', forge: 'vulcan', quoxperticus: 'quoxperticus' });
14
14
  export const COMPANION_MODES = ['on', 'greeting', 'off'];
15
15
  /** One-line change: the owner decides this after his Mac check (docs/dragons/COMPANION-UX.md section 4). */
16
16
  export const DEFAULT_COMPANION_MODE = 'on';
@@ -1,23 +1,146 @@
1
- # quoxon extensions
1
+ # quoxon extensions: contract v1
2
2
 
3
- Optional npm packages that add a surface to quoxon (a MIDI pad deck, a status bar). Core ships none and knows none by name.
3
+ Optional npm packages that add a surface to quoxon (a MIDI pad deck, a status bar, a fleet board). Core ships none and knows none by name.
4
+ An extension can OPEN an approval or PRESELECT an answer. It can never resolve one, and it never polls Quox: data is pushed to it.
4
5
 
5
- **Loading.** Nothing loads unless the package is installed AND listed in `~/.quox/extensions.json`
6
+ ## Loading
7
+
8
+ Nothing loads unless the package is installed AND listed in `~/.quox/extensions.json`
6
9
  (`quox ext add|remove|list <pkg>`; `/ext list|on|off <name>` inside quoxon). Install with `npm i -g <pkg>`.
10
+ Set env `QUOX_EXT_LIST=<path>` to use another list file (default `~/.quox/extensions.json`), for `quox ext` and quoxon alike (dev and tests).
7
11
  Resolution order: env `QUOX_EXT_<NAME>_MODULE` (name upper-cased, non-alphanumerics to `_`), a local `import(name)`,
8
- then the global npm root (`dirname(execPath)/../lib/node_modules` first, then `npm root -g` once, cached).
9
- `/ext list` names where each was found; a miss lists where it looked.
10
-
11
- **Module.** Default export (or the module itself):
12
- `{ name, version, commands?: [{ name: '/deck', help, run(args, ctx) }], create({ emit, log, settings }) -> { update(state), close() } }`.
13
- Its slash commands exist only while it is loaded. `run` may return a string or `{ text, tone }`.
14
-
15
- **State** (`update(state)`, deduped, at most 1/s):
16
- `{ org:{id,name}, approvals:{count, items:[{n,id,risk:'low'|'high',title,agent,options?:[{key,label}]}]}, health:{<name>:'ok'|'warn'|'down'},
17
- loops:[{id,title,turn,max,status}], fleet:{total,online,offline,warn,hosts:[{name,state:'ok'|'warn'|'down'}] (max 16)},
18
- evidence:{chain:'ok'|'broken'|'unknown', checked_at}, sessions:{active}, event?:{kind,text,level} }`.
19
- `fleet` comes from a 30 s poll of the host list while an extension is loaded. `evidence.chain` is `unknown` until quoxon holds a chain status.
20
-
21
- **emit.** `{type:'open_approval', n}` opens approval n; `{type:'pick', n, option}` only fills the composer with the
22
- answer command (the user presses Enter, high risk still needs the step-up PIN); `{type:'alert_ack'}` clears the event.
23
- An extension can never resolve an approval. emit is ignored once the extension is unloaded.
12
+ then the global npm root. `/ext list` names where each was found; a miss lists where it looked.
13
+
14
+ **contractVersion** (integer, this document is `1`). The host refuses an extension whose `contractVersion` is above what it supports,
15
+ with a line in `/ext list`: `needs extension contract v2, this quoxon supports v1`. An extension with no `contractVersion` is a
16
+ pre-contract (legacy) extension: it loads, and `emit` may use `n` without `id` (see F2). Declare `contractVersion: 1` to get the strict rules.
17
+
18
+ ## Module
19
+
20
+ Default export (or the module itself):
21
+
22
+ ```ts
23
+ interface Extension {
24
+ name: string; version?: string;
25
+ contractVersion?: 1;
26
+ commands?: Command[];
27
+ settings?: SettingDef[];
28
+ create(ctx: { emit(msg: Emit): boolean; log(text: string): void; settings: Record<string, string|number|boolean>; invalidate(): void }): Instance;
29
+ }
30
+ interface Instance {
31
+ update?(state: State): void; // deduped; see Freshness
32
+ close?(): void | Promise<void>; // see Close
33
+ panel?: Panel; // see Panels (one panel per extension)
34
+ onSettings?(next: Record<string, string|number|boolean>): void;
35
+ onEmitResult?(r: { ok: boolean; reason?: string; msg: Emit }): void;
36
+ }
37
+ ```
38
+
39
+ `panel`, `onSettings` and `onEmitResult` live on the object `create()` returns. There is no module-level `onSettings`.
40
+
41
+ ## Commands
42
+
43
+ ```ts
44
+ interface Command {
45
+ name: `/${string}`; help?: string;
46
+ subcommands?: { name: string; help?: string }[]; // feed /help and Tab completion
47
+ run(args: string[], ctx: { raw: string; say(text: string, tone?: 'ok'|'warn'|'accent'|'dim'): void; extension: string; loaded: string[] }): string | { text: string; tone?: string } | Promise<...>;
48
+ }
49
+ ```
50
+
51
+ `args` is the text after the command split on whitespace; `ctx.raw` is the same text unsplit. Commands exist only while the extension is loaded.
52
+ A thrown error is printed as `<cmd>: <message>`, never crashes the window.
53
+
54
+ ## State
55
+
56
+ `update(state)` is deduped (a push identical except `at` is dropped) and sent at most once per second.
57
+
58
+ ```ts
59
+ interface State {
60
+ at: string; // ISO time of this push
61
+ stale?: true; // present only when the host's last successful server read is over 60 s old
62
+ org: { id: string|null; name: string|null };
63
+ approvals: { count: number; items: { n: number; id: string|null; risk: 'low'|'high'; title: string; agent: string|null; options?: { key: string; label: string }[] }[] };
64
+ health: Record<string, 'ok'|'warn'|'down'>;
65
+ loops: { id: string; title: string; turn: number; max: number; status: string }[];
66
+ fleet: { total: number; online: number; offline: number; warn: number; hosts: { name: string; state: 'ok'|'warn'|'down' }[] }; // hosts max 16, may be empty
67
+ evidence: { chain: 'ok'|'broken'|'unknown'; checked_at: string|null }; // PLACEHOLDER: always 'unknown' until quoxon holds a chain status
68
+ sessions: { active: number };
69
+ event?: { seq: number; id?: string; kind: string; text: string; level: 'info'|'ok'|'warn'|'error' };
70
+ }
71
+ ```
72
+
73
+ **Freshness.** `state.at` is set on every push. While any extension is loaded the host pushes a heartbeat every 15 s, even when nothing
74
+ changed, so `at` keeps moving. `stale: true` means the data may be old (the server was not read for over 60 s): show it as such.
75
+
76
+ **Event ids.** `event.seq` is a positive integer, monotonic within one quoxon window session and never reused; a new event gets the next number,
77
+ the seq changes only when `kind`, `text`, `level` or `id` changes: a repeat of the held event keeps its seq until `alert_ack`. `event.id` appears only when the server supplied one.
78
+
79
+ `fleet` comes from a 30 s poll of the host list while an extension is loaded.
80
+
81
+ ## emit
82
+
83
+ ```ts
84
+ type Emit =
85
+ | { type: 'open_approval'; id: string; n?: number }
86
+ | { type: 'pick'; id: string; n?: number; option: string }
87
+ | { type: 'alert_ack' };
88
+ ```
89
+
90
+ `id` (from `approvals.items[].id`) is REQUIRED for `open_approval` and `pick`. `n` is display-only; when `id` and `n` disagree, `id` wins
91
+ (logged). An unknown, missing or already-resolved `id` is refused through `onEmitResult({ ok:false, reason, msg })`, and is never retargeted
92
+ to another item. `pick` only fills the composer with the answer command: the user presses Enter, high risk still needs the step-up PIN.
93
+ `alert_ack` clears the event. emit returns `true` when accepted and is ignored once the extension is unloaded.
94
+ (Legacy extensions without `contractVersion` may omit `id` and use `n`.)
95
+
96
+ ## Panels and tabs
97
+
98
+ ```ts
99
+ interface Panel {
100
+ title: string;
101
+ render(a: { width: number; height: number; focused: boolean; theme: Theme }): string[]; // pure
102
+ onKey?(key: string): boolean; // true = consumed, see Keys
103
+ onFocus?(): void; onBlur?(): void;
104
+ }
105
+ ```
106
+
107
+ ```ts
108
+ interface Theme { name: 'dark'|'light'|'mono'; color: boolean; unicode: boolean }
109
+ ```
110
+
111
+ The host owns the tab strip, shown only when one or more panels exist. The panel region sits above the composer, height
112
+ `min(12, 40% of terminal rows)`, collapsed until a panel is focused or opened. `/ext tab <name>` focuses (and opens) a panel; `/ext tab off`
113
+ collapses; Ctrl+] cycles focus through the panels; Esc returns keys to the composer (the panel stays open). While focused, keys go to
114
+ `onKey` first; a key it does not consume is dropped. The panel never receives `escape` or `ctrl+]`: the host consumes both.
115
+
116
+ **Keys.** `onKey(key)` gets one of `up down left right enter tab backspace space home end pageup pagedown`, otherwise the single typed
117
+ character (`a`, `?`, `/`), or `ctrl+<c>` / `alt+<c>` for the modifier combinations the terminal reports. Pasted text arrives one
118
+ character at a time.
119
+
120
+ `render` must be pure and fast. The 50 ms budget is wall-clock per render call (`performance.now`). The host clips the result to `height` lines and `width` columns. A render that throws, or takes over 50 ms,
121
+ shows `panel error` in its place and is retried no sooner than 5 s later; it never crashes the window. The panel re-renders on every state
122
+ push and on `invalidate()` (passed to `create`, throttled to 4 per second).
123
+
124
+ ## Settings
125
+
126
+ ```ts
127
+ interface SettingDef { key: string; type: 'string'|'number'|'bool'|'enum'; default: string|number|boolean; help: string; choices?: string[] }
128
+ ```
129
+
130
+ Values live in `~/.quox/extensions.json` on the extension's entry as `settings: { <key>: value }`. `create({ settings })` receives the resolved
131
+ object (defaults overlaid with stored values). `/ext set <name> <key> <value>` validates by type (number: `Number()` and it must be a finite number; bool: `true|yes|on|1` or
132
+ `false|no|off|0`, case-insensitive, anything else is rejected with a message; enum: an exact match to one of `choices`; string: any text), stores it, and calls `onSettings(next)` on the running instance.
133
+ `/ext settings <name>` lists each key with its value, default and help. The extension must be loaded and listed.
134
+
135
+ ## Close
136
+
137
+ `close()` may return a promise. The host awaits it for at most 500 ms, then drops the instance anyway and logs it. It must be idempotent
138
+ (the host may call it twice) and must free every timer, interval, watcher, device and process listener, because hot reload calls it on each change.
139
+
140
+ ## Hot reload
141
+
142
+ While an extension is loaded, quoxon watches its `package.json` (`fs.watch`, 2 s debounce, plus a 30 s stat poll because global installs
143
+ replace directories). When the version or mtime changes it awaits `close()` on the old instance (500 ms cap), drops its commands and panel,
144
+ re-imports the entry with a cache-busting `?v=<version>-<mtime>`, and calls `create()` again, then prints `↻ <name> updated <old> -> <new>`.
145
+ Only the entry module is re-imported. A failed reload leaves the old instance closed, prints the error once, and shows `failed: <error>` in
146
+ `/ext list` until the next change. Watchers and timers are cleared on `/ext off`, unload and exit.
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
  }
@@ -453,6 +459,10 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
453
459
  await client.post(ep.path, { service: ep.service, body: grantBody(cur, provider), retries: 0 });
454
460
  return true;
455
461
  },
462
+ /** Record a contact opt-in as THIS person (POST /api/v1/commanderq/contactability/policy/grant; human-only, needs a lawful basis). */
463
+ async grantContactability(body) {
464
+ return client.post('/api/v1/commanderq/contactability/policy/grant', { service: 'collector', body, retries: 0 });
465
+ },
456
466
  /** The dashboard address to print (never localhost when a better one is known). */
457
467
  async dashboardBase() {
458
468
  const profileUrl = config.getActiveProfile?.()?.url || config.getBaseUrl();
@@ -550,16 +560,23 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
550
560
  * file when the agent did not). The client asks the user first, so the agent is told to file without asking.
551
561
  * -> { ok:true, response, url, superseded, notes } | { ok:false, message }.
552
562
  */
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
- }
563
+ async gripe(text, { yes = true, repo = null } = {}) {
564
+ return fileOrQueue({
565
+ text, repo, queue: gripeStore,
566
+ run: async (key) => {
567
+ const options = { yes, idempotencyKey: key, queueRetryable: true, ...(repo ? { repo } : {}) };
568
+ const data = await askGripe({ client, complaint: text, yes, orgId: orgId() });
569
+ const r = await settleGripe({ client, data, complaint: text, options, orgId: orgId() });
570
+ return { response: r.response, url: r.url, superseded: r.superseded, notes: r.notes };
571
+ },
572
+ });
562
573
  },
574
+ /** GRIPE-QUEUE: file what is queued (at most once per 2 minutes). -> { ran, filed:[{text,url}], expired:[{text}], pending }. */
575
+ async gripeRetry({ force = false } = {}) {
576
+ return gripeStore.retry(makeRetryFiler({ fileDeterministically, client, orgId: orgId() }), { force });
577
+ },
578
+ /** GRIPE-QUEUE: queued plus recently filed gripes, oldest first. */
579
+ gripes() { return gripeStore.list(); },
563
580
  /**
564
581
  * /feedback: POST the previewed payload to the public quox.ai feedback API (not the org's server, not GitHub).
565
582
  * Anonymous by design: no token, cookie, org or profile goes with it. -> { ok:true, id, url } | { ok:false, message }.
@@ -581,6 +598,8 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
581
598
  async todoList() { return listBoard(client); },
582
599
  /** /todo move: move one item (real id) to todo, doing or done. -> the item. */
583
600
  async todoMove(id, status) { return moveActionItem(client, id, status); },
601
+ /** /callmeforapprovals: { action, after, quiet } -> { data, changed } (same core as `quox approvals call-me`). Throws on failure. */
602
+ async callMe(args) { return runCallMe(client, args); },
584
603
  /** -> [{ plugin_id, version, is_enabled, ... }] every plugin installed for the org (GET plugins.list, as `quox plugin list`). Throws on failure. */
585
604
  async pluginList() {
586
605
  const id = orgId();
@@ -617,6 +636,35 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
617
636
  * ("quox1 (Adam)"); `fast` answers once the line is in the slot, without waiting on the chat-app bridges.
618
637
  * -> { ok:true, message } | { ok:false, message, status }.
619
638
  */
639
+ /**
640
+ * Does this slot name exist in one of the user's OWN orgs? Reads only, never sends. Same rules as slotSay: name@org
641
+ * targets one membership; two other orgs holding the name is { found:true, ambiguous:true } (the send path then asks).
642
+ * -> { found:boolean, ambiguous?:true }. Throws when the membership list itself cannot be read.
643
+ */
644
+ async slotLocate(name) {
645
+ const slug = (x) => String(x || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
646
+ const clientFor = async (oid) => (oid === orgId() ? client : clientForOrg(oid));
647
+ const listed = async (oid, nm) => {
648
+ const ep = resolveEndpoint('v1.slots.list');
649
+ const rows = listOf(await (await clientFor(oid)).get(ep.path, { service: ep.service, retries: 0 }), 'slots') || [];
650
+ return rows.some(r => String(r?.name || '').toLowerCase() === String(nm).toLowerCase());
651
+ };
652
+ const oe = resolveEndpoint('orgs.list'); const d = await client.get(oe.path, { service: oe.service });
653
+ 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 }));
654
+ const at = String(name).indexOf('@');
655
+ if (at > 0) {
656
+ const raw = String(name).slice(at + 1); const frag = slug(raw);
657
+ const hit = frag ? orgs.filter(o => o.id === raw || slug(o.name) === frag) : [];
658
+ const pick = hit.length ? hit : orgs.filter(o => frag && slug(o.name).startsWith(frag));
659
+ if (pick.length !== 1 || !clientForOrg) return { found: false };
660
+ return { found: await listed(pick[0].id, String(name).slice(0, at)) };
661
+ }
662
+ if (await listed(orgId(), name)) return { found: true };
663
+ if (!clientForOrg) return { found: false };
664
+ const hits = [];
665
+ 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 */ } }
666
+ return hits.length > 1 ? { found: true, ambiguous: true } : { found: hits.length === 1 };
667
+ },
620
668
  /** SLOTS-REPLY2: the window registers how to name its checkout session; slotSay then sends it as holder_session. */
621
669
  bindHolderSession(fn) { holderSessionFn = typeof fn === 'function' ? fn : null; },
622
670
  async slotSay(name, text, { from = null, fast = false, roomSession = null, fromSlot = null, holderSession = holderSessionFn ? holderSessionFn() : null, anyOrg = true } = {}) {
@@ -630,33 +678,66 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
630
678
  if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
631
679
  return { ok: false, message: e?.status === 404 ? `no slot named ${name}` : (e?.message || 'failed'), status: e?.status || null };
632
680
  };
681
+ // The user's own memberships (orgs.list is scoped to the caller); nothing outside this list is ever tried.
682
+ const memberOrgs = async () => {
683
+ try {
684
+ const oe = resolveEndpoint('orgs.list'); const d = await client.get(oe.path, { service: oe.service });
685
+ 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 }));
686
+ } catch { return []; }
687
+ };
688
+ const slug = (x) => String(x || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
689
+ const clientFor = async (oid) => (oid === orgId() ? client : clientForOrg(oid));
690
+ const at = String(name).indexOf('@');
691
+ if (at > 0) { // name@org: exactly that membership, never the others
692
+ const base = String(name).slice(0, at); const frag = slug(String(name).slice(at + 1));
693
+ const orgs = anyOrg && clientForOrg ? await memberOrgs() : [];
694
+ const hit = frag ? orgs.filter(o => o.id === String(name).slice(at + 1) || slug(o.name) === frag) : [];
695
+ const pick = hit.length ? hit : orgs.filter(o => frag && slug(o.name).startsWith(frag));
696
+ 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}` };
697
+ 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 }; }
698
+ }
633
699
  let first;
634
700
  try { return await send(client, orgId()); } catch (e) { first = fail(e); }
635
701
  // 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.
702
+ // sessions (not a cross-tenant leak): look in the user's other orgs only, each on a token for THAT org.
637
703
  if (!anyOrg || first.status !== 404 || !clientForOrg) return first;
638
704
  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 */ }
705
+ const orgs = await memberOrgs();
706
+ const nameOf = (oid) => orgs.find(o => o.id === oid)?.name || null;
707
+ // 1. Find it without sending: list slots per other org. Two orgs hold the name: ask, never pick.
708
+ const hits = [];
709
+ for (const o of orgs) {
710
+ if (tried.has(o.id)) continue;
711
+ try {
712
+ const ep = resolveEndpoint('v1.slots.list');
713
+ const rows = listOf(await (await clientFor(o.id)).get(ep.path, { service: ep.service, retries: 0 }), 'slots') || [];
714
+ if (rows.some(r => String(r?.name || '').toLowerCase() === String(name).toLowerCase())) hits.push(o);
715
+ } catch { /* an org that cannot be listed is not a candidate here; the probe below still covers it */ }
716
+ }
717
+ if (hits.length > 1) {
718
+ const choices = hits.map(o => ({ orgId: o.id, orgName: o.name, ref: `${name}@${slug(o.name)}` }));
719
+ 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} ...` };
720
+ }
721
+ const order = hits.map(o => o.id);
722
+ if (!order.length) { // nothing listed it: fall back to trying the home org, then each membership
723
+ try { const home = config.getAuth?.()?.org_id; if (home) order.push(home); } catch { /* no home org known */ }
724
+ for (const o of orgs) order.push(o.id);
725
+ }
645
726
  for (const oid of order) {
646
727
  if (tried.has(oid)) continue; tried.add(oid);
647
728
  try {
648
- const cl = await clientForOrg(oid);
729
+ const cl = await clientFor(oid);
649
730
  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
731
+ return { ...r, viaOrgId: oid, viaOrgName: nameOf(oid) };
732
+ } 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
733
  }
653
734
  return first;
654
735
  },
655
736
  /** 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 } = {}) {
737
+ async slotBroadcast({ text, slots = null, priority = 'normal', ack = false, active = false, dryRun = false, from = null } = {}) {
657
738
  const ep = resolveEndpoint('v1.slots.broadcast');
658
739
  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 } : {}) } });
740
+ 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
741
  return { ok: true, data };
661
742
  } catch (e) {
662
743
  if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
@@ -859,8 +940,26 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
859
940
  return l.map(e => fromExecution(e, now, { userId: userId() })).filter(Boolean);
860
941
  })
861
942
  ]);
943
+ // Attached agents (org list, owner/admin) and this box's live `claude --resume` processes. Neither is a work row:
944
+ // they ride on the source as `items` and /roster prints them (rosterSessions.js).
945
+ const itemsSrc = (id, label, fn) => fn().then(
946
+ (items) => ({ id, label, state: 'connected', rows: [], items, count: items.length }),
947
+ (e) => ({ id, label, rows: [], items: [], count: 0, state: missing(e) ? 'unavailable' : 'error', note: missing(e) ? 'no list endpoint on this server' : (e?.message || 'failed') }));
948
+ const extra = await Promise.all([
949
+ itemsSrc('agents', 'attached agents', async () => {
950
+ const ep = resolveEndpoint('agent-attach.list');
951
+ const d = await client.get(ep.path, { service: ep.service, retries: 0 });
952
+ const l = listOf(d, 'attaches'); if (!l) throw new Error('unexpected response');
953
+ return l;
954
+ }),
955
+ itemsSrc('boxsessions', "this box's sessions", async () => {
956
+ if (listSessions) return await listSessions();
957
+ const [{ listLiveSessions }, { spawnSync }] = await Promise.all([import('../core/governedSession.js'), import('node:child_process')]);
958
+ return listLiveSessions({ spawnSync, pid: process.pid });
959
+ })
960
+ ]);
862
961
  const unlisted = [
863
- { id: 'agents', label: 'attached agents', state: 'unavailable', count: 0, note: 'no org-wide list endpoint' },
962
+ ...extra.map(({ rows, ...rest }) => rest),
864
963
  { id: 'devsessions', label: 'dev sessions', state: 'unavailable', count: 0, note: 'listed per host only (quox dev sessions <host>)' }
865
964
  ];
866
965
  return { rows: sortRows(parts.flatMap(p => p.rows)), sources: [...parts.map(({ rows, ...rest }) => rest), ...unlisted] };
@@ -896,18 +995,31 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
896
995
  return { situation, approvals };
897
996
  },
898
997
  /** 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 } = {}) {
998
+ async setPresence(state, { note, slots, dnd = false, human = false } = {}) {
900
999
  const ep = resolveEndpoint('presence.set');
901
1000
  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 } : {}) } });
1001
+ 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
1002
  return { ok: true, presence: d?.presence || null, notice: d?.notice || null };
904
1003
  } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
905
1004
  },
1005
+ /** AFK-SURVIVES-RESTART: read-only GET /api/v1/presence/me. -> { ok:true, presence } | { ok:false, status }. Never throws, never writes. */
1006
+ async getPresence() {
1007
+ try {
1008
+ const ep = resolveEndpoint('presence.get', { who: 'me' });
1009
+ const d = await client.get(ep.path, { service: ep.service, query: { org_id: orgId() }, retries: 0, timeout: 6000 });
1010
+ return { ok: true, presence: d?.presence || null };
1011
+ } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
1012
+ },
906
1013
  noteActivity,
907
1014
  /** E5 /away: GET /api/v1/away?since=<iso> -> { since, until, items, activity, blind }. Throws on non-2xx. */
908
1015
  async away(since) {
909
1016
  const ep = resolveEndpoint('away.digest');
910
- return client.get(ep.path, { service: ep.service, query: { since }, retries: 0, timeout: 15000 });
1017
+ const read = () => client.get(ep.path, { service: ep.service, query: { since }, retries: 0, timeout: 15000 });
1018
+ const first = await read();
1019
+ // AWAY-RETRY: the first calls after a fresh sign-in can time out; try those sources once more before saying "Can't see".
1020
+ if (!(first?.blind || []).some((b) => /timed? ?out|timeout/i.test(String(b?.reason || '')))) return first;
1021
+ await new Promise((r) => setTimeout(r, awayRetryMs));
1022
+ try { return (await read()) || first; } catch { return first; } // the retry also failing leaves the honest first answer
911
1023
  },
912
1024
  /** Facts for /observers, each part on its own: { lucid, learning:[{ id, label, state, text }] }. */
913
1025
  async observers() {
@@ -1074,12 +1186,42 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
1074
1186
  async hyperion() { const data = await fetchActivity(client); return { lines: heldLines(data), tone: data.nodes_error || data.pool_error ? 'warn' : undefined }; },
1075
1187
  approvals: listApprovals,
1076
1188
  /** 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 } = {}) {
1189
+ async inboxItems({ limit = 30, page = 1 } = {}) {
1078
1190
  const ep = resolveEndpoint('inbox.list', { orgId: orgId() });
1079
- const data = await client.get(ep.path, { service: ep.service, query: { limit } });
1191
+ const data = await client.get(ep.path, { service: ep.service, query: page > 1 ? { limit, page } : { limit } });
1080
1192
  const items = data?.items || data?.data || data || [];
1081
1193
  return Array.isArray(items) ? items : [];
1082
1194
  },
1195
+ /** TUI-MAIL: connected InboxQ mailboxes (GET /api/v1/inboxq/accounts, the `quox mail accounts` read). Throws on failure, never reads as none. */
1196
+ async mailAccounts() {
1197
+ const ep = resolveEndpoint('inboxq.accounts.list');
1198
+ const data = await client.get(ep.path, { service: ep.service, retries: 0 });
1199
+ return Array.isArray(data?.accounts) ? data.accounts : [];
1200
+ },
1201
+ /** TUI-MAIL: triaged messages (GET /api/v1/inboxq/messages, the `quox mail list` read). Throws on failure. */
1202
+ async mailMessages({ limit = 10, zone } = {}) {
1203
+ const ep = resolveEndpoint('inboxq.messages.list');
1204
+ const data = await client.get(ep.path, { service: ep.service, retries: 0, query: { limit, offset: 0, ...(zone ? { zone } : {}) } });
1205
+ return Array.isArray(data?.messages) ? data.messages : [];
1206
+ },
1207
+ /** TUI-MAIL: one message with its stored body (GET /api/v1/inboxq/messages/:id, read only: no body fetch from the mailbox). */
1208
+ async mailMessage(id) {
1209
+ const ep = resolveEndpoint('inboxq.messages.get', { id });
1210
+ const data = await client.get(ep.path, { service: ep.service, retries: 0 });
1211
+ return data?.message || null;
1212
+ },
1213
+ /** Every inbox item, paged (100 per page, capped at maxPages). Throws on failure, never returns a partial list as the whole. */
1214
+ async inboxAllItems({ pageSize = 100, maxPages = 20 } = {}) {
1215
+ const ep = resolveEndpoint('inbox.list', { orgId: orgId() });
1216
+ const all = [];
1217
+ for (let page = 1; page <= maxPages; page++) {
1218
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: pageSize, page } });
1219
+ const items = data?.items || data?.data || (Array.isArray(data) ? data : []);
1220
+ all.push(...items);
1221
+ if (!(data?.pagination?.has_more) || !items.length) break;
1222
+ }
1223
+ return all;
1224
+ },
1083
1225
  /** One inbox item in full (the server marks it read for this user as a side effect). */
1084
1226
  async inboxItem(itemId) {
1085
1227
  const ep = resolveEndpoint('inbox.get', { orgId: orgId(), itemId: encodeURIComponent(itemId) });
@@ -1097,6 +1239,14 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
1097
1239
  const d = await client.get(ep.path, { service: ep.service });
1098
1240
  return { pending: Number(d?.pending_approvals ?? 0), awaiting: Number(d?.awaiting_human ?? 0) };
1099
1241
  },
1242
+ /** Approvals waiting in the user's OTHER orgs: counts only, per-org stats on that org's own token (core/crossOrgApprovals). */
1243
+ async otherOrgApprovals() { return otherOrgsWaiting({ client, currentOrgId: orgId() }); },
1244
+ /** 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). */
1245
+ async approvalsInOrg(org) {
1246
+ const ep = resolveEndpoint('inbox.approvals.open', { orgId: org });
1247
+ const data = await client.get(ep.path, { service: ep.service, retries: 0 });
1248
+ return Array.isArray(data?.approvals) ? data.approvals.map((a) => a.item).filter(Boolean) : [];
1249
+ },
1100
1250
  /** GET /api/v1/approvals/:id/result -> { status, result, executed_at } ; null when unknown (404) */
1101
1251
  async approvalResult(approvalId) {
1102
1252
  const ep = resolveEndpoint('v1.approvals.result', { id: approvalId });
@@ -1104,14 +1254,14 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
1104
1254
  catch (e) { if (e?.status === 404) return null; throw e; }
1105
1255
  },
1106
1256
  /** 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 });
1257
+ async answerDecision(itemId, option, { note = null, stepUp = null, bulk = false, org = null } = {}) {
1258
+ 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
1259
  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
1260
  if (stepUp && Object.keys(stepUp).length) noteActivity('answer', { quiet: true }); // PRESENCE: answering is the owner being back
1111
1261
  return r;
1112
1262
  },
1113
- async respond(itemId, action, { via = null, stepUp = null, reason = null, bulk = false } = {}) {
1114
- const ep = resolveEndpoint('inbox.respond', { orgId: orgId(), itemId });
1263
+ async respond(itemId, action, { via = null, stepUp = null, reason = null, bulk = false, org = null } = {}) {
1264
+ const ep = resolveEndpoint('inbox.respond', { orgId: org || orgId(), itemId });
1115
1265
  const tag = via ? `via quoxon ${via}` : 'via quoxon';
1116
1266
  const why = reason ? String(reason).trim().slice(0, 4000) : '';
1117
1267
  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 +1279,11 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
1129
1279
  const ep = resolveEndpoint('stepup.status');
1130
1280
  return client.get(ep.path, { service: ep.service });
1131
1281
  },
1282
+ /** /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. */
1283
+ async verifyEmail({ email, reason, userId = null, stepUp = {} }) {
1284
+ const ep = resolveEndpoint('admin.users.verify_email');
1285
+ return client.post(ep.path, { service: ep.service, headers: stepUp, body: { email, reason, ...(userId ? { user_id: userId } : {}) } });
1286
+ },
1132
1287
  /** QUOXON-STEPUP part B: override ONE blocked action. Needs a live override-scoped step-up token. */
1133
1288
  async override({ blockId, blockType, reason, agentId = null, action = null, stepUp = {} }) {
1134
1289
  const ep = resolveEndpoint('safety.override');