quoxcli 0.3.1 → 0.4.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 (188) hide show
  1. package/lib/api/endpoints/actions.js +15 -0
  2. package/lib/api/endpoints/admin.js +20 -0
  3. package/lib/api/endpoints/blog.js +16 -0
  4. package/lib/api/endpoints/chat.js +7 -0
  5. package/lib/api/endpoints/contactsq.js +4 -0
  6. package/lib/api/endpoints/devServerRegistry.js +25 -0
  7. package/lib/api/endpoints/engagement.js +26 -0
  8. package/lib/api/endpoints/evidence.js +20 -0
  9. package/lib/api/endpoints/hat.js +14 -0
  10. package/lib/api/endpoints/index.js +12 -0
  11. package/lib/api/endpoints/loops.js +26 -0
  12. package/lib/api/endpoints/mon.js +173 -0
  13. package/lib/api/endpoints/objectives.js +6 -0
  14. package/lib/api/endpoints/quoxseo.js +6 -0
  15. package/lib/api/endpoints/server.js +21 -0
  16. package/lib/api/endpoints/setup.js +6 -0
  17. package/lib/api/endpoints/v1.js +34 -0
  18. package/lib/commands/actions.js +81 -11
  19. package/lib/commands/agent.js +11 -4
  20. package/lib/commands/agentAttach.js +46 -7
  21. package/lib/commands/approvals.js +5 -0
  22. package/lib/commands/ask.js +3 -1
  23. package/lib/commands/blog.js +110 -0
  24. package/lib/commands/chat.js +127 -28
  25. package/lib/commands/chatCompanion.js +27 -0
  26. package/lib/commands/chatState.js +45 -0
  27. package/lib/commands/claude.js +3 -32
  28. package/lib/commands/contacts.js +85 -4
  29. package/lib/commands/contactsSendAt.js +21 -0
  30. package/lib/commands/engagement.js +90 -0
  31. package/lib/commands/evidence.js +154 -0
  32. package/lib/commands/ext.js +23 -0
  33. package/lib/commands/fleet.js +25 -0
  34. package/lib/commands/hat.js +76 -0
  35. package/lib/commands/image.js +68 -0
  36. package/lib/commands/kuma.js +1050 -0
  37. package/lib/commands/kumaSla.js +174 -0
  38. package/lib/commands/licence.js +39 -1
  39. package/lib/commands/loop.js +291 -0
  40. package/lib/commands/objective.js +48 -1
  41. package/lib/commands/org.js +21 -4
  42. package/lib/commands/provider.js +58 -0
  43. package/lib/commands/quoxseo.js +31 -0
  44. package/lib/commands/research.js +79 -0
  45. package/lib/commands/rows.js +9 -0
  46. package/lib/commands/run.js +18 -1
  47. package/lib/commands/scope.js +2 -2
  48. package/lib/commands/server.js +113 -0
  49. package/lib/commands/shortcut.js +41 -0
  50. package/lib/commands/skills.js +20 -4
  51. package/lib/commands/vm.js +2 -2
  52. package/lib/commands/youtube.js +172 -0
  53. package/lib/core/actionItems.js +115 -0
  54. package/lib/core/agentAlias.js +2 -0
  55. package/lib/core/agentFamilies.js +7 -0
  56. package/lib/core/client.js +10 -2
  57. package/lib/core/commandRegistrars.js +9 -0
  58. package/lib/core/config.js +1 -1
  59. package/lib/core/findingReconcile.js +90 -0
  60. package/lib/core/global-flags.js +1 -1
  61. package/lib/core/governedSession.js +1 -3
  62. package/lib/core/liveSessionsReport.js +1 -1
  63. package/lib/core/loopGlyph.js +97 -0
  64. package/lib/core/offload.js +36 -7
  65. package/lib/core/output.js +7 -7
  66. package/lib/core/sessionHeartbeat.js +12 -8
  67. package/lib/core/shortcut.js +83 -0
  68. package/lib/core/version.js +1 -1
  69. package/lib/core/vm.js +5 -0
  70. package/lib/dragon/companionView.js +34 -0
  71. package/lib/dragon/frames.js +118 -0
  72. package/lib/dragon/jobs.js +123 -0
  73. package/lib/dragon/renderWorker.js +15 -0
  74. package/lib/dragon/select.js +72 -0
  75. package/lib/evidence/bundleExport.js +47 -0
  76. package/lib/evidence/bundleVerify.js +77 -0
  77. package/lib/quoxon/EXTENSIONS.md +23 -0
  78. package/lib/quoxon/api.js +192 -6
  79. package/lib/quoxon/app.js +655 -89
  80. package/lib/quoxon/approvalFacts.js +8 -6
  81. package/lib/quoxon/approvalSentence.js +44 -0
  82. package/lib/quoxon/approvalsView.js +63 -4
  83. package/lib/quoxon/commandShort.js +15 -3
  84. package/lib/quoxon/commands.js +75 -10
  85. package/lib/quoxon/countdownLine.js +10 -1
  86. package/lib/quoxon/deck.js +30 -0
  87. package/lib/quoxon/demo.autoplay.js +1 -1
  88. package/lib/quoxon/demo.js +3 -1
  89. package/lib/quoxon/dividerAocl.js +4 -3
  90. package/lib/quoxon/dividerRule.js +3 -3
  91. package/lib/quoxon/dragonCompanion.js +255 -0
  92. package/lib/quoxon/dragonModes.js +1 -0
  93. package/lib/quoxon/dragonMoments.js +82 -0
  94. package/lib/quoxon/dragonReads.js +68 -0
  95. package/lib/quoxon/dragonView.js +2 -2
  96. package/lib/quoxon/events.js +24 -3
  97. package/lib/quoxon/extensions.js +276 -0
  98. package/lib/quoxon/inboxView.js +79 -0
  99. package/lib/quoxon/installCli.js +79 -0
  100. package/lib/quoxon/keys.js +2 -2
  101. package/lib/quoxon/loopCard.js +456 -0
  102. package/lib/quoxon/loopCards.js +180 -0
  103. package/lib/quoxon/loopStream.js +87 -0
  104. package/lib/quoxon/main.js +12 -3
  105. package/lib/quoxon/mouse.js +12 -1
  106. package/lib/quoxon/objectiveOffer.js +82 -0
  107. package/lib/quoxon/objects/index.js +22 -4
  108. package/lib/quoxon/objects/model.js +33 -0
  109. package/lib/quoxon/objects/proof.js +12 -4
  110. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  111. package/lib/quoxon/onboarding/view.js +1 -1
  112. package/lib/quoxon/orgSwitch.js +27 -1
  113. package/lib/quoxon/planSteps.js +51 -0
  114. package/lib/quoxon/print.js +6 -3
  115. package/lib/quoxon/qdragonFly.js +7 -6
  116. package/lib/quoxon/qmotion.js +17 -0
  117. package/lib/quoxon/render.js +130 -33
  118. package/lib/quoxon/roster.js +26 -12
  119. package/lib/quoxon/rows.js +244 -1
  120. package/lib/quoxon/serverChip.js +42 -0
  121. package/lib/quoxon/session.js +10 -0
  122. package/lib/quoxon/slash/cliCommand.js +42 -1
  123. package/lib/quoxon/slash/cliLeaves.json +76 -1
  124. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  125. package/lib/quoxon/slash/palette.js +1 -1
  126. package/lib/quoxon/slash/qpeCmd.js +35 -0
  127. package/lib/quoxon/slash/restart.js +25 -1
  128. package/lib/quoxon/slash/resume.js +18 -1
  129. package/lib/quoxon/slash/todoCmd.js +122 -0
  130. package/lib/quoxon/suggestCommand.js +33 -2
  131. package/lib/quoxon/toolCards.js +30 -0
  132. package/lib/quoxon/whatsnew.jsonl +45 -0
  133. package/lib/quoxon/windowOrg.js +11 -1
  134. package/lib/server/devServers.js +137 -0
  135. package/package.json +2 -1
  136. package/vendor/dragonlab/SOURCE +4 -0
  137. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  138. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  139. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  140. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  141. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  142. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  143. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  144. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  145. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  146. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  147. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  148. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  149. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  150. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  151. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  152. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  153. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  154. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  155. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  156. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  157. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  158. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  159. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  160. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  161. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  162. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  163. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  164. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  165. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  166. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  167. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  168. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  169. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  170. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  171. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  172. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  173. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  174. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  175. package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
  176. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  177. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  178. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  179. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  180. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  181. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  182. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  183. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  184. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  185. package/vendor/dragonlab/lib/power-effects.js +294 -0
  186. package/vendor/dragonlab/lib/power-registry.js +74 -0
  187. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  188. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher): which one, whether to show it, and where its art lives.
