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.
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +20 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/index.js +12 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +173 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/v1.js +34 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +46 -7
- package/lib/commands/approvals.js +5 -0
- package/lib/commands/ask.js +3 -1
- package/lib/commands/blog.js +110 -0
- package/lib/commands/chat.js +127 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +3 -32
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +154 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/kuma.js +1050 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/loop.js +291 -0
- package/lib/commands/objective.js +48 -1
- package/lib/commands/org.js +21 -4
- package/lib/commands/provider.js +58 -0
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +18 -1
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +113 -0
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +20 -4
- package/lib/commands/vm.js +2 -2
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/client.js +10 -2
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +1 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +1 -3
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +36 -7
- package/lib/core/output.js +7 -7
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +23 -0
- package/lib/quoxon/api.js +192 -6
- package/lib/quoxon/app.js +655 -89
- package/lib/quoxon/approvalFacts.js +8 -6
- package/lib/quoxon/approvalSentence.js +44 -0
- package/lib/quoxon/approvalsView.js +63 -4
- package/lib/quoxon/commandShort.js +15 -3
- package/lib/quoxon/commands.js +75 -10
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dragonCompanion.js +255 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +82 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/events.js +24 -3
- package/lib/quoxon/extensions.js +276 -0
- package/lib/quoxon/inboxView.js +79 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +456 -0
- package/lib/quoxon/loopCards.js +180 -0
- package/lib/quoxon/loopStream.js +87 -0
- package/lib/quoxon/main.js +12 -3
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/orgSwitch.js +27 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +6 -3
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +130 -33
- package/lib/quoxon/roster.js +26 -12
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +76 -1
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/palette.js +1 -1
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +25 -1
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/suggestCommand.js +33 -2
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/whatsnew.jsonl +45 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/package.json +2 -1
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- 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
|
-
|
|
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
|
|
529
|
-
|
|
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
|
|
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
|
-
}
|
|
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() });
|