quoxcli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/inbox.js +6 -0
  9. package/lib/api/endpoints/inboxq.js +19 -0
  10. package/lib/api/endpoints/index.js +2 -0
  11. package/lib/api/endpoints/mon.js +122 -0
  12. package/lib/api/endpoints/tools.js +13 -0
  13. package/lib/api/endpoints/v1.js +13 -0
  14. package/lib/commands/admin.js +35 -0
  15. package/lib/commands/agentAttach.js +15 -0
  16. package/lib/commands/approvals.js +193 -3
  17. package/lib/commands/ask.js +1 -1
  18. package/lib/commands/auth.js +64 -11
  19. package/lib/commands/backup.js +11 -4
  20. package/lib/commands/capabilities.js +3 -2
  21. package/lib/commands/chat.js +15 -0
  22. package/lib/commands/claude.js +4 -1
  23. package/lib/commands/compliance.js +109 -0
  24. package/lib/commands/connect.js +69 -0
  25. package/lib/commands/dev.js +6 -8
  26. package/lib/commands/doctor.js +22 -3
  27. package/lib/commands/evidence.js +2 -1
  28. package/lib/commands/gripe.js +24 -13
  29. package/lib/commands/inbox.js +37 -3
  30. package/lib/commands/inboxq.js +147 -0
  31. package/lib/commands/kuma.js +78 -5
  32. package/lib/commands/licenceLinkView.js +311 -0
  33. package/lib/commands/linkedin.js +19 -0
  34. package/lib/commands/loop.js +30 -3
  35. package/lib/commands/lucid.js +1 -0
  36. package/lib/commands/mail.js +161 -20
  37. package/lib/commands/memory.js +68 -4
  38. package/lib/commands/memorySync.js +158 -0
  39. package/lib/commands/mon.js +55 -144
  40. package/lib/commands/notes.js +29 -7
  41. package/lib/commands/notesSeal.js +259 -0
  42. package/lib/commands/objective.js +5 -2
  43. package/lib/commands/offload.js +2 -3
  44. package/lib/commands/plan.js +17 -0
  45. package/lib/commands/provider.js +29 -8
  46. package/lib/commands/qlarity.js +32 -1
  47. package/lib/commands/run.js +2 -1
  48. package/lib/commands/server.js +37 -0
  49. package/lib/commands/setup.js +127 -75
  50. package/lib/commands/skills.js +4 -1
  51. package/lib/commands/slot.js +49 -9
  52. package/lib/commands/status.js +92 -44
  53. package/lib/commands/tool.js +30 -0
  54. package/lib/commands/vault.js +6 -3
  55. package/lib/commands/walkthrough.js +14 -2
  56. package/lib/commands/watch.js +11 -2
  57. package/lib/commands/wazuh.js +824 -0
  58. package/lib/commands/wazuhIntake.js +317 -0
  59. package/lib/commands/wazuhReport.js +192 -0
  60. package/lib/commands/workflow.js +47 -4
  61. package/lib/commands/x.js +50 -0
  62. package/lib/core/announce.js +86 -0
  63. package/lib/core/auth.js +20 -2
  64. package/lib/core/callMe.js +118 -0
  65. package/lib/core/client.js +27 -1
  66. package/lib/core/commandRegistrars.js +8 -0
  67. package/lib/core/config.js +28 -2
  68. package/lib/core/confirm.js +68 -7
  69. package/lib/core/crossOrgApprovals.js +69 -0
  70. package/lib/core/errors.js +52 -1
  71. package/lib/core/governedSession.js +135 -5
  72. package/lib/core/inboxResolution.js +3 -2
  73. package/lib/core/loginGuard.js +73 -0
  74. package/lib/core/offload.js +37 -0
  75. package/lib/core/org-override.js +5 -1
  76. package/lib/core/output.js +1 -1
  77. package/lib/core/profileTrust.js +182 -0
  78. package/lib/core/quoxglyph.js +22 -1
  79. package/lib/core/router.js +14 -0
  80. package/lib/core/stepUpPrompt.js +99 -0
  81. package/lib/core/version.js +1 -1
  82. package/lib/core/walkthrough.js +9 -5
  83. package/lib/dragon/select.js +2 -2
  84. package/lib/quoxon/EXTENSIONS.md +142 -19
  85. package/lib/quoxon/api.js +185 -34
  86. package/lib/quoxon/app.js +1032 -152
  87. package/lib/quoxon/approvalBatch.js +117 -0
  88. package/lib/quoxon/approvalFacts.js +56 -2
  89. package/lib/quoxon/approvalSentence.js +8 -4
  90. package/lib/quoxon/approvalsView.js +69 -11
  91. package/lib/quoxon/autoOpen.js +94 -0
  92. package/lib/quoxon/backgroundWork.js +59 -0
  93. package/lib/quoxon/bang.js +1 -1
  94. package/lib/quoxon/capabilities.js +7 -2
  95. package/lib/quoxon/commandShort.js +7 -0
  96. package/lib/quoxon/commands.js +40 -10
  97. package/lib/quoxon/connect.js +5 -3
  98. package/lib/quoxon/deckLazy.js +58 -0
  99. package/lib/quoxon/destructive.js +35 -0
  100. package/lib/quoxon/dnd.js +71 -0
  101. package/lib/quoxon/dragonCompanion.js +37 -7
  102. package/lib/quoxon/dragonMoments.js +78 -0
  103. package/lib/quoxon/echo.js +1 -0
  104. package/lib/quoxon/events.js +43 -7
  105. package/lib/quoxon/extensions.js +398 -38
  106. package/lib/quoxon/gripeQueue.js +135 -0
  107. package/lib/quoxon/inboxView.js +125 -22
  108. package/lib/quoxon/keys.js +2 -2
  109. package/lib/quoxon/loopCard.js +5 -1
  110. package/lib/quoxon/loopCards.js +17 -8
  111. package/lib/quoxon/loopStream.js +20 -9
  112. package/lib/quoxon/mailView.js +71 -0
  113. package/lib/quoxon/main.js +20 -5
  114. package/lib/quoxon/markdown.js +1 -1
  115. package/lib/quoxon/model.js +15 -0
  116. package/lib/quoxon/neverAnswered.js +20 -0
  117. package/lib/quoxon/objects/away.js +43 -5
  118. package/lib/quoxon/onboarding/connectServer.js +162 -0
  119. package/lib/quoxon/onboarding/copy.js +6 -0
  120. package/lib/quoxon/onboarding/index.js +5 -43
  121. package/lib/quoxon/onboarding/probe.js +20 -1
  122. package/lib/quoxon/onboarding/view.js +16 -3
  123. package/lib/quoxon/orgSwitch.js +22 -0
  124. package/lib/quoxon/print.js +2 -0
  125. package/lib/quoxon/profileSwitch.js +7 -0
  126. package/lib/quoxon/promptHistory.js +3 -1
  127. package/lib/quoxon/render.js +63 -14
  128. package/lib/quoxon/roster.js +10 -5
  129. package/lib/quoxon/rosterSessions.js +44 -0
  130. package/lib/quoxon/screenContext.js +1 -1
  131. package/lib/quoxon/secretShape.js +86 -0
  132. package/lib/quoxon/slash/agents.js +2 -1
  133. package/lib/quoxon/slash/argSources.js +9 -2
  134. package/lib/quoxon/slash/args.js +4 -2
  135. package/lib/quoxon/slash/broadcast.js +18 -5
  136. package/lib/quoxon/slash/broadcastRun.js +13 -2
  137. package/lib/quoxon/slash/cliLeaves.json +63 -1
  138. package/lib/quoxon/slash/hatWalk.js +1 -1
  139. package/lib/quoxon/slash/palette.js +22 -1
  140. package/lib/quoxon/slash/paletteController.js +10 -4
  141. package/lib/quoxon/slash/restart.js +7 -4
  142. package/lib/quoxon/slash/stepUp.js +5 -5
  143. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  144. package/lib/quoxon/slashSpinner.js +35 -0
  145. package/lib/quoxon/slotAttach.js +8 -0
  146. package/lib/quoxon/slotChat.js +17 -0
  147. package/lib/quoxon/slotFormat.js +1 -1
  148. package/lib/quoxon/slotOutbox.js +113 -0
  149. package/lib/quoxon/suggestCommand.js +1 -1
  150. package/lib/quoxon/term.js +25 -1
  151. package/lib/quoxon/transcript.js +2 -1
  152. package/lib/quoxon/welcome.js +1 -1
  153. package/lib/quoxon/whatsnew.js +1 -1
  154. package/lib/quoxon/whatsnew.jsonl +36 -0
  155. package/lib/server/installFlow.js +168 -0
  156. package/lib/server/status.js +15 -1
  157. package/lib/toolOutputShape.js +55 -0
  158. package/package.json +3 -2
  159. package/vendor/dragonlab/SOURCE +3 -3
  160. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  161. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  162. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  163. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  164. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  165. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  166. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  167. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  168. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  169. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  170. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  171. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  172. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  173. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  174. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  175. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -30,6 +30,7 @@ export const ICONS = {
30
30
  quoxseo: '📈 QUOXSEO', reach: '💥 REACH', vulcan: '🌋 VULCAN',
31
31
  quoxvoice: '📞 QUOXVOICE', quoxsecurity: '🛡️ QUOXSECURITY', quoxpert: '🎓 QUOXPERT',
32
32
  quoxslots: '📍 QUOXSLOTS',
33
+ gripe: '📣 GRIPE',
33
34
  quox: 'Ⓠ QUOX'
34
35
  };