3
+ * Pure. The art, animation and activity reducer stay in quoxai/dragonlab (integration/companions-v1);
4
+ * Quox only chooses and loads. See docs/dragons/COMPANION-UX.md for the decisions behind each rule.
5
+ */
6
+ import { resolve, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ /** The dragonlab art bundled with the CLI (cli/vendor/dragonlab, refreshed by scripts/sync-dragonlab.sh). */
10
+ export const VENDORED_DRAGONLAB = fileURLToPath(new URL('../../vendor/dragonlab', import.meta.url));
11
+
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' });
14
+ export const COMPANION_MODES = ['on', 'greeting', 'off'];
15
+ /** One-line change: the owner decides this after his Mac check (docs/dragons/COMPANION-UX.md section 4). */
16
+ export const DEFAULT_COMPANION_MODE = 'on';
17
+
18
+ export function dragonForAgent(agent) {
19
+ const k = String(agent ?? '').trim().toLowerCase();
20
+ return Object.hasOwn(AGENT_DRAGON, k) ? AGENT_DRAGON[k] : null;
21
+ }
22
+
23
+ export function normaliseMode(v) {
24
+ const s = String(v ?? '').trim().toLowerCase();
25
+ return COMPANION_MODES.includes(s) ? s : null;
26
+ }
27
+
28
+ /** env QUOX_DRAGON_COMPANION beats the saved profile setting beats the default. */
29
+ export function companionMode(settings = {}, env = process.env) {
30
+ return normaliseMode(env.QUOX_DRAGON_COMPANION) || normaliseMode(settings?.dragonCompanion) || DEFAULT_COMPANION_MODE;
31
+ }
32
+
33
+ /**
34
+ * Motion policy from the existing Quox switches. lite or a dumb terminal = off; reduced motion = still
35
+ * (no entrance, every channel frozen, captions still update); NO_COLOR = mono render.
36
+ */
37
+ export function motionPolicy(env = process.env) {
38
+ const lite = Boolean(env.QUOX_TUI_LITE) || env.TERM === 'dumb';
39
+ const quiet = env.QUOX_REDUCED_MOTION === '1' || String(env.QUOX_MOTION || '').trim().toLowerCase() === 'quiet';
40
+ return { off: lite, still: quiet, mono: 'NO_COLOR' in env };
41
+ }
42
+
43
+ /**
44
+ * Where dragonlab lives: QUOX_DRAGONLAB beats the saved quoxon.dragonLab beats `bundled` (hosts pass
45
+ * VENDORED_DRAGONLAB). With no bundled path and no override, null = art not available.
46
+ */
47
+ export function dragonLabRoot(settings = {}, env = process.env, bundled = null) {
48
+ const p = env.QUOX_DRAGONLAB || settings?.dragonLab;
49
+ if (typeof p === 'string' && p.trim()) return resolve(p.trim());
50
+ return bundled ? resolve(bundled) : null;
51
+ }
52
+ export const companionModuleFile = (root, id) => join(root, 'integration', 'companions-v1', `${id}.mjs`);
53
+ export const viewModuleFile = (root) => join(root, 'integration', 'companions-v1', 'view.mjs');
54
+
55
+ /**
56
+ * Everything a host needs, or { enabled:false, why } (the reason is what `/dragon companion status` prints).
57
+ * `exists` is injected so this stays pure.
58
+ */
59
+ export function companionPlan({ agent, settings = {}, env = process.env, isTTY = true, exists = () => true, bundled = null }) {
60
+ const mode = companionMode(settings, env), policy = motionPolicy(env);
61
+ const id = dragonForAgent(agent);
62
+ const no = (why) => ({ enabled: false, why, mode, id });
63
+ if (mode === 'off') return no('companion is off');
64
+ if (!isTTY) return no('output is not a terminal');
65
+ if (policy.off) return no('QUOX_TUI_LITE or a dumb terminal turns decoration off');
66
+ if (!id) return no(agent ? `${agent} has no approved dragon` : 'no agent attached');
67
+ const root = dragonLabRoot(settings, env, bundled);
68
+ if (!root) return no('dragon art not found (the bundled copy is missing; set QUOX_DRAGONLAB or quoxon.dragonLab to a dragonlab checkout)');
69
+ const file = companionModuleFile(root, id);
70
+ if (!exists(file) || !exists(viewModuleFile(root))) return no(`dragon art not found at ${root}`);
71
+ return { enabled: true, mode, id, root, file, viewFile: viewModuleFile(root), still: policy.still, mono: policy.mono, persist: mode === 'on' };
72
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Fetch one conversation's signed evidence zip, write it, verify what was written. One implementation for
3
+ * `quox evidence bundle` and the TUI's /evidence. The server scopes by the caller's org and refuses anything else.
4
+ */
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { resolveEndpoint } from '../api/endpoints.js';
9
+ import { verifyEvidenceBundle } from './bundleVerify.js';
10
+
11
+ export function defaultBundlePath(corr, now = new Date()) {
12
+ const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
13
+ const safe = String(corr).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 60);
14
+ return path.join(os.homedir(), 'quox-evidence', `${safe}-${ts}.zip`);
15
+ }
16
+
17
+ /** -> the id of the caller's newest chat conversation, or null. */
18
+ export async function newestConversationId(client) {
19
+ const ep = resolveEndpoint('conversations.chat.list');
20
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: 1 } });
21
+ const c = (data && data.conversations && data.conversations[0]) || null;
22
+ return c ? c.id : null;
23
+ }
24
+
25
+ /**
26
+ * @returns {Promise<{ path: string, corr: string, bytes: number, envelopes: number, receipts: number, verify: object }>}
27
+ * @throws Error with the HTTP status named when the server refuses (404 = not your conversation or no evidence).
28
+ */
29
+ export async function exportConversationBundle({ client, corr, out, now = new Date() }) {
30
+ const ep = resolveEndpoint('evidence.bundle', { corr });
31
+ const res = await client.get(ep.path, { service: ep.service, query: { format: 'zip' }, raw: true, timeout: 120000 });
32
+ if (!res.ok) {
33
+ let detail = res.statusText;
34
+ try { const j = await res.json(); detail = j.error || JSON.stringify(j); } catch { /* keep statusText */ }
35
+ const e = new Error(res.status === 404
36
+ ? `No evidence for conversation ${corr} in your org (HTTP 404: ${detail}). Tool calls made before this fix are filed under per-command ids and are not part of the conversation.`
37
+ : `Evidence bundle failed: HTTP ${res.status}: ${detail}`);
38
+ e.status = res.status;
39
+ throw e;
40
+ }
41
+ const buf = Buffer.from(await res.arrayBuffer());
42
+ const file = path.resolve(out || defaultBundlePath(corr, now));
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ fs.writeFileSync(file, buf);
45
+ const verify = await verifyEvidenceBundle(fs.readFileSync(file)); // what was written, not what was sent
46
+ return { path: file, corr, bytes: buf.length, envelopes: verify.manifest?.counts?.envelopes ?? 0, receipts: verify.manifest?.counts?.ward_receipts ?? 0, receiptRows: verify.receipts || [], verify };
47
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Offline verifier for a conversation evidence bundle (collector lib/conversationEvidenceBundle.js).
3
+ * Needs nothing but the zip: file hashes against manifest.json, the envelope list hash, the Ed25519 signature
4
+ * over the canonical manifest, and the counts. Never reaches the network. The collector builds; this only checks.
5
+ */
6
+ import crypto from 'node:crypto';
7
+ import JSZip from 'jszip';
8
+
9
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
10
+
11
+ // Same canonical form as services/collector/lib/bundleSigner.js (sorted keys at every level, no whitespace).
12
+ export function canonicalize(val) {
13
+ if (val === null || val === undefined) return JSON.stringify(val);
14
+ if (typeof val !== 'object') return JSON.stringify(val);
15
+ if (Array.isArray(val)) return `[${val.map((v) => canonicalize(v)).join(',')}]`;
16
+ const keys = Object.keys(val).filter((k) => val[k] !== undefined).sort();
17
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(val[k])}`).join(',')}}`;
18
+ }
19
+
20
+ const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
21
+
22
+ export function envelopesHash(envelopes) {
23
+ return sha256(Buffer.from(JSON.stringify(
24
+ envelopes.map((e) => ({ id: e.id ?? null, ts: e.ts ?? null, intent: e.intent ?? null, corr: e.corr ?? null, reply_to: e.reply_to ?? null, from: e.from ?? null, to: e.to ?? null })),
25
+ ), 'utf8'));
26
+ }
27
+
28
+ /**
29
+ * @param {Buffer|Uint8Array} zipBytes
30
+ * @returns {Promise<{ ok: boolean, checks: {name:string, ok:boolean, detail?:string}[], manifest: object|null, signature: object|null }>}
31
+ */
32
+ export async function verifyEvidenceBundle(zipBytes) {
33
+ const checks = [];
34
+ const add = (name, ok, detail) => { checks.push({ name, ok: !!ok, ...(detail ? { detail } : {}) }); return !!ok; };
35
+ let zip;
36
+ try { zip = await JSZip.loadAsync(zipBytes); } catch (e) {
37
+ add('zip readable', false, e.message);
38
+ return { ok: false, checks, manifest: null, signature: null };
39
+ }
40
+ const read = async (name) => { const f = zip.file(name); return f ? Buffer.from(await f.async('nodebuffer')) : null; };
41
+ const manifestBuf = await read('manifest.json');
42
+ const sigBuf = await read('signature.json');
43
+ if (!add('manifest.json present', manifestBuf)) return { ok: false, checks, manifest: null, signature: null };
44
+ if (!add('signature.json present', sigBuf)) return { ok: false, checks, manifest: null, signature: null };
45
+ let manifest; let signature;
46
+ try { manifest = JSON.parse(manifestBuf.toString('utf8')); signature = JSON.parse(sigBuf.toString('utf8')); } catch (e) {
47
+ add('manifest and signature parse', false, e.message);
48
+ return { ok: false, checks, manifest: null, signature: null };
49
+ }
50
+
51
+ // 1. signature over the canonical manifest
52
+ try {
53
+ const signed = Buffer.from(canonicalize(manifest), 'utf8');
54
+ const pub = crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(signature.public_key, 'base64')]), format: 'der', type: 'spki' });
55
+ const sigOk = signature.alg === 'ed25519' && crypto.verify(null, signed, pub, Buffer.from(signature.signature, 'base64'));
56
+ add('signature (Ed25519 over the manifest)', sigOk, sigOk ? `key ${signature.key_id}` : 'signature does not match the manifest');
57
+ add('signed manifest hash', signature.manifest_sha256 === sha256(signed), signature.manifest_sha256 === sha256(signed) ? undefined : 'manifest_sha256 differs');
58
+ } catch (e) { add('signature (Ed25519 over the manifest)', false, e.message); }
59
+
60
+ // 2. every listed file hashes to what the (signed) manifest says
61
+ const bufs = {};
62
+ for (const [name, want] of Object.entries(manifest.files || {})) {
63
+ const b = await read(name);
64
+ bufs[name] = b;
65
+ add(`file ${name}`, b && sha256(b) === want, !b ? 'missing from the zip' : (sha256(b) === want ? undefined : 'hash differs from the manifest'));
66
+ }
67
+
68
+ // 3. envelope list hash + counts
69
+ let envelopes = [];
70
+ try { envelopes = JSON.parse((bufs['envelopes.json'] || Buffer.from('[]')).toString('utf8')); } catch { envelopes = []; }
71
+ add('envelope list hash', envelopesHash(envelopes) === manifest.envelopes_sha256, envelopesHash(envelopes) === manifest.envelopes_sha256 ? undefined : 'recomputed hash differs');
72
+ add('envelope count', envelopes.length === manifest.counts?.envelopes, `${envelopes.length} in envelopes.json, ${manifest.counts?.envelopes} in the manifest`);
73
+ let receipts = [];
74
+ try { receipts = JSON.parse((bufs['ward-receipts.json'] || Buffer.from('[]')).toString('utf8')); } catch { receipts = []; }
75
+ add('receipt count', receipts.length === manifest.counts?.ward_receipts, `${receipts.length} in ward-receipts.json, ${manifest.counts?.ward_receipts} in the manifest`);
76
+ return { ok: checks.every((c) => c.ok), checks, manifest, signature, receipts };
77
+ }
@@ -0,0 +1,23 @@
1
+ # quoxon extensions
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.
4
+
5
+ **Loading.** Nothing loads unless the package is installed AND listed in `~/.quox/extensions.json`
6
+ (`quox ext add|remove|list <pkg>`; `/ext list|on|off <name>` inside quoxon). Install with `npm i -g <pkg>`.
7
+ 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.
package/lib/quoxon/api.js CHANGED
@@ -16,6 +16,7 @@ import { normChoice } from './model.js';
16
16
  import { normPrefs as normModelPrefs } from './modelPrefs.js';
17
17
  import { createRowsStore } from './rowsStore.js';
18
18
  import { createObjectsApi } from './objects/fetch.js';
19
+ import { createDragonReads } from './dragonReads.js';
19
20
  import { createKillerApi } from './slash/killerApi.js';
20
21
  import { createUndoOps } from './slash/undoApi.js';
21
22
  import { resolveDashboardBase, resolveCollectorBase, detectLanIp } from './hostUrls.js';
@@ -26,6 +27,8 @@ import { runVmCommand } from '../core/vmTui.js';
26
27
  import { fetchActivity, heldLines } from '../core/hyperion.js';
27
28
  import { createPeekFetchers, peekId } from '../core/peek.js';
28
29
  import { askAside } from '../core/aside.js';
30
+ import { exportConversationBundle } from '../evidence/bundleExport.js';
31
+ import { addActionItem, listBoard, moveActionItem } from '../core/actionItems.js';
29
32
 
30
33
  const missing = (e) => e?.status === 404 || e?.status === 405 || e?.status === 501;
31
34
  /** Server steer refusal label -> client reason. Unknown labels are 'ambiguous', never "finished". */
@@ -71,11 +74,35 @@ const vaultFailure = (e) => ({ ok: false, status: e?.status, message: e?.message
71
74
  const slotsUnlicensed = (e) => e?.status === 403 && /not licen[cs]ed/i.test(`${e?.code || ''} ${e?.body?.error || ''} ${e?.message || ''}`);
72
75
 
73
76
  /** PRESENCE: only a person at a real terminal is "back". --print, pipes, proof runs and agents are not. */
77
+ const NOTES_OFF = 'NotesQ is not available for this org. Ask an org admin to enable the NotesQ plugin (dashboard, Plugins), or check `quox notes ls` in a terminal.';
78
+ export function noteParts(text) {
79
+ const t = String(text || '').trim();
80
+ const first = t.split('\n')[0].trim();
81
+ const title = first.length > 60 ? `${first.slice(0, 60).trimEnd()}…` : first;
82
+ return { title, markdown: `# ${title}\n\n${t}\n` };
83
+ }
84
+ async function createNoteFrom(client, text) {
85
+ const { title, markdown } = noteParts(text);
86
+ try {
87
+ const ep = resolveEndpoint('notesq.notes.create');
88
+ const d = await client.post(ep.path, { service: ep.service, retries: 0, body: { scope: 'org', markdown, properties: {} } });
89
+ const n = d?.note || {};
90
+ if (!n.id) return { ok: false, message: 'NotesQ answered without a note id, so nothing is confirmed saved. Check quox notes ls.' };
91
+ return { ok: true, id: n.id, title: n.title || title };
92
+ } catch (e) {
93
+ if (e?.status === 402) return { ok: false, message: 'NotesQ is not licensed for this org. ' + NOTES_OFF };
94
+ if (e?.status === 403 || e?.status === 404 || e?.status === 503) return { ok: false, message: NOTES_OFF };
95
+ return { ok: false, message: `Could not save the note: ${e?.message || 'request failed'}${e?.status ? ` (HTTP ${e.status})` : ''}.` };
96
+ }
97
+ }
74
98
  export function isInteractiveHuman({ stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
75
99
  return Boolean(stdin?.isTTY && stdout?.isTTY) && !env.CI;
76
100
  }
77
101
 
78
- export function createApi({ client, config, auth, windowOrg = null, reattachBackoff = REATTACH_BACKOFF_MS }) {
102
+ /** A one-line title within the tasks service's 200 character cap. */
103
+ const clipTitle = (g) => { const t = String(g ?? '').replace(/\s+/g, ' ').trim(); return t.length > 200 ? `${t.slice(0, 199)}…` : t; };
104
+
105
+ export function createApi({ client, config, auth, windowOrg = null, clientForOrg = null, reattachBackoff = REATTACH_BACKOFF_MS }) {
79
106
  let holderSessionFn = null;
80
107
  const orgId = () => { try { return auth.getOrgId(); } catch { return null; } };
81
108
  const rowsStore = createRowsStore({ client, config, orgId });
@@ -147,9 +174,13 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
147
174
  ...createKillerApi({ client }),
148
175
  orgId,
149
176
  undoOps: createUndoOps({ client, orgId }), // /undo
177
+ /** /note: same call as `quox notes new` (org scope). -> { ok, id, title } | { ok:false, message } */
178
+ createNote: (text) => createNoteFrom(client, text),
150
179
  userId,
151
180
  /** /peek <id>: the same lookup as `quox peek`, in this window's org. -> peekId result */
152
181
  peek: (id) => peekId(id, createPeekFetchers({ client, orgId })),
182
+ /** /evidence, /ward: the signed bundle of one conversation, written to ~/quox-evidence (same export as `quox evidence bundle`). */
183
+ evidenceExport: (corr) => exportConversationBundle({ client, corr }),
153
184
  /** /aside: a no-tools, no-conversation question (core/aside.js). turns = recent exchanges, read-only context. */
154
185
  aside: (question, turns = []) => askAside(client, { question, turns }),
155
186
  /** @mention hosts: the Bastion inventory (same read as `quox fleet hosts`). -> [{ id, group, health }] */
@@ -158,6 +189,25 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
158
189
  const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 8000 });
159
190
  return (listOf(d, 'hosts') || []).map(h => ({ id: h.id, group: h.group || '', health: h.status?.health || '' }));
160
191
  },
192
+ /** QUOXON objective offer: `quox objective create <goal> --checkpoint` + `start` (same endpoints). -> { id, status } */
193
+ async startObjective(goal, loop = null) {
194
+ const c = resolveEndpoint('objectives.create');
195
+ // QLOOP: a loop offer creates a LOOP (server-derived scope + goal), never a plan under the loop's name.
196
+ const body = loop ? { title: String(goal).slice(0, 200), description: String(goal), execution_mode: 'loop', capability_scope: loop.capability_scope, goal_condition: loop.goal_condition,
197
+ ...(loop.until ? { until: loop.until } : {}), ...(loop.every_seconds ? { every_seconds: loop.every_seconds } : {}), ...(loop.max_iterations ? { max_iterations: loop.max_iterations } : {}), ...(loop.max_duration_seconds ? { max_duration_ms: loop.max_duration_seconds * 1000 } : {}), ...(Array.isArray(loop.done_check) && loop.done_check.length ? { done_check: loop.done_check } : {}) }
198
+ : { title: clipTitle(goal), description: String(goal), checkpoint_after_each_task: true }; // the tasks service caps a title at 200; the whole ask rides in the description
199
+ const made = await client.post(c.path, { service: c.service, body });
200
+ if (!made?.id) throw new Error('the server answered without an objective id');
201
+ const st = resolveEndpoint('objectives.start', { id: made.id });
202
+ const started = await client.post(st.path, { service: st.service, body: {} });
203
+ return { id: made.id, status: started?.status || 'started' };
204
+ },
205
+ /** An objective with its tasks (the same read as `quox objective get`), for the chat's report-back. */
206
+ async objectiveGet(id) {
207
+ const ep = resolveEndpoint('objectives.get', { id: encodeURIComponent(id) });
208
+ const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 8000 });
209
+ return d?.objective || d;
210
+ },
161
211
  /** @mention tasks: the newest objectives (one read, cached by the composer for the session). -> [{ id, title, status }] */
162
212
  async mentionTasks() {
163
213
  const ep = resolveEndpoint('objectives.list');
@@ -203,6 +253,7 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
203
253
  },
204
254
  /** Inline object views (objects/): skill, workflow, crystal, agent, memory, rules, proof, reconcile. */
205
255
  objects: createObjectsApi({ client, orgId, profile: config?._effectiveProfile || null }),
256
+ dragonReads: createDragonReads({ client }), // the companion dragons' jobs: bounded read-only reads (dragonReads.js)
206
257
  /** /model choice persisted on the active profile ({ provider, model? } only, never a key); null = auto. */
207
258
  modelChoice() { try { return normChoice(config.getActiveProfile?.()?.chat_model); } catch { return null; } },
208
259
  setModelChoice(choice) {
@@ -455,6 +506,27 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
455
506
  try { const r = await fetch(`${String(base).replace(/\/+$/, '')}/install`, { method: 'GET', signal: ctl.signal }); try { await r.body?.cancel?.(); } catch { /* drained */ } return { ok: r.ok, status: r.status }; }
456
507
  catch { return null; } finally { clearTimeout(t); }
457
508
  },
509
+ /** TUI-STATUS: the registered server this quox runs on -> { name, role, remote } | null (unregistered or any failure). */
510
+ async serverChip() {
511
+ const { resolveServerChip } = await import('./serverChip.js');
512
+ const { loadRegistry } = await import('../server/devServers.js');
513
+ let registry = { servers: {} };
514
+ try { registry = { servers: (await loadRegistry({ client })).servers }; } catch { /* unreadable registry: no purpose shown */ }
515
+ return resolveServerChip({
516
+ registry,
517
+ url: config.getActiveProfile?.()?.url || config.getBaseUrl?.() || null,
518
+ listHosts: async () => { const ep = resolveEndpoint('v1.fleet.hosts.list'); const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 5000 }); return d?.hosts || d; }
519
+ });
520
+ },
521
+ /** DEVFLEET: the registered dev servers (same rows as `quox server list`) -> rows[]; throws on a Bastion read failure. */
522
+ async devServers() {
523
+ const [{ loadRegistry, buildRows, hereIdentity }] = [await import('../server/devServers.js')];
524
+ const ep = resolveEndpoint('v1.fleet.hosts.list');
525
+ const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 5000 });
526
+ const hosts = d?.hosts || d;
527
+ if (!Array.isArray(hosts)) throw new Error('unexpected response');
528
+ { const reg = await loadRegistry({ client }); return buildRows(hosts, { servers: reg.servers, defaults: reg.defaults }, hereIdentity()); }
529
+ },
458
530
  serverUrl() { try { return new URL(config.getServiceUrl('collector')).origin; } catch { return null; } },
459
531
  /** /config settings on the active profile. */
460
532
  settings() { try { const q = config.getActiveProfile?.()?.quoxon; return q && typeof q === 'object' ? { ...q } : {}; } catch { return {}; } },
@@ -503,6 +575,29 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
503
575
  if (!rows) throw new Error('unexpected response');
504
576
  return rows.filter(r => r.is_enabled !== false && r.is_enabled !== 0).map(r => r.plugin_id).filter(Boolean);
505
577
  },
578
+ /** /todo: add an Action Tracker item as the logged-in user (the same core as `quox actions add`). Throws on failure. -> the item. */
579
+ async todoAdd(text) { return addActionItem(client, { title: text }); },
580
+ /** /todos: the whole board { backlog, doing, done }, every page. Throws on failure so it never reads as "none". */
581
+ async todoList() { return listBoard(client); },
582
+ /** /todo move: move one item (real id) to todo, doing or done. -> the item. */
583
+ async todoMove(id, status) { return moveActionItem(client, id, status); },
584
+ /** -> [{ plugin_id, version, is_enabled, ... }] every plugin installed for the org (GET plugins.list, as `quox plugin list`). Throws on failure. */
585
+ async pluginList() {
586
+ const id = orgId();
587
+ if (!id) throw new Error('no org');
588
+ const ep = resolveEndpoint('plugins.list', { orgId: id });
589
+ const rows = listOf(await client.get(ep.path, { service: ep.service, retries: 0 }), 'plugins', 'data', 'items');
590
+ if (!rows) throw new Error('unexpected response');
591
+ return rows;
592
+ },
593
+ /** One installed plugin's record (GET plugins.get, as `quox plugin get`), or null when unknown (404). */
594
+ async pluginGet(pluginId) {
595
+ const id = orgId();
596
+ if (!id) throw new Error('no org');
597
+ const ep = resolveEndpoint('plugins.get', { orgId: id, pluginId });
598
+ try { const d = await client.get(ep.path, { service: ep.service, retries: 0 }); return d?.plugin || d; }
599
+ catch (e) { if (e?.status === 404) return null; throw e; }
600
+ },
506
601
  /** Fleet hosts already connected (tips skip the QuoxAgent tip when > 0). GET agents list; throws on failure so a failure never reads as "no hosts". */