35
36
  export const BUDGETS = { VERB_MAX: 48, RESULT_MAX: 40, LINE_MAX: 120, TREE_MAX: 6, TABLE_MAX: 6 };
@@ -74,7 +75,7 @@ export function line(surface, verb, opts = {}) {
74
75
  const col = on();
75
76
  const name = brandName(iconFor(surface), col);
76
77
  const sep = col ? `${DIM}│${R}` : '│';
77
- let tail = opts.result ? trunc(opts.result, BUDGETS.RESULT_MAX) : '';
78
+ let tail = opts.result ? trunc(opts.result, opts.max || BUDGETS.RESULT_MAX) : '';
78
79
  if (opts.ms !== undefined) {
79
80
  const d = col ? `${DIM}${opts.ms}ms${R}` : `${opts.ms}ms`;
80
81
  tail = tail ? `${tail} ${col ? DIM + '·' + R : '·'} ${d}` : d;
@@ -85,6 +86,26 @@ export function line(surface, verb, opts = {}) {
85
86
  return tail ? `${name} ${sep} ${v} ${sep} ${tail}` : `${name} ${sep} ${v}`;
86
87
  }
87
88
 
89
+ const GRIPE_TITLE_MAX = 50;
90
+ const gripeTitle = (t) => trunc(String(t || '').split('\n')[0].trim(), GRIPE_TITLE_MAX);
91
+
92
+ /** GRIPE-1460: `📣 GRIPE │ filed #N owner/repo │ "title" ✔` for an issue URL (the URL itself goes on the next line). */
93
+ export function gripeFiledLine(url, title) {
94
+ const m = /github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/.exec(String(url || ''));
95
+ const verb = m ? `filed #${m[2]} ${m[1]}` : 'filed issue';
96
+ return line('gripe', verb, { result: `"${gripeTitle(title)}"`, status: 'ok', max: GRIPE_TITLE_MAX + 2 });
97
+ }
98
+
99
+ /** GRIPE-1460: a gripe saved earlier and filed on retry: `📣 GRIPE │ queued gripe filed │ <url> ✔`. */
100
+ export function gripeQueuedFiledLine(url) {
101
+ return line('gripe', 'queued gripe filed', { result: String(url), status: 'ok', max: 200 });
102
+ }
103
+
104
+ /** GRIPE-1460: `📣 GRIPE │ file "title" │ ✖ <exact error>`. */
105
+ export function gripeFailedLine(title, error) {
106
+ return line('gripe', `file "${trunc(gripeTitle(title), 30)}"`, { result: `${STATUS.fail.glyph} ${error}`, max: 400 });
107
+ }
108
+
88
109
  /** Gradient progress meter: ▰▰▰▱ done/total (or pct when total omitted). */
89
110
  export function meter(done, total, width = 8) {
90
111
  const col = on();
@@ -8,6 +8,7 @@
8
8
  * output is tested without a router.
9
9
  */
10
10
  import { resolveEndpoint } from '../api/endpoints.js';
11
+ import { QuoxUserError } from './errors.js';
11
12
 
12
13
  /** verb -> the tool it runs. All read-only (aoclClassification layer 1, autonomy FULL). */
13
14
  export const ROUTER_TOOLS = Object.freeze({
@@ -21,6 +22,18 @@ export const ROUTER_TOOLS = Object.freeze({
21
22
  inventory: 'mikrotik_discovery_inventory',
22
23
  });
23
24
 
25
+ /** The tool route refused because this identity lacks the router grant: say that, keep the plumbing in detail. */
26
+ function notPermitted(tool, raw, path) {
27
+ const err = new QuoxUserError('This identity is not allowed to read the router: the router tools are not granted to it.', {
28
+ next: 'run it from an identity that has the router tools (`quox config profile list`), or ask an org owner or admin to grant them.',
29
+ code: 'not_permitted',
30
+ });
31
+ err.debug = { tool, route: `POST ${path}`, server_said: raw }; // --json and --verbose only; never the headline
32
+ err.toolError = raw;
33
+ err.tool = tool;
34
+ return err;
35
+ }
36
+
24
37
  export function makeRouterExec({ client, orgId }) {
25
38
  return async function exec(tool, input = {}) {
26
39
  const ep = resolveEndpoint('v1.tools.execute');
@@ -28,6 +41,7 @@ export function makeRouterExec({ client, orgId }) {
28
41
  if (orgId) body.org_id = orgId;
29
42
  const data = await client.post(ep.path, { service: ep.service, body });
30
43
  if (data && data.success === false) {
44
+ if (/not granted to agent/i.test(String(data.error || ''))) throw notPermitted(tool, data.error, ep.path);
31
45
  const detail = data.detail ? ` (${data.detail})` : '';
32
46
  const err = new Error(`${tool} via POST ${ep.path} returned success:false: ${data.error || 'unknown error'}${detail}`);
33
47
  err.toolError = data.error || 'unknown error';
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DS-010 / quox#1335: the CLI half of the human step-up PIN.
3
+ *
4
+ * The SERVER decides what needs a person and answers 403 step_up_required. This helper only reacts:
5
+ * on a terminal it asks for the PIN with hidden input, trades it for a short-lived step-up token
6
+ * (POST /api/auth/me/step-up, the same endpoint the Quox terminal uses) and retries the call ONCE with
7
+ * the token. Rules, all deliberate:
8
+ * - the PIN is read from a hidden prompt only. NEVER from argv, an env var or a file: an agent that can
9
+ * set an env var or a flag could supply it, and then the PIN proves nothing about a person
10
+ * - the PIN is never logged, echoed or put in an error message; the token is never stored
11
+ * - no terminal: refuse with a plain message saying a person must run it interactively
12
+ */
13
+ import { QuoxUserError } from './errors.js';
14
+ import { resolveEndpoint } from '../api/endpoints.js';
15
+
16
+ export const PIN_RE = /^\d{6}$/;
17
+ export const MAX_PIN_TRIES = 3;
18
+ export const STEP_UP_HEADER = 'x-step-up-token';
19
+ const NO_PIN_NEXT = 'set one in the dashboard: Settings > Account > Approval PIN, then run this again in a terminal.';
20
+
21
+ export function isStepUpRequired(err) {
22
+ return Boolean(err && err.status === 403 && err.body && err.body.error === 'step_up_required');
23
+ }
24
+
25
+ /** Real IO: hidden prompt via inquirer; a terminal means a TTY on both stdin and stdout. */
26
+ export function defaultPinIO() {
27
+ return {
28
+ isTTY: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
29
+ promptPin: async (message) => {
30
+ const { default: inquirer } = await import('inquirer');
31
+ const a = await inquirer.prompt([{ type: 'password', name: 'pin', message, mask: '*' }]);
32
+ return a.pin;
33
+ },
34
+ };
35
+ }
36
+
37
+ function nonTtyError(what) {
38
+ return new QuoxUserError(`${what} needs your step-up PIN, and there is no terminal to ask for it on.`, {
39
+ next: 'run this command yourself in an interactive terminal; it will ask for the PIN with hidden input. The PIN cannot be passed as a flag or an environment variable.',
40
+ exitCode: 4, code: 'step_up_required',
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Run `run(extraHeaders)`; if the server says step-up is required, get a token from the person and run it once more.
46
+ * @param {(headers:object)=>Promise<any>} run performs the guarded request, merging `headers` into it
47
+ * @param {{client:any, orgId?:string|null, scope?:string, io?:object, what?:string}} opts
48
+ */
49
+ export async function withStepUp(run, { client, orgId = null, scope = 'approve', io = defaultPinIO(), what = 'This approval' } = {}) {
50
+ try {
51
+ return await run({});
52
+ } catch (err) {
53
+ if (!isStepUpRequired(err)) throw err;
54
+ if (err.body?.reason === 'non_human') {
55
+ throw new QuoxUserError(`${what} needs a person. This login is an agent or service identity, which can never approve it.`, {
56
+ next: 'a person must approve it from their own login (the dashboard, or `quox` in their terminal).', exitCode: 4, code: 'step_up_human_only',
57
+ });
58
+ }
59
+ if (err.body?.pin_set === false) {
60
+ throw new QuoxUserError(`${what} needs your step-up PIN, and you have not set one yet.`, { next: NO_PIN_NEXT, exitCode: 4, code: 'no_pin_set' });
61
+ }
62
+ if (!io.isTTY()) throw nonTtyError(what);
63
+ const token = await collectToken({ client, orgId, scope, io, what });
64
+ return run({ [STEP_UP_HEADER]: token });
65
+ }
66
+ }
67
+
68
+ async function collectToken({ client, orgId, scope, io, what }) {
69
+ const ep = resolveEndpoint('stepup.verify');
70
+ for (let attempt = 1; attempt <= MAX_PIN_TRIES; attempt += 1) {
71
+ let pin = await io.promptPin(`${what} needs your step-up PIN (6 digits, hidden):`);
72
+ if (!PIN_RE.test(String(pin ?? ''))) {
73
+ pin = null;
74
+ if (attempt < MAX_PIN_TRIES) continue;
75
+ throw new QuoxUserError('A step-up PIN is exactly 6 digits. Nothing was approved.', { exitCode: 4, code: 'bad_pin_format' });
76
+ }
77
+ try {
78
+ const body = { pin, scopes: [scope] };
79
+ if (orgId) body.org_id = orgId;
80
+ const r = await client.post(ep.path, { service: ep.service, body });
81
+ pin = null;
82
+ if (r && r.step_up_token) return r.step_up_token;
83
+ throw new QuoxUserError('The server did not issue a step-up token. Nothing was approved.', { exitCode: 4, code: 'step_up_failed' });
84
+ } catch (e) {
85
+ pin = null;
86
+ if (e instanceof QuoxUserError) throw e;
87
+ const code = e?.body?.error || e?.code;
88
+ if (e?.status === 409 || code === 'no_pin_set') throw new QuoxUserError('You have not set a step-up PIN yet.', { next: NO_PIN_NEXT, exitCode: 4, code: 'no_pin_set' });
89
+ if (e?.status === 423 || code === 'pin_locked') throw new QuoxUserError('PIN entry is locked after too many wrong tries.', { next: 'wait for the lock to pass, or approve from the dashboard.', exitCode: 4, code: 'pin_locked' });
90
+ if (e?.status === 403 && code === 'step_up_human_only') throw new QuoxUserError(`${what} needs a person; this login is an agent identity.`, { next: 'approve from a human login.', exitCode: 4, code: 'step_up_human_only' });
91
+ if (e?.status === 401 || code === 'wrong_pin') {
92
+ if (attempt >= MAX_PIN_TRIES) throw new QuoxUserError('Wrong PIN. Nothing was approved.', { exitCode: 4, code: 'wrong_pin' });
93
+ continue;
94
+ }
95
+ throw e;
96
+ }
97
+ }
98
+ throw new QuoxUserError('Wrong PIN. Nothing was approved.', { exitCode: 4, code: 'wrong_pin' });
99
+ }
@@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { dirname, resolve } from 'node:path';
20
20
 
21
- export const BASE_VERSION = '0.4.0';
21
+ export const BASE_VERSION = '0.5.0';
22
22
 
23
23
  // The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
24
24
  // levels up is the tree whose HEAD identifies the running build.
@@ -3,13 +3,16 @@
3
3
  * card at a time, using the same card and reply rules as the TUI (quoxon/approvalsView.js). Identity is the decision id.
4
4
  * io = { readLine(prompt) -> Promise<string|null>, write(line), answer(item, key, note) -> Promise<void> }.
5
5
  */
6
- import { isDecision, walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary } from '../quoxon/approvalsView.js';
6
+ import { isDestructive } from '../quoxon/destructive.js';
7
+ import { isDecision, needsPin, approvalAsWalkItem, walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary } from '../quoxon/approvalsView.js';
7
8
 
8
9
  export async function runWalkthrough(items, io) {
9
- const decs = (items || []).filter(isDecision);
10
- if (!decs.length) { io.write('No question is waiting for you.'); return { answered: [], skipped: [], left: [] }; }
10
+ // Questions AND policy-held approval cards (kind approval, shaped as yes/no cards); other inbox kinds are not cards.
11
+ const decs = (items || []).filter((it) => isDecision(it) || it?.kind === 'approval').map(approvalAsWalkItem);
12
+ if (!decs.length) { io.write('No question or approval is waiting for you.'); return { answered: [], skipped: [], left: [] }; }
11
13
  const st = { answered: [], skipped: [], left: [] };
12
- io.write(`${decs.length} question${decs.length === 1 ? '' : 's'} waiting for you. One at a time: a choice, then an optional note.`);
14
+ const nApp = decs.filter((d) => d.__approval).length;
15
+ io.write(`${decs.length - nApp ? `${decs.length - nApp} question${decs.length - nApp === 1 ? '' : 's'}` : ''}${decs.length - nApp && nApp ? ' and ' : ''}${nApp ? `${nApp} approval${nApp === 1 ? '' : 's'}` : ''} waiting for you. One at a time: a choice, then an optional note.`);
13
16
  for (let i = 0; i < decs.length; i++) {
14
17
  const it = decs[i]; const card = walkCard(it, i + 1, decs.length);
15
18
  io.write(''); card.lines.forEach((l) => io.write(l));
@@ -21,7 +24,8 @@ export async function runWalkthrough(items, io) {
21
24
  if (rep.notYesNo) { io.write(walkNotYesNo(it)); continue; }
22
25
  if (rep.defer) { st.skipped.push(it.title || it.id); break; }
23
26
  if (rep.ask) { io.write(` Asked by ${it.payload.session || 'a session'} (id ${it.id}). ${it.payload.why || 'No further reason was given.'}`); continue; }
24
- if (it.payload.high_risk === true && rep.key !== 'n') { io.write(' High risk: a yes needs your PIN, which this plain terminal cannot take. Answer it in `quox` with /approvals walkthrough, or skip it here.'); continue; }
27
+ if (needsPin(it) && rep.key !== 'n') { io.write(' High risk: a yes needs your PIN, which this walkthrough cannot take. Run `quox approvals approve <id>` (it asks for the PIN) or answer in `quox` with /approvals walkthrough, or skip it here.'); continue; }
28
+ if (it.__approval && rep.key === 'y' && isDestructive(it)) { io.write(' This deletes something: its name must be typed, which this walkthrough does not take. Run `quox approvals approve <id>` in a terminal, or skip it.'); continue; }
25
29
  try { await io.answer(it, rep.key, rep.note || null); }
26
30
  catch (e) { io.write(` Not answered (${e.message}). The question stays open.`); continue; }
27
31
  const label = it.payload.options.find((o) => o.key === rep.key).label;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher): which one, whether to show it, and where its art lives.
2
+ * Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): which one, whether to show it, and where its art lives.
3
3
  * Pure. The art, animation and activity reducer stay in quoxai/dragonlab (integration/companions-v1);
4
4
  * Quox only chooses and loads. See docs/dragons/COMPANION-UX.md for the decisions behind each rule.
5
5
  */
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  export const VENDORED_DRAGONLAB = fileURLToPath(new URL('../../vendor/dragonlab', import.meta.url));
11
11
 
12
12
  /** Registry agent ids (and display names) to approved dragon. Others, including quox/commanderq, get none. */
13
- export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher' });
13
+ export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher', vulcan: 'vulcan', forge: 'vulcan', quoxperticus: 'quoxperticus' });
14
14
  export const COMPANION_MODES = ['on', 'greeting', 'off'];
15
15
  /** One-line change: the owner decides this after his Mac check (docs/dragons/COMPANION-UX.md section 4). */
16
16
  export const DEFAULT_COMPANION_MODE = 'on';
@@ -1,23 +1,146 @@
1
- # quoxon extensions
1
+ # quoxon extensions: contract v1
2
2
 
3
- Optional npm packages that add a surface to quoxon (a MIDI pad deck, a status bar). Core ships none and knows none by name.
3
+ Optional npm packages that add a surface to quoxon (a MIDI pad deck, a status bar, a fleet board). Core ships none and knows none by name.
4
+ An extension can OPEN an approval or PRESELECT an answer. It can never resolve one, and it never polls Quox: data is pushed to it.
4
5
 
5
- **Loading.** Nothing loads unless the package is installed AND listed in `~/.quox/extensions.json`
6
+ ## Loading
7
+
8
+ Nothing loads unless the package is installed AND listed in `~/.quox/extensions.json`
6
9
  (`quox ext add|remove|list <pkg>`; `/ext list|on|off <name>` inside quoxon). Install with `npm i -g <pkg>`.
10
+ Set env `QUOX_EXT_LIST=<path>` to use another list file (default `~/.quox/extensions.json`), for `quox ext` and quoxon alike (dev and tests).
7
11
  Resolution order: env `QUOX_EXT_<NAME>_MODULE` (name upper-cased, non-alphanumerics to `_`), a local `import(name)`,
8
- then the global npm root (`dirname(execPath)/../lib/node_modules` first, then `npm root -g` once, cached).
9
- `/ext list` names where each was found; a miss lists where it looked.
10
-
11
- **Module.** Default export (or the module itself):
12
- `{ name, version, commands?: [{ name: '/deck', help, run(args, ctx) }], create({ emit, log, settings }) -> { update(state), close() } }`.
13
- Its slash commands exist only while it is loaded. `run` may return a string or `{ text, tone }`.
14
-
15
- **State** (`update(state)`, deduped, at most 1/s):
16
- `{ org:{id,name}, approvals:{count, items:[{n,id,risk:'low'|'high',title,agent,options?:[{key,label}]}]}, health:{<name>:'ok'|'warn'|'down'},
17
- loops:[{id,title,turn,max,status}], fleet:{total,online,offline,warn,hosts:[{name,state:'ok'|'warn'|'down'}] (max 16)},
18
- evidence:{chain:'ok'|'broken'|'unknown', checked_at}, sessions:{active}, event?:{kind,text,level} }`.
19
- `fleet` comes from a 30 s poll of the host list while an extension is loaded. `evidence.chain` is `unknown` until quoxon holds a chain status.
20
-
21
- **emit.** `{type:'open_approval', n}` opens approval n; `{type:'pick', n, option}` only fills the composer with the
22
- answer command (the user presses Enter, high risk still needs the step-up PIN); `{type:'alert_ack'}` clears the event.
23
- An extension can never resolve an approval. emit is ignored once the extension is unloaded.
12
+ then the global npm root. `/ext list` names where each was found; a miss lists where it looked.
13
+
14
+ **contractVersion** (integer, this document is `1`). The host refuses an extension whose `contractVersion` is above what it supports,
15
+ with a line in `/ext list`: `needs extension contract v2, this quoxon supports v1`. An extension with no `contractVersion` is a
16
+ pre-contract (legacy) extension: it loads, and `emit` may use `n` without `id` (see F2). Declare `contractVersion: 1` to get the strict rules.
17
+
18
+ ## Module
19
+
20
+ Default export (or the module itself):
21
+
22
+ ```ts
23
+ interface Extension {
24
+ name: string; version?: string;
25
+ contractVersion?: 1;
26
+ commands?: Command[];
27
+ settings?: SettingDef[];
28
+ create(ctx: { emit(msg: Emit): boolean; log(text: string): void; settings: Record<string, string|number|boolean>; invalidate(): void }): Instance;
29
+ }
30
+ interface Instance {
31
+ update?(state: State): void; // deduped; see Freshness
32
+ close?(): void | Promise<void>; // see Close
33
+ panel?: Panel; // see Panels (one panel per extension)
34
+ onSettings?(next: Record<string, string|number|boolean>): void;
35
+ onEmitResult?(r: { ok: boolean; reason?: string; msg: Emit }): void;
36
+ }
37
+ ```
38
+
39
+ `panel`, `onSettings` and `onEmitResult` live on the object `create()` returns. There is no module-level `onSettings`.
40
+
41
+ ## Commands
42
+
43
+ ```ts
44
+ interface Command {
45
+ name: `/${string}`; help?: string;
46
+ subcommands?: { name: string; help?: string }[]; // feed /help and Tab completion
47
+ run(args: string[], ctx: { raw: string; say(text: string, tone?: 'ok'|'warn'|'accent'|'dim'): void; extension: string; loaded: string[] }): string | { text: string; tone?: string } | Promise<...>;
48
+ }
49
+ ```
50
+
51
+ `args` is the text after the command split on whitespace; `ctx.raw` is the same text unsplit. Commands exist only while the extension is loaded.
52
+ A thrown error is printed as `<cmd>: <message>`, never crashes the window.
53
+
54
+ ## State
55
+
56
+ `update(state)` is deduped (a push identical except `at` is dropped) and sent at most once per second.
57
+
58
+ ```ts
59
+ interface State {
60
+ at: string; // ISO time of this push
61
+ stale?: true; // present only when the host's last successful server read is over 60 s old
62
+ org: { id: string|null; name: string|null };
63
+ approvals: { count: number; items: { n: number; id: string|null; risk: 'low'|'high'; title: string; agent: string|null; options?: { key: string; label: string }[] }[] };
64
+ health: Record<string, 'ok'|'warn'|'down'>;
65
+ loops: { id: string; title: string; turn: number; max: number; status: string }[];
66
+ fleet: { total: number; online: number; offline: number; warn: number; hosts: { name: string; state: 'ok'|'warn'|'down' }[] }; // hosts max 16, may be empty
67
+ evidence: { chain: 'ok'|'broken'|'unknown'; checked_at: string|null }; // PLACEHOLDER: always 'unknown' until quoxon holds a chain status
68
+ sessions: { active: number };
69
+ event?: { seq: number; id?: string; kind: string; text: string; level: 'info'|'ok'|'warn'|'error' };
70
+ }
71
+ ```
72
+
73
+ **Freshness.** `state.at` is set on every push. While any extension is loaded the host pushes a heartbeat every 15 s, even when nothing
74
+ changed, so `at` keeps moving. `stale: true` means the data may be old (the server was not read for over 60 s): show it as such.
75
+
76
+ **Event ids.** `event.seq` is a positive integer, monotonic within one quoxon window session and never reused; a new event gets the next number,
77
+ the seq changes only when `kind`, `text`, `level` or `id` changes: a repeat of the held event keeps its seq until `alert_ack`. `event.id` appears only when the server supplied one.
78
+
79
+ `fleet` comes from a 30 s poll of the host list while an extension is loaded.
80
+
81
+ ## emit
82
+
83
+ ```ts
84
+ type Emit =
85
+ | { type: 'open_approval'; id: string; n?: number }
86
+ | { type: 'pick'; id: string; n?: number; option: string }
87
+ | { type: 'alert_ack' };
88
+ ```
89
+
90
+ `id` (from `approvals.items[].id`) is REQUIRED for `open_approval` and `pick`. `n` is display-only; when `id` and `n` disagree, `id` wins
91
+ (logged). An unknown, missing or already-resolved `id` is refused through `onEmitResult({ ok:false, reason, msg })`, and is never retargeted
92
+ to another item. `pick` only fills the composer with the answer command: the user presses Enter, high risk still needs the step-up PIN.
93
+ `alert_ack` clears the event. emit returns `true` when accepted and is ignored once the extension is unloaded.
94
+ (Legacy extensions without `contractVersion` may omit `id` and use `n`.)
95
+
96
+ ## Panels and tabs
97
+
98
+ ```ts
99
+ interface Panel {
100
+ title: string;
101
+ render(a: { width: number; height: number; focused: boolean; theme: Theme }): string[]; // pure
102
+ onKey?(key: string): boolean; // true = consumed, see Keys
103
+ onFocus?(): void; onBlur?(): void;
104
+ }
105
+ ```
106
+
107
+ ```ts
108
+ interface Theme { name: 'dark'|'light'|'mono'; color: boolean; unicode: boolean }
109
+ ```
110
+
111
+ The host owns the tab strip, shown only when one or more panels exist. The panel region sits above the composer, height
112
+ `min(12, 40% of terminal rows)`, collapsed until a panel is focused or opened. `/ext tab <name>` focuses (and opens) a panel; `/ext tab off`
113
+ collapses; Ctrl+] cycles focus through the panels; Esc returns keys to the composer (the panel stays open). While focused, keys go to
114
+ `onKey` first; a key it does not consume is dropped. The panel never receives `escape` or `ctrl+]`: the host consumes both.
115
+
116
+ **Keys.** `onKey(key)` gets one of `up down left right enter tab backspace space home end pageup pagedown`, otherwise the single typed
117
+ character (`a`, `?`, `/`), or `ctrl+<c>` / `alt+<c>` for the modifier combinations the terminal reports. Pasted text arrives one
118
+ character at a time.
119
+
120
+ `render` must be pure and fast. The 50 ms budget is wall-clock per render call (`performance.now`). The host clips the result to `height` lines and `width` columns. A render that throws, or takes over 50 ms,
121
+ shows `panel error` in its place and is retried no sooner than 5 s later; it never crashes the window. The panel re-renders on every state
122
+ push and on `invalidate()` (passed to `create`, throttled to 4 per second).
123
+
124
+ ## Settings
125
+
126
+ ```ts
127
+ interface SettingDef { key: string; type: 'string'|'number'|'bool'|'enum'; default: string|number|boolean; help: string; choices?: string[] }
128
+ ```
129
+
130
+ Values live in `~/.quox/extensions.json` on the extension's entry as `settings: { <key>: value }`. `create({ settings })` receives the resolved
131
+ object (defaults overlaid with stored values). `/ext set <name> <key> <value>` validates by type (number: `Number()` and it must be a finite number; bool: `true|yes|on|1` or
132
+ `false|no|off|0`, case-insensitive, anything else is rejected with a message; enum: an exact match to one of `choices`; string: any text), stores it, and calls `onSettings(next)` on the running instance.
133
+ `/ext settings <name>` lists each key with its value, default and help. The extension must be loaded and listed.
134
+
135
+ ## Close
136
+
137
+ `close()` may return a promise. The host awaits it for at most 500 ms, then drops the instance anyway and logs it. It must be idempotent
138
+ (the host may call it twice) and must free every timer, interval, watcher, device and process listener, because hot reload calls it on each change.
139
+
140
+ ## Hot reload
141
+
142
+ While an extension is loaded, quoxon watches its `package.json` (`fs.watch`, 2 s debounce, plus a 30 s stat poll because global installs
143
+ replace directories). When the version or mtime changes it awaits `close()` on the old instance (500 ms cap), drops its commands and panel,
144
+ re-imports the entry with a cache-busting `?v=<version>-<mtime>`, and calls `create()` again, then prints `↻ <name> updated <old> -> <new>`.
145
+ Only the entry module is re-imported. A failed reload leaves the old instance closed, prints the error once, and shows `failed: <error>` in
146
+ `/ext list` until the next change. Watchers and timers are cleared on `/ext off`, unload and exit.