507
602
  async hostCount() {
508
603
  const ep = resolveEndpoint('v1.agents.list');
@@ -524,16 +619,38 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
524
619
  */
525
620
  /** SLOTS-REPLY2: the window registers how to name its checkout session; slotSay then sends it as holder_session. */
526
621
  bindHolderSession(fn) { holderSessionFn = typeof fn === 'function' ? fn : null; },
527
- async slotSay(name, text, { from = null, fast = false, roomSession = null, fromSlot = null, holderSession = holderSessionFn ? holderSessionFn() : null } = {}) {
528
- const ep = resolveEndpoint('v1.slots.say', { name });
529
- try {
622
+ async slotSay(name, text, { from = null, fast = false, roomSession = null, fromSlot = null, holderSession = holderSessionFn ? holderSessionFn() : null, anyOrg = true } = {}) {
623
+ const send = async (cl, oid) => {
624
+ const ep = resolveEndpoint('v1.slots.say', { name });
530
625
  // SLOTS-REPLY: fromSlot is this window's held slot, its reply address (the server verifies the hold).
531
- const d = await client.post(ep.path, { service: ep.service, retries: 0, body: { text, org_id: orgId(), ...(roomSession ? { room_session: roomSession } : from ? { from } : {}), ...(!roomSession && fromSlot ? { from_slot: fromSlot } : {}), ...(!roomSession && holderSession ? { holder_session: holderSession } : {}), ...(fast ? { bridge_report: false } : {}) } });
626
+ const d = await cl.post(ep.path, { service: ep.service, retries: 0, body: { text, org_id: oid, ...(roomSession ? { room_session: roomSession } : from ? { from } : {}), ...(!roomSession && fromSlot ? { from_slot: fromSlot } : {}), ...(!roomSession && holderSession ? { holder_session: holderSession } : {}), ...(fast ? { bridge_report: false } : {}) } });
532
627
  return { ok: true, message: d?.message || null, roomPending: d?.room_pending === true, presence: d?.presence || null, humanPresence: d?.human_presence || null };
533
- } catch (e) {
628
+ };
629
+ const fail = (e) => {
534
630
  if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
535
631
  return { ok: false, message: e?.status === 404 ? `no slot named ${name}` : (e?.message || 'failed'), status: e?.status || null };
632
+ };
633
+ let first;
634
+ try { return await send(client, orgId()); } catch (e) { first = fail(e); }
635
+ // 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.
637
+ if (!anyOrg || first.status !== 404 || !clientForOrg) return first;
638
+ 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 */ }
645
+ for (const oid of order) {
646
+ if (tried.has(oid)) continue; tried.add(oid);
647
+ try {
648
+ const cl = await clientForOrg(oid);
649
+ 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
536
652
  }
653
+ return first;
537
654
  },
538
655
  /** QUOXON-BROADCAST: fan a message out to the org's held slots. -> { ok:true, data } | { ok:false, message, status }. */
539
656
  async slotBroadcast({ text, slots = null, priority = 'normal', ack = false, from = null } = {}) {
@@ -563,6 +680,18 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
563
680
  const ep = resolveEndpoint('v1.slots.broadcast.ack', { id });
564
681
  try { return { ok: true, data: await client.post(ep.path, { service: ep.service, retries: 0, body: { slot, org_id: orgId() } }) }; } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
565
682
  },
683
+ /** QLOOP-LIVE: the org's loops, newest first (GET /api/v1/loops) -> { loops:[{ objective_id, title, state, created_at }] }. */
684
+ async loopList() {
685
+ return client.get('/api/v1/loops', { service: 'collector', retries: 0, timeout: 8000, query: { limit: 20 } });
686
+ },
687
+ /** QLOOP-LIVE: one loop's snapshot (GET /api/v1/loops/:id). Throws on failure (the card decides what a failure means). */
688
+ async loopSnapshot(id) {
689
+ return client.get(`/api/v1/loops/${encodeURIComponent(id)}`, { service: 'collector', retries: 0, timeout: 8000 });
690
+ },
691
+ /** QLOOP-LIVE: one loop's events (SSE; ?since resumes). Yields { event_seq, type, ... }; ends after loop.finished or a drop. */
692
+ async *loopEvents(id, { since = null } = {}, signal) {
693
+ yield* client.stream(`/api/v1/loops/${encodeURIComponent(id)}/events`, { service: 'collector', query: { since }, signal, untilClose: true });
694
+ },
566
695
  /** QUOXON-MESH: the slot's live messages (SSE). Yields { type: 'connected' | 'slot.message' | 'reconnect' }. */
567
696
  async *slotStream(name, { since = null, excludeFrom = null, session = null } = {}, signal) {
568
697
  const ep = resolveEndpoint('v1.slots.stream', { name });
@@ -678,6 +807,20 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
678
807
  if (!windowOrg) { const e = new Error('Org switching is not available in this mode.'); e.code = 'no_window_org'; throw e; }
679
808
  return windowOrg.switchTo(targetId);
680
809
  },
810
+ /**
811
+ * "also make default" from the /org picker: the same profile-wide change as `quox org switch <org>` in a shell
812
+ * (moves the shared session and rewrites the profile's stored org + token). This window's own org is untouched.
813
+ */
814
+ async makeOrgDefault(targetId) {
815
+ const ep = resolveEndpoint('auth.switch-org');
816
+ const data = await client.post(ep.path, { service: ep.service, body: { org_id: targetId } });
817
+ if (!data?.org?.id) { const e = new Error('The server did not confirm the switch.'); e.code = 'switch_failed'; throw e; }
818
+ const a = config.getAuth() || {};
819
+ a.org_id = data.org.id; a.org_name = data.org.name; a.org_slug = data.org.slug; a.org_role = data.role;
820
+ if (data.access_token) a.access_token = data.access_token;
821
+ config.setAuth(a);
822
+ return { id: data.org.id, name: data.org.name };
823
+ },
681
824
  /** The window's own org id when it differs from the profile's (carried across /restart), else null. */
682
825
  windowOrgId: () => windowOrg?.current()?.id || null,
683
826
  /** -> [{ id, name, description, edition, version, decayState }] ; the org's QSKL procedural skills (read-only list; Crystals are a separate store). */
@@ -823,6 +966,31 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
823
966
  }
824
967
  },
825
968
  /** /connect status: the org vault list (the only source a user can read). -> { ok:true, rows } | { ok:false, error }. */
969
+ /** LAZY-AI-CLI: which vendor CLIs (claude-code, codex) this server has. Never throws. */
970
+ async providerCliStatus() {
971
+ const ep = resolveEndpoint('v1.providers.cli.status');
972
+ try {
973
+ const d = await client.get(ep.path, { service: ep.service, retries: 0 });
974
+ return Array.isArray(d?.tools) ? { ok: true, tools: d.tools } : { ok: false, error: 'unexpected response' };
975
+ } catch (e) { return { ok: false, status: e?.status, missing: missing(e), error: e?.status ? `HTTP ${e.status} ${e.message || ''}`.trim() : (e?.message || 'failed') }; }
976
+ },
977
+ /** The install plan: source, exact package + version, download size. Instance admin only (403 otherwise). Never throws. */
978
+ async providerCliPlan(tool) {
979
+ const ep = resolveEndpoint('v1.providers.cli.plan', { tool });
980
+ try { const d = await client.get(ep.path, { service: ep.service, retries: 0 }); return d?.ok ? d : { ok: false, error: d?.message || 'unexpected response' }; }
981
+ catch (e) { return { ok: false, status: e?.status, missing: missing(e), error: e?.body?.message || e?.message || 'failed' }; }
982
+ },
983
+ /** Start an install job (the server audits it). Never throws. */
984
+ async providerCliInstall(tool, { approvalId = null } = {}) {
985
+ const ep = resolveEndpoint('v1.providers.cli.install', { tool });
986
+ try { return await client.post(ep.path, { service: ep.service, body: approvalId ? { approval_id: approvalId } : {}, retries: 0 }); }
987
+ catch (e) { return { ok: false, status: e?.status, error: e?.body?.message || e?.message || 'failed' }; }
988
+ },
989
+ async providerCliJob(id) {
990
+ const ep = resolveEndpoint('v1.providers.cli.job', { id });
991
+ try { return await client.get(ep.path, { service: ep.service, retries: 0 }); }
992
+ catch (e) { return { state: 'unknown', error: e?.message || 'failed', status: e?.status }; }
993
+ },
826
994
  async connections() {
827
995
  try {
828
996
  const ep = resolveEndpoint('vault.list', { orgId: orgId() });
@@ -905,6 +1073,24 @@ export function createApi({ client, config, auth, windowOrg = null, reattachBack
905
1073
  /** /hyperion: held VMs and who holds them, one line each (read only, GET /api/v1/proxmox/activity). */
906
1074
  async hyperion() { const data = await fetchActivity(client); return { lines: heldLines(data), tone: data.nodes_error || data.pool_error ? 'warn' : undefined }; },
907
1075
  approvals: listApprovals,
1076
+ /** 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 } = {}) {
1078
+ const ep = resolveEndpoint('inbox.list', { orgId: orgId() });
1079
+ const data = await client.get(ep.path, { service: ep.service, query: { limit } });
1080
+ const items = data?.items || data?.data || data || [];
1081
+ return Array.isArray(items) ? items : [];
1082
+ },
1083
+ /** One inbox item in full (the server marks it read for this user as a side effect). */
1084
+ async inboxItem(itemId) {
1085
+ const ep = resolveEndpoint('inbox.get', { orgId: orgId(), itemId: encodeURIComponent(itemId) });
1086
+ const data = await client.get(ep.path, { service: ep.service });
1087
+ return data?.item || data;
1088
+ },
1089
+ /** Mark one inbox item read (PATCH .../inbox/:id/read). */
1090
+ async inboxMarkRead(itemId, isRead = true) {
1091
+ const ep = resolveEndpoint('inbox.get', { orgId: orgId(), itemId: encodeURIComponent(itemId) });
1092
+ return client.patch(`${ep.path}/read`, { service: ep.service, body: { is_read: Boolean(isRead) } });
1093
+ },
908
1094
  /** Org-wide approvals awaiting a human (the dashboard header's server-side count, inbox stats awaiting_human). */
909
1095
  async approvalStats() {
910
1096
  const ep = resolveEndpoint('inbox.stats.list', { orgId: orgId() });