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
@@ -4,6 +4,8 @@
4
4
  // when). Pure, so the card, the /approvals row and /approvals N detail cannot drift apart.
5
5
 
6
6
  export const CMD_CARD_MAX = 400;
7
+ /** Server text can arrive HTML-escaped ("2>/dev/null"); decode for display. Never renders HTML. */
8
+ const dec = (t) => (typeof t === 'string' ? t.replace(/&(amp|lt|gt|quot|apos|#39|#x27);/gi, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#x27': "'" })[e.toLowerCase()]) : t);
7
9
  const str = (v) => (typeof v === 'string' ? v : v == null ? '' : (() => { try { return JSON.stringify(v); } catch { return String(v); } })());
8
10
 
9
11
  /** Text cut at `max` with a visible, honest tail. `n` is the /approvals number when known. */
@@ -16,11 +18,11 @@ export function clipVisible(text, max, n = null) {
16
18
  /** The exact command or tool input, verbatim: method.command, else the reach request, else args. */
17
19
  export function commandOf(it) {
18
20
  const p = it?.payload || {}; const c = p.approval_context || {}; const m = c.method || {};
19
- if (typeof m.command === 'string' && m.command) return { text: m.command, kind: 'command' };
21
+ if (typeof m.command === 'string' && m.command) return { text: dec(m.command), kind: 'command' };
20
22
  const rq = (p.reach?.requests || []).find(r => r && r.kind === 'command' && typeof r.id === 'string' && r.id);
21
- if (rq) return { text: rq.id, kind: 'command' };
23
+ if (rq) return { text: dec(rq.id), kind: 'command' };
22
24
  const a = m.args ?? c.args ?? p.args ?? null;
23
- if (a != null && a !== '') return { text: str(a), kind: 'input' };
25
+ if (a != null && a !== '') return { text: dec(str(a)), kind: 'input' };
24
26
  return null;
25
27
  }
26
28
 
@@ -70,12 +72,12 @@ export function whoText(it) {
70
72
  /** WHY: the reason text (marked inferred when the caller did not state it). */
71
73
  export function whyText(it) {
72
74
  const c = it?.payload?.approval_context || {}; const r = c.reason;
73
- if (r && typeof r === 'object') return r.text ? `${r.text}${r.source === 'inferred' ? ' (inferred, not stated by the caller)' : ''}` : null;
74
- return r || it?.payload?.reason || null;
75
+ if (r && typeof r === 'object') return r.text ? `${dec(r.text)}${r.source === 'inferred' ? ' (inferred, not stated by the caller)' : ''}` : null;
76
+ return dec(r || it?.payload?.reason || null);
75
77
  }
76
78
 
77
79
  /** The user's own words that started this, when the server kept them. */
78
- export const excerptOf = (it) => it?.payload?.approval_context?.on_behalf_of?.request_excerpt || null;
80
+ export const excerptOf = (it) => dec(it?.payload?.approval_context?.on_behalf_of?.request_excerpt) || null;
79
81
 
80
82
  /** One bag of the facts, for the card. */
81
83
  export function approvalFacts(it, { n = null } = {}) {
@@ -0,0 +1,44 @@
1
+ // TUI-APPROVE: "You approved WHO to do WHAT on WHERE", built only from fields the card already shows.
2
+ // A field that is unknown is left out, never printed as "unknown".
3
+
4
+ const PLAIN = { // tool -> [infinitive, gerund]
5
+ ssh_exec: ['run a shell command', 'running a shell command'],
6
+ host_exec: ['run a shell command', 'running a shell command'],
7
+ host_restart: ['restart a service', 'restarting a service']
8
+ };
9
+
10
+ /** "VULCAN-SEC via FORGE (CLI) acting for Adam" -> "VULCAN-SEC". */
11
+ export function shortWho(raw) {
12
+ let w = String(raw || '').replace(/\s+acting for .*$/i, '').trim();
13
+ if (!w || /^unknown requester/i.test(w)) return '';
14
+ const sess = w.match(/\(via\s+session\s+([^,)]+)/i); // "FORGE (via session VULCAN-SEC, CLI)": the session is who asked
15
+ if (sess && sess[1].trim()) return sess[1].trim();
16
+ w = w.split('→').pop().split(/\s+via\s+/i)[0].replace(/\s*\(.*$/, '').trim();
17
+ return w;
18
+ }
19
+
20
+ const clean = (s) => String(s || '').replace(/\s+/g, ' ').replace(/[.\s]+$/, '').trim();
21
+
22
+ function whatOf(a) {
23
+ const plain = PLAIN[a.tool];
24
+ if (plain) return plain;
25
+ const w = clean(a.what && a.what !== a.tool ? a.what : '');
26
+ if (w) return [w, w];
27
+ const t = clean(String(a.tool || '').replace(/_/g, ' '));
28
+ return t ? [`use ${t}`, `using ${t}`] : ['', ''];
29
+ }
30
+
31
+ /** The one- or two-line confirmation for an answered card. */
32
+ export function approvalSentence(a, approved) {
33
+ const who = shortWho(a.who);
34
+ const [inf, ger] = whatOf(a);
35
+ const where = clean(a.where || a.target);
36
+ const said = (t) => where && new RegExp(`\\bon ${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i').test(t); // WHAT already ends "on <where>"
37
+ const at = where && !(said(inf) || said(ger) || new RegExp(`\\b${where.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(inf)) ? ` on ${where}` : '';
38
+ if (approved) {
39
+ const tail = a.foreign ? ' It runs now; its result isn\'t tracked here.' : '';
40
+ const head = who ? `${who}${inf ? ` to ${inf}` : ''}` : (ger || 'the request');
41
+ return `\u2714 You approved ${head}${at}.${tail}`;
42
+ }
43
+ return `\u2716 You denied ${who ? `${who} ` : ''}${ger || 'the request'}${at}.`;
44
+ }
@@ -110,12 +110,49 @@ export function filterPredicate(f, { userId = null, conversationId = null, now =
110
110
  return (it) => matchesAgent(it, f);
111
111
  }
112
112
 
113
+ /** What the item says it will do, in its own words: the server's what.summary or the post text, whichever is not just the generic "tool target". Null when there is nothing more specific. */
114
+ export function distinctWhat(it) {
115
+ const p = it?.payload || {}; const w = p.what && typeof p.what === 'object' ? p.what : {};
116
+ const generic = [w.action, w.target].filter(Boolean).join(' ').toLowerCase().replace(/\s+/g, ' ').trim();
117
+ const one = (t) => String(t ?? '').replace(/\s+/g, ' ').trim();
118
+ for (const c of [w.summary, p.action_params?.text, p.action_params?.body, p.action_params?.message]) {
119
+ const t = one(c); if (t && t.toLowerCase() !== generic) return t;
120
+ }
121
+ return null;
122
+ }
123
+ /** The content being approved, in full: summary, post text, images, body_md. */
124
+ export function contentRows(it) {
125
+ const p = it?.payload || {}; const w = p.what && typeof p.what === 'object' ? p.what : {}; const ap = p.action_params || {};
126
+ const imgs = [].concat(ap.image_paths || ap.images || []).filter(Boolean).map(String);
127
+ const text = ap.text ?? ap.body ?? ap.message ?? null;
128
+ const body = typeof p.body_md === 'string' ? p.body_md : null;
129
+ return [
130
+ ['summary', w.summary ? String(w.summary) : null],
131
+ ['content', text != null && String(text).trim() ? String(text).trim() : null],
132
+ ['images', imgs.length ? imgs.join(', ') : null],
133
+ ['details', body && body.trim() && body.trim() !== String(text ?? '').trim() ? body.trim() : null]
134
+ ].filter(r => r[1]);
135
+ }
136
+
137
+ /** A plain approval (not a quox ask) shaped as a y/n card so the walkthrough can show it. The original id stays, so /approve N routes by the same list. */
138
+ export function approvalAsWalkItem(it) {
139
+ if (isDecision(it)) return it;
140
+ const p = it?.payload || {}; const c = p.approval_context || {};
141
+ const dw = distinctWhat(it); const d = describeInboxItem(it);
142
+ const body = contentRows(it).filter(r => r[0] !== 'summary').map(r => `${r[0]}: ${r[1]}`).join('\n');
143
+ const why = [whyText(it) || c.why || p.reason || '', body].filter(Boolean).join('\n');
144
+ return { ...it, __approval: true, title: dw || d.title || it.title || it.id,
145
+ payload: { ...p, type: 'decision', session: requesterNames(it)[0] || approvalSourceLabel(it), why, high_risk: c.risk === 'high' || p.high_risk === true,
146
+ options: [{ key: 'y', label: 'Approve' }, { key: 'n', label: 'Deny' }] } };
147
+ }
148
+
113
149
  /** One line: [N] age who what (risk). Clipped to `cols`. */
114
150
  export function approvalRow(it, n, { cols = 100, now = Date.now() } = {}) {
115
151
  if (isDecision(it)) return decisionRow(it, n, { cols, now });
116
152
  const d = describeInboxItem(it);
117
153
  const who = requesterNames(it)[0] || approvalSourceLabel(it);
118
154
  let what = [d.tool, d.target && d.target !== d.tool ? d.target : null].filter(Boolean).join(' ') || d.title || it.id;
155
+ const dw = distinctWhat(it); if (dw && commandOf(it)?.kind !== 'command') what = dw; // two posts to the same account must not read the same
119
156
  const risk = ctxOf(it).risk ? ` (${ctxOf(it).risk})` : '';
120
157
  const priv = isPrivileged({ tool: d.tool, risk: ctxOf(it).risk, flag: it?.payload?.privileged === true || ctxOf(it).privileged === true, title: it.title }) ? ' [privileged]' : '';
121
158
  const cmd = commandOf(it);
@@ -150,6 +187,7 @@ export function approvalDetail(it, n, { now = Date.now() } = {}) {
150
187
  ['who', whoText(it)],
151
188
  ['why', why ? String(why) : null],
152
189
  ['request', excerptOf(it) ? `"${excerptOf(it)}"` : null],
190
+ ...contentRows(it).filter(r => !(r[0] === 'summary' && String(r[1]) === String(why ?? ''))),
153
191
  ['class', accessOf(it)],
154
192
  ['from', requesterNames(it).join(', ') || approvalSourceLabel(it)],
155
193
  ['asked', d.at ? `${ageText(d.at, now)} ago (${String(d.at)})` : null],
@@ -235,7 +273,7 @@ export function walkNoCatch(it) {
235
273
  }
236
274
  /** A title without the asker's own name in front of it ("SEER: Publish?" asked by SEER -> "Publish?"), whitespace and line breaks folded. */
237
275
  export function cleanTitle(it) {
238
- const p = it.payload || {}; const t = String(it.title || it.id || '').replace(/\s+/g, ' ').trim();
276
+ const p = it.payload || {}; const t = decodeEntities(String(it.title || it.id || '')).replace(/\s+/g, ' ').trim();
239
277
  for (const name of [p.session, p.agent].filter(Boolean).map(String)) {
240
278
  if (t.toLowerCase().startsWith(name.toLowerCase() + ':')) { const rest = t.slice(name.length + 1).trim(); if (rest) return rest; }
241
279
  }
@@ -252,6 +290,27 @@ export function hang(label, text, cols) {
252
290
  if (cur || !out.length) out.push(cur);
253
291
  return out.map((l, k) => (k ? pad : lead) + l);
254
292
  }
293
+ /** Like hang, but keeps the text's own line breaks: each line (a "- " list item too) starts a fresh line under the label, wrapped items indented past the dash. */
294
+ export function hangBlock(label, text, cols) {
295
+ const segs = String(text ?? '').split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
296
+ if (segs.length <= 1) return hang(label, segs[0] || '', cols);
297
+ const lead = ' ' + label.padEnd(8); const pad = ' '.repeat(lead.length);
298
+ const room = Math.max(20, (cols || 0) - lead.length - 1); const out = [];
299
+ for (const seg of segs) {
300
+ if (!cols) { out.push(seg); continue; }
301
+ const ind = /^[-*] /.test(seg) ? ' ' : '';
302
+ let cur = '';
303
+ for (const w of seg.split(' ')) {
304
+ if (cur && cur.length + 1 + w.length > room) { out.push(cur); cur = ind + w; } else cur = cur ? cur + ' ' + w : w;
305
+ }
306
+ if (cur) out.push(cur);
307
+ }
308
+ return out.map((l, k) => (k ? pad : lead) + l);
309
+ }
310
+ /** Slot and server text can arrive HTML-escaped ("-&gt;"); decode for display. Never renders HTML. */
311
+ export function decodeEntities(t) {
312
+ return String(t ?? '').replace(/&(amp|lt|gt|quot|apos|#39|#x27);/gi, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#x27': "'" })[e.toLowerCase()]);
313
+ }
255
314
  /** Owner 2026-10-17: a test or guard loosening needs a plain justification. True when the title, kind or tags say the ask would make a check easier to pass. */
256
315
  export const LOOSEN_RE = /loosen|weaken|relax|widen|(?:delete|remove|skip|disable|replace|rewrite|change|edit|update)\w*\s+(?:\w+\s+){0,3}?(?:tests?|guards?|checks?|assertions?|proof cases?)\b|test.?pin|goal ?post/i;
257
316
  export const isLoosening = (it) => { const p = it?.payload || {}; return p.loosening === true || LOOSEN_RE.test([it?.title, p.kind, p.category, ...(Array.isArray(p.tags) ? p.tags : [])].filter(Boolean).join(' ')); };
@@ -265,7 +324,7 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
265
324
  const yn = p.options.some(o => o.key === 'y') && p.options.some(o => o.key === 'n');
266
325
  const rec = bareLoose ? null : (p.options.find(o => o.key === p.recommend) || null);
267
326
  const dflt = bareLoose ? (p.options.find(o => o.key === 'n') || null) : (p.options.find(o => o.key === p.default) || null);
268
- const why = String(p.why || '').replace(/\s+/g, ' ').trim();
327
+ const why = decodeEntities(String(p.why || '')).split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n');
269
328
  const what = yn
270
329
  ? `Yes lets ${who} go ahead with this; no stops it.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`
271
330
  : `${who} needs you to choose how to settle this.${p.high_risk ? ' High risk: a yes needs your PIN.' : ''}`;
@@ -281,10 +340,10 @@ export function walkCard(it, i, total, { cols = 0 } = {}) {
281
340
  { key: keyClash('?') ? 'ask' : '?', label: 'Ask about it', ask: true },
282
341
  ];
283
342
  const lines = [
284
- `Decision ${i} of ${total} · ${who} asks`,
343
+ `${p.__approval ? 'Approval' : 'Decision'} ${i} of ${total} · ${who} asks`,
285
344
  ...hang('', cleanTitle(it), cols).map(l => l.slice(8)),
286
345
  ...hang('WHAT', what, cols),
287
- ...(why ? hang('WHY', why, cols) : []),
346
+ ...(why ? hangBlock('WHY', why, cols) : []),
288
347
  ...(loose || just ? hang('JUSTIFY', just || NO_JUSTIFICATION, cols) : []),
289
348
  ...(!why && !just && !loose ? hang('WHY', NO_REASON, cols) : []),
290
349
  ...hang('ADVICE', advice, cols),
@@ -7,6 +7,7 @@
7
7
  export const SHORT_MAX = 54;
8
8
  export const SHORT = {
9
9
  walkthrough: 'a guided pass: what it does, why, my advice',
10
+ inbox: 'your Quox inbox: latest items, read, open',
10
11
  approvals: 'the questions waiting for you, one at a time',
11
12
  peek: 'look up an envelope, item, run or receipt by id',
12
13
  aside: 'a read-only side question, kept out of the chat',
@@ -20,13 +21,17 @@ export const SHORT = {
20
21
  skill: 'ask Quox to run a skill (approvals still apply)',
21
22
  rules: 'which rules win: QUOX.md, dev pack, server policy',
22
23
  clear: 'fresh conversation and a clear screen',
24
+ stop: 'stop the current turn, same as Esc',
25
+ devservers: 'your registered dev servers, and how to open one',
23
26
  resume: 'pick a recent conversation and carry on',
24
27
  compact: 'summarise the conversation, keeping decisions',
25
28
  challenge: 'a second opinion on the last answer (one extra turn)',
26
29
  spawn: 'ask HYPERION for a disposable VM (max 24h)',
27
30
  consent: 'AI data sharing: see where you stand, or agree',
31
+ contact: 'add a contact, or list them (as quox contacts)',
28
32
  help: 'this reference, or detail for one command',
29
- note: 'jot a note for the dev observer (also // text)',
33
+ note: 'save a note to NotesQ (/notes lists them)',
34
+ devnote: 'jot a note for the dev observer (also // text)',
30
35
  share: 'attach your last ! command\'s output to your message',
31
36
  copy: 'copy the last answer to the clipboard',
32
37
  dragon: 'the Q comes alive; explain, ask, sleep',
@@ -95,10 +100,14 @@ export const SHORT = {
95
100
  afk: 'tell sessions you are away; approvals still queue',
96
101
  back: 'mark yourself present and show what you missed',
97
102
  observe: 'post a note to the session watching your window',
98
- huh: 'the dragon explains what just happened',
99
- sleep: 'mark yourself sleeping; the dragon curls up',
103
+ huh: 'explain the last output in plain words',
104
+ sleep: 'say you are asleep; sessions stop waiting on you',
100
105
  mode: 'ask first (default) or plan only',
101
106
  slots: 'held slots: who holds them and where',
107
+ todo: 'add an Action Tracker item; /todo alone lists',
108
+ done: 'mark Action Tracker items done: /done td1,td2',
109
+ todos: 'your Action Tracker by column: td1 do1 dn1',
110
+ plugins: 'installed plugins (extensions) and their state',
102
111
  mirror: 'what CommanderQ sees about this org right now',
103
112
  health: 'dashboard summary: services, agents, approvals',
104
113
  status: 'profile, org, server health, model, agent, slot',
@@ -108,5 +117,8 @@ export const SHORT = {
108
117
  server: 'your self-hosted install: version, health, disk',
109
118
  config: 'settings: theme, motion, verbose, model',
110
119
  model: 'choose the provider and model for your next turns',
120
+ qpe: 'how many proof engine rows show',
111
121
  row: 'your own live lines above the status bar',
122
+ deck: 'QuoxDeck MIDI pads (the quoxdeck extension)',
123
+ ext: 'installed extensions: list, turn on or off',
112
124
  };
@@ -34,7 +34,7 @@ const ARGS = {
34
34
  fleet: [{ after: [], src: 'fleetHosts' }],
35
35
  replay: [{ after: [], src: 'conversations' }],
36
36
  org: [
37
- { after: [], choices: [
37
+ { after: [], src: 'orgs', choices: [
38
38
  { value: 'switch', desc: 'move this session to another org' },
39
39
  { value: 'back', desc: 'return to the org you were in before' },
40
40
  { value: 'list', desc: 'your orgs' }] },
@@ -62,7 +62,11 @@ const ARGS = {
62
62
  say: [{ after: [], src: 'slots' }],
63
63
  broadcast: [{ after: [], choices: [{ value: '--slots', desc: 'only these slots, comma separated' }, { value: '--priority', desc: 'urgent shows red' }, { value: '--ack', desc: 'ask recipients to /ack' }] }],
64
64
  send: [{ after: [], src: 'slots' }, { after: ['*'], src: 'files', of: 'partial' }],
65
- dragon: [{ after: [], choices: ['explain', 'ask', 'sleep', 'fly', 'hero', 'fire', 'classic'] }],
65
+ dragon: [
66
+ { after: [], choices: ['explain', 'ask', 'sleep', 'fly', 'hero', 'fire', 'classic', { value: 'companion', desc: 'the dragon beside an attached Lucid, Hyperion, Seer, Sentinel, Drago or Cipher' }] },
67
+ { after: ['companion'], choices: [{ value: 'on', desc: 'on attach and key moments, then leaves' }, { value: 'greeting', desc: 'only on attach, then leaves' }, { value: 'off', desc: 'never on its own (/dragon shows it)' }, { value: 'status', desc: 'what is set and whether the art loads' }] }
68
+ ],
69
+ inbox: [{ after: [], choices: [{ value: 'read', desc: 'read N | all: mark items read' }, { value: 'open', desc: 'open N: take an approval to /approvals' }, { value: 'latest', desc: 'the latest 10' }] }],
66
70
  tips: [{ after: [], choices: topicList().map(t => ({ value: t.topic, desc: `${t.count} tip${t.count === 1 ? "" : "s"}` })) }],
67
71
  whatsnew: [{ after: [], choices: [{ value: 'all', desc: 'every command and entry, regardless of seen' }, { value: 'on', desc: 'show the card at startup when something is new' }, { value: 'off', desc: 'stop the startup card' }] }],
68
72
  'tip-settings': [{ after: [], choices: [
@@ -93,6 +97,19 @@ const ARGS = {
93
97
  ]
94
98
  };
95
99
 
100
+ // Dev-only entries (dev: true) are vendor tooling: they live in lib/vendor/devCommands.js, which package.json
101
+ // "files" keeps out of the published quoxcli (same mechanism as the owner-only licence verbs, quox#1134).
102
+ // Present in the dev tree, absent for every npm user.
103
+ let DEV_ENTRIES = [];
104
+ // TUI-QPE 2026-10-20 (owner: "/qpe is extension right?"): owner tooling that is not dev-observer gated also lives in
105
+ // lib/vendor (VENDOR_ENTRIES), so the public build has no /qpe; customers have no proof engine.
106
+ let VENDOR_ENTRIES = [];
107
+ try {
108
+ const v = await import('../vendor/devCommands.js');
109
+ DEV_ENTRIES = v.DEV_ENTRIES || [];
110
+ VENDOR_ENTRIES = v.VENDOR_ENTRIES || [];
111
+ } catch (e) { if (e?.code !== 'ERR_MODULE_NOT_FOUND') throw e; }
112
+
96
113
  export const REGISTRY = [
97
114
  // Talk (keys)
98
115
  { id: 'type', kind: 'key', group: 'Talk', usage: 'just type', desc: 'ask Quox anything; CommanderQ routes it to the right agent', status: 'active' },
@@ -110,6 +127,7 @@ export const REGISTRY = [
110
127
  { id: 'ctrlc', kind: 'key', group: 'Talk', usage: 'Ctrl+C', desc: 'clear the draft; during a turn interrupt; twice when idle quits', status: 'active' },
111
128
  // Approvals
112
129
  { id: 'approvals', kind: 'command', group: 'Approvals', usage: '/approvals [mine|agents|old|<agent>|N]', desc: 'the questions waiting for you, one at a time', server: true, cap: 'approvals', status: 'active' },
130
+ { id: 'inbox', kind: 'command', new: true, group: 'Approvals', usage: '/inbox [N | read N|all | open N]', desc: 'your Quox inbox: the latest 10 as a table, N shows one in full, read marks read, open takes an approval to /approvals', needs: ['inboxItems'], cap: 'approvals', status: 'active', args: ARGS.inbox },
113
131
  { id: 'walkthrough', kind: 'command', new: true, group: 'Approvals', usage: '/walkthrough', desc: 'walk through what is waiting for you one question at a time: what it does, why, my advice, then your choice (same as /approvals walkthrough)', server: true, cap: 'approvals', status: 'active' },
114
132
  { id: 'yestoall', kind: 'command', new: true, group: 'Approvals', usage: '/yestoall', desc: 'approve everything waiting for you (same as /approve all; privileged ones still ask)', server: true, cap: 'approvals', status: 'active' },
115
133
  { id: 'approve', kind: 'command', group: 'Approvals', usage: '/approve N | 1 y 2 n | agent <name>', desc: 'approve item N from the list, or everything one agent asked', server: true, cap: 'approvals', status: 'active',
@@ -128,11 +146,21 @@ export const REGISTRY = [
128
146
  detail: 'Lists your org skills (reusable procedures an agent follows; not Crystals, which are deterministic replays) with edition and one-line description. /skills <name> shows its card: purpose, version and review state, tests (results only when run-tests recorded them), the agents it is for, where it came from, recorded use, and how to run it.' },
129
147
  { id: 'skill', kind: 'command', group: 'Skills', usage: '/skill <name> [args]', desc: 'ask Quox to run a skill (governed: approvals still apply)', server: true, cap: 'skills', status: 'active',
130
148
  detail: 'Sends "Run the skill <name> with: <args>" as a normal turn, so CommanderQ runs it through the usual governed path. Nothing is executed from here, and approval cards work as always.' },
149
+ { id: 'plugins', kind: 'command', group: 'Skills', usage: '/plugins [name]', desc: 'plugins (extensions, add-ons) your org has installed: enabled state and version; /plugins <name> shows one', needs: ['pluginList'], status: 'active', aliases: ['plugin', 'extensions', 'extension', 'addons', 'apps'],
150
+ detail: 'Read-only. Lists the plugins installed for the active org (the same call as quox plugin list) with enabled or disabled and the version. /plugins <name> shows one plugin (as quox plugin get). Also answers to /extensions, /extension, /addons and /apps; "/extension list" works too.' },
151
+ { id: 'todo', kind: 'command', group: 'Awareness', usage: '/todo <text>', desc: 'add an Action Tracker item; /todo move do2 done moves one; /todo alone lists', needs: ['todoAdd', 'todoList', 'todoMove'], status: 'active',
152
+ detail: 'Moves: /todo move do5 done, /todo do5 done, /todo mark do2 done, /todo td3 doing (td = to do, do = doing, dn = done; the numbers are the ones /todos last showed, and if the list changed since, it says so and still moves the item you saw). Adds one item to your Action Tracker (status todo) as you, the same as quox actions add "<text>" or adding it in the dashboard, then confirms with the item id. No model call and no approval: you typing it is the authorization. /todo with no text lists your open items, the same as /todos. Mark one done with quox actions done <id>.' },
153
+ { id: 'done', kind: 'command', group: 'Awareness', usage: '/done <ids>', desc: 'mark Action Tracker items done: /done td1,td2', needs: ['todoList', 'todoMove'], status: 'active',
154
+ detail: 'Marks Action Tracker items done by the short ids /todos last showed: /done td1,td2 or /done td1 td2. Each one is confirmed by title. Same as /todo move td1,td2 done. No model call and no approval: you typing it is the authorization.' },
155
+ { id: 'todos', kind: 'command', group: 'Awareness', usage: '/todos', desc: 'your Action Tracker by column, numbered td1 do1 dn1 for /todo move', needs: ['todoList'], status: 'active',
156
+ detail: 'Read-only. Lists your whole Action Tracker (every page) by column: To do td1.., Doing do1.., Done dn1.. (the 10 newest; the rest via quox actions list --status done), newest first. Use the numbers with /todo move do2 done. /todo <text> adds one.' },
131
157
  { id: 'rules', kind: 'command', group: 'Skills', usage: '/rules [files|off|on|N]', desc: 'which rules win: advisory QUOX.md, the dev pack, enforced server policy', status: 'active',
132
158
  detail: '/rules shows the precedence: QUOX.md and the dev pack only guide; armed governance policies on the server are enforced and win. /rules files shows the QUOX.md text. QUOX.md in this directory and parents up to the git root, plus ~/.quox/QUOX.md, are sent with each turn as your rules. World-writable files and symlinks leaving your home or repo are ignored. /rules N shows page N.' },
133
159
  // Conversation
134
160
  { id: 'clear', kind: 'command', group: 'Conversation', usage: '/clear', desc: 'fresh conversation and a clear screen; rules and memory kept, /resume goes back', status: 'active', aliases: ['new', 'reset'],
135
161
  detail: 'Clears the screen and starts a new conversation. Your QUOX.md rules, attached agent, model choice and Quox memory stay as they are. The previous conversation is kept on the server: /resume lists it.' },
162
+ { id: 'stop', kind: 'command', group: 'Conversation', usage: '/stop', desc: 'stop the current turn, same as Esc', status: 'active', aliases: ['cancel'],
163
+ detail: 'Stops the turn that is running, exactly like Esc: the stream is cancelled, the working animation ends and the status line goes back to normal. /cancel does the same. With nothing running it says so.' },
136
164
  { id: 'resume', kind: 'command', group: 'Conversation', usage: '/resume [id|name]', desc: 'pick a recent conversation and carry on where it left off', status: 'active', needs: ['conversations', 'conversationTurns'],
137
165
  detail: 'Alone: a picker of your recent conversations (title, agent, age); Up/Down and Enter resume, Esc cancels. With an id (or the start of one) or a /rename name it resumes directly. The last turns are shown as history and your next message continues that conversation. Also at launch: quox --resume [id|name] or quox --conversation <id>.' },
138
166
  { id: 'compact', kind: 'command', group: 'Conversation', usage: '/compact [show]', desc: 'summarise the conversation on the server, keeping decisions', status: 'active', needs: ['compactConversation'],
@@ -149,15 +177,19 @@ export const REGISTRY = [
149
177
  'suggested too (providers, live model ids, orgs, agents, slots, options): Up and Down' +
150
178
  ' choose, Tab completes, Esc closes, and keep typing to narrow. Suggestions that ' +
151
179
  'cannot be fetched are simply left out.' },
152
- { id: 'note', kind: 'command', group: 'Conversation', usage: '/note <text>', desc: 'jot an observation for the dev observer (also: // text); not sent to Quox', status: 'active', dev: true,
153
- detail: 'Records your text in the dev transcript only: not sent to the model or the server, no turn, not in chat history. Needs dev transcripts on (touch ~/.config/quox/dev-transcripts). Up to 2000 characters.' },
180
+ { id: 'note', kind: 'command', group: 'Conversation', usage: '/note <text>', desc: 'save a NotesQ note (first line is the title); not sent to Quox. /notes lists them', status: 'active',
181
+ detail: 'Creates a NotesQ note through the same backend as quox notes new: the first line (or first 60 characters) becomes the title and your text is the body. It confirms with the title and id. If NotesQ is not licensed or enabled for your org it says so and how to enable it. /notes lists recent notes (quox notes ls).' },
182
+ { id: 'notes', kind: 'command', group: 'Conversation', usage: '/notes', desc: 'list your recent NotesQ notes (same as quox notes ls)', status: 'active',
183
+ detail: 'Runs quox notes list in this window. /note <text> saves a new one.' },
184
+ { id: 'contact', kind: 'command', group: 'Conversation', usage: '/contact new <name> [--phone N] [--email E] [--relationship R]', desc: 'add a contact (same as quox contacts add); /contact list pages them', status: 'active',
185
+ detail: 'Builds a quox contacts add command from what you type: --mobile, --tel and --number become --phone, a multi-word name is quoted for you, and a flag the command does not have is refused with the list it does take. It goes through the same approval rules as the CLI.' },
154
186
  { id: 'bang', kind: 'key', group: 'Talk', usage: '!<command>', desc: 'run a shell command here without leaving (!quox <args> runs this quox as you); output stays on your screen', status: 'active',
155
187
  detail: 'A line starting with ! runs on this machine in the current directory via your shell and prints inline: "$ <command>", the output, then "exit N". It is never sent to Quox, starts no turn and needs no approval, because it is your own command. !quox <args> (or !q <args>) runs THIS quox binary with your current profile, so !quox inbox list works as you; bare quox and tui need the terminal and are refused. Ctrl+C stops it (SIGTERM, then SIGKILL after 2 s) without quitting; one command at a time; stdin is closed, so a command that waits for input gets end-of-file; 120 s limit (QUOX_BANG_TIMEOUT_MS); output is capped at 2000 lines / 256 KB. !! is reserved. Only the command line and exit code go in the dev transcript, never the output.' },
156
188
  { id: 'share', kind: 'command', group: 'Talk', usage: '/share [N]', desc: 'attach the output of your last ! command (or the Nth-last) to your next message', status: 'active',
157
189
  detail: 'Command output is not shared with Quox by default, because it can contain secrets. /share attaches the latest ! result to your next message (up to 16 KB, and it says when it was cut); /share 2 picks the one before. A line "shared with Quox: output of `<command>`" appears when it is sent.' },
158
190
  { id: 'copy', kind: 'command', group: 'Conversation', usage: '/copy [N|code]', desc: 'copy the last answer (raw markdown) to the clipboard; N = Nth-last, code = last code block', status: 'active',
159
191
  detail: 'Uses the OSC 52 terminal escape (works over SSH in most terminals; wrapped for tmux). Needs a terminal that allows OSC 52. Mouse selection is untouched: select and copy with your terminal as usual.' },
160
- { id: 'dragon', kind: 'command', group: 'Conversation', usage: '/dragon [explain|ask [question]|sleep|fly|hero|fire|classic]', desc: 'the Q comes alive; /dragon explain gives the simplest possible explanation of the last output, /dragon ask [question] answers one question just as simply, /dragon sleep is a katakana screensaver (any key wakes)', status: 'active', args: ARGS.dragon,
192
+ { id: 'dragon', kind: 'command', group: 'Conversation', usage: '/dragon [explain|ask [question]|sleep|fly|hero|fire|classic|companion on|greeting|off|status]', desc: 'the Q comes alive; /dragon explain gives the simplest possible explanation of the last output, /dragon ask [question] answers one question just as simply, /dragon sleep is a katakana screensaver (any key wakes)', status: 'active', args: ARGS.dragon,
161
193
  detail: 'Plain /dragon plays the dragon (googly eyes, a talking smile, tail sway and whip, then fire from its mouth); classic and hero are the same show, /dragon fire is the breath alone. explain and ask are ordinary turns on whichever AI you have selected (tools and approvals still apply); the answer stays in the conversation as "Dragon explains" or "Dragon answers". With no AI connected you get the usual connect card. /dragon ask with no question waits for your next line. /dragon sleep hides the screen under katakana with the dragon asleep; any key wakes it and is not typed, an approval wakes it, Ctrl+C quits. It only changes what is drawn, never the conversation. Quiet motion or NO_COLOR shows a still screen.' },
162
194
  { id: 'dragon-fly', kind: 'command', group: 'Conversation', usage: '/dragon-fly', desc: 'the dragon flies across the screen, wings beating (same as /dragon fly)', status: 'active',
163
195
  detail: 'One 9 second pass of the dragon in flight, wings beating; Esc stops it early. It only changes what is drawn, never the conversation. Quiet motion or NO_COLOR shows a single still frame instead.' },
@@ -234,6 +266,8 @@ export const REGISTRY = [
234
266
  detail: 'off (default) keeps the screen to your conversation, the tool result lines and ONE AOCL footer per turn (layers, tool count, time). on shows the compact AEE / VOLT / AOCL / WARD protocol lines as they happen. full adds the thinking stream. Saved to your profile. The full list of tools offered to the model for the last turn is in /aocl.' },
235
267
  { id: 'proof', kind: 'command', group: 'Verbose', usage: '/proof [envelope]', desc: 'the evidence chain for the last tool call: AEE, AOCL, tool, WARD', server: true, cap: 'receipts', status: 'active',
236
268
  detail: 'Each link is read back from the server and drawn confirmed only when the read says so. WARD is the server-verified inclusion receipt; it can be slow on a large chain and is bounded, so a link may stay open.' },
269
+ { id: 'evidence', kind: 'command', new: true, group: 'Verbose', usage: '/evidence', desc: 'signed evidence bundle for this conversation', server: true, cap: 'receipts', status: 'active', aliases: ['envelope', 'envelopes', 'aee', 'volt', 'ward'],
270
+ detail: 'The same export as `quox evidence bundle`: a zip with envelopes.json/csv, ward-receipts.json, volt.json, a manifest of file hashes and an Ed25519 signature, scoped to your org and this conversation. It prints the path, the envelope count, the receipt count and the command to check it offline (`quox evidence verify <file>`). /ward does the same and lists the WARD receipts; /envelope, /envelopes and /aee show the envelope ids (same as /proof); /volt points at the VOLT run inside the bundle.' },
237
271
  { id: 'prove', kind: 'command', new: true, group: 'Verbose', usage: '/prove', desc: 'independently check that the last turn\'s tool calls really did what they said: receipt chain plus a read-back of the effect', server: true, cap: 'receipts', status: 'active',
238
272
  detail: 'For each tool call of the last turn: the receipt chain is re-read from the server (as in /proof), and where the tool is a write we know how to read back (today: slot_create, memory_save) the effect is read from the system of record. ✔ proven = the chain agrees and the effect is there (or it was a read and the chain agrees). ⚠ unproven = it cannot be shown from here (no read mapping for that write, a read failed, no receipt). ✖ contradicts = the call failed, the server recorded a failure, or the effect is not there. Nothing is assumed: an unmapped write is never called proven. /proof is unchanged and shows one call\'s chain.' },
239
273
  { id: 'replay', kind: 'command', new: true, group: 'Verbose', usage: '/replay [run or conversation id] [page]', desc: 'rebuild an earlier run from the audit trail: decisions, tools with args, outputs and receipts, in order, paged', server: true, cap: 'receipts', status: 'active', args: ARGS.replay,
@@ -244,6 +278,8 @@ export const REGISTRY = [
244
278
  // Awareness
245
279
  { id: 'roster', kind: 'command', group: 'Awareness', usage: '/roster [all]', desc: 'show or hide the live roster under the status line; all includes background work', needs: ['roster'], status: 'active',
246
280
  detail: 'The roster lists what needs you: pending approvals, then work you started (objectives, loops, manual workflows). Scheduled and event-driven workflows are hidden; background failures show once per workflow in the bar below. /roster all shows everything. Refreshed every 15 s from the server. Down on an empty composer focuses it; Enter peeks, s asks to stop (y confirms), Esc returns. A source the server cannot answer shows nothing and is named here.' },
281
+ { id: 'loop', kind: 'command', group: 'Awareness', usage: '/loop [list|watch <id>|peek <id>|focus]', desc: 'follow a loop live in a card that updates in place', server: true, status: 'active',
282
+ detail: 'A loop you start from chat gets a live card: the turn strip (✔ ok, ⚠ waiting or denied, ✖ error, ◐ running), the receipts counter, host and scope, and the last lines of what it is writing. Milestones and errors land as normal chat lines beneath it, and a finished loop prints one line with the proof command. /loop list shows loops, /loop watch <id> follows one that is already running (and gives it the keys), /loop peek <id> prints one snapshot, /loop focus gives the keys back. Keys: Enter expands the output, s asks to halt (y confirms, never immediate), Esc collapses and leaves. Under 60 columns the card is one line. Needs a server with the loop event routes; one without them says so.' },
247
283
  { id: 'workflow', kind: 'command', group: 'Skills', usage: '/workflow <name|id|run>', desc: 'the step flow of a workflow\'s latest run (or one run): state, timings, human gates', server: true, cap: 'workflows', status: 'active',
248
284
  detail: 'Steps come from the workflow definition and each state and duration from that run\'s recorded envelopes. A reached human gate shows amber. Enter on a workflow row in the roster shows the same flow.' },
249
285
  { id: 'crystal', kind: 'command', group: 'Skills', usage: '/crystal <workflow>', desc: 'is this workflow eligible to become a Crystal? checked against the real gate', server: true, cap: 'crystals', status: 'active',
@@ -288,11 +324,11 @@ export const REGISTRY = [
288
324
  { id: 'afk', kind: 'command', new: true, group: 'Context', usage: '/afk [note]', desc: 'tell sessions you are away (they stop waiting on approvals, which still queue); /back then shows everything since, grouped', status: 'active',
289
325
  detail: 'Sets your presence on the server (per user and org): sessions saying into hitl, triage-q or this window\'s slot see "owner away since 15:54 UTC (note): approvals will queue"; `quox presence`, `quox slot list`, quox ask and agents see it too. Nothing is approved while you are away. /back or any typed message marks you present.' },
290
326
  { id: 'back', kind: 'command', new: true, group: 'Context', usage: '/back', desc: 'mark yourself present and show what happened since /afk (same as /away)', status: 'active' },
291
- { id: 'observe', kind: 'command', new: true, group: 'Context', usage: '/observe <text>', desc: 'a note to the observer: posts your text to the session that watches your window', status: 'active', dev: true },
292
- { id: 'huh', kind: 'command', new: true, group: 'Conversation', usage: '/huh [question]', desc: 'the dragon explains what just happened (same as /dragon explain)', status: 'active' },
293
- { id: 'sleep', kind: 'command', new: true, group: 'Conversation', usage: '/sleep', desc: 'mark yourself sleeping (sessions stop waiting on approvals, which still queue) and the dragon curls up (/dragon sleep)', status: 'active' },
327
+ { id: 'huh', kind: 'command', new: true, group: 'Conversation', usage: '/huh [question]', desc: 'explain the last output in plain words, using one turn on your model (same as /dragon explain)', status: 'active' },
328
+ { id: 'sleep', kind: 'command', new: true, group: 'Conversation', usage: '/sleep', desc: 'tell other sessions you are away sleeping so they stop waiting on your approvals (they still queue), then the dragon curls up (same as /dragon sleep)', status: 'active' },
294
329
  { id: 'mode', kind: 'command', new: true, group: 'Context', usage: '/mode [ask|plan]', desc: 'this window\'s mode: ask first (default) or plan only', status: 'active', args: ARGS.mode,
295
330
  detail: 'Alone it shows the current mode and the options. /mode plan: Quox investigates with read tools and proposes the steps and the exact commands, and runs nothing that changes state; the server enforces it, and the answer ends with "Run this plan now? y/N/c" (y switches back to ask and sends the go-ahead). /mode ask goes back. For this window only: kept across /restart, never saved to your profile. Auto mode is not available yet: it needs standing grants (planned).' },
331
+ { id: 'devservers', kind: 'command', new: true, group: 'Connect', usage: '/devservers [name]', desc: 'your registered dev servers; /devservers <name> shows its go command', status: 'active' },
296
332
  { id: 'slots', kind: 'command', new: true, group: 'Conversation', usage: '/slots [all]', desc: 'held slots: who holds them and where; free ones are summarised, /slots all lists them (same as /slot list)', needs: ['slots'], cap: 'slots', status: 'active' },
297
333
  { id: 'mirror', kind: 'command', new: true, group: 'Context', usage: '/mirror [agents]', desc: 'the Mirror: what CommanderQ sees about this org right now', needs: ['mirror'], status: 'active',
298
334
  detail: 'Read-only, no model call. The org-scoped Mirror the server builds from each service and injects into CommanderQ on every turn: identity, health, implants, capabilities, plugins, memory, codebase, and every value it could not read with the reason. /mirror agents lists the agents. For the QuoxMind implants alone use /mind.' },
@@ -333,6 +369,10 @@ export const REGISTRY = [
333
369
  'prompt profile: compact is a short system prompt for small or local models (the ' +
334
370
  'default on Ollama, LM Studio and vLLM), full is the default elsewhere; --prompt-' +
335
371
  'profile does the same for one --print run.' },
372
+ { id: 'deck', ext: 'quoxdeck', kind: 'command', group: 'Context', usage: '/deck on|off|status', desc: 'QuoxDeck: an optional MIDI pad surface; pads open an approval or preselect an answer, you confirm here', status: 'active',
373
+ detail: 'Provided by the optional quoxdeck extension, not by core: npm i -g quoxdeck, then quox ext add quoxdeck (until then /deck says so). quoxon pushes approvals, health and loops to the deck at most once a second. A pad only opens an approval view or fills the composer with the answer; it never answers for you, and high risk still needs the step-up PIN. The setting is saved, so the deck comes back on at the next start.' },
374
+ { id: 'ext', kind: 'command', group: 'Context', usage: '/ext list|on|off <name>', desc: 'optional extensions (npm packages) that add surfaces to this window', status: 'active',
375
+ detail: 'Extensions load only when installed (npm i -g <pkg>) and listed (quox ext add <pkg>). /ext list shows each one and where it was found, on|off switches one for the next start and now. An extension can open an approval or preselect an answer; it cannot answer for you. Contract: cli/lib/quoxon/EXTENSIONS.md.' },
336
376
  { id: 'row', kind: 'command', group: 'Context', usage: '/row [new|add|list|edit <id>|rm <id>|test <id>|reload|on|off]', desc: 'your own live lines above the status bar: stats from a URL, your Quox, a plugin or a command', status: 'active', args: ARGS.row,
337
377
  detail:
338
378
  '/row new walks you through one row with y/N cards and shows what it will say before saving; /row edit <id> changes one. ' +
@@ -350,12 +390,24 @@ export const REGISTRY = [
350
390
  detail: 'Type @ at the start of a word: a list under the composer suggests agents, fleet hosts, tasks (your newest objectives) and receipts (the envelope ids of this session\'s tool calls), filtered as you type by id or name. Tab or Enter puts it in, Esc closes the list. The lists are fetched once per session on your first @ and then filtered locally, so typing never sends a request; receipts come from this session and need no request at all. /peek <id> shows what an id is.' }
351
391
  ];
352
392
 
393
+ // Splice dev entries back next to their neighbours (after /notes and /back); absent in the public build.
394
+ for (const e of DEV_ENTRIES) {
395
+ const after = e.id === 'devnote' ? 'notes' : 'back';
396
+ const i = REGISTRY.findIndex((x) => x.id === after);
397
+ REGISTRY.splice(i < 0 ? REGISTRY.length : i + 1, 0, e);
398
+ }
399
+ // Vendor (owner tooling) entries go just before /row; absent in the public build.
400
+ for (const e of VENDOR_ENTRIES) {
401
+ const i = REGISTRY.findIndex((x) => x.id === 'row');
402
+ REGISTRY.splice(i < 0 ? REGISTRY.length : i, 0, e);
403
+ }
404
+
353
405
  // The palette row's one-line wording (commandShort.js); /help keeps the long desc.
354
406
  for (const e of REGISTRY) if (e.kind === 'command' && !e.short && SHORT[e.id]) e.short = SHORT[e.id];
355
407
 
356
408
  const norm = (s) => String(s || '').trim().toLowerCase().replace(/^\//, '');
357
409
 
358
- /** Dev-only entries (/observe, /note) are invisible unless a dev observer is configured. */
410
+ /** Dev-only entries (/observe, /devnote) are invisible unless a dev observer is configured. */
359
411
  export const entryVisible = (e) => !e.dev || devObserverOn();
360
412
  /** REGISTRY minus entries hidden on this install. */
361
413
  export const visibleRegistry = () => REGISTRY.filter(entryVisible);
@@ -397,6 +449,14 @@ const STATUS_LINE_HELP = [
397
449
  { kind: 'row', key: '── model ─', text: 'the divider under the composer names the provider and model (/model)' }
398
450
  ];
399
451
 
452
+ /** QUOXON-DELIGHT: the short "Try these" block at the end of /help (the full verified set is the collector's tryThese.json). */
453
+ export const TRY_THESE = [
454
+ { id: 'challenge', usage: '/challenge', hook: 'a different AI provider argues with the answer you just got' },
455
+ { id: 'spawn', usage: '/spawn 2c 4G 20G 1h for X', hook: 'a disposable VM in seconds, gone when the timer ends' },
456
+ { id: 'crystallize', usage: '/crystallize', hook: 'turn what you just did into a reusable skill' },
457
+ { id: 'roar', usage: '/roar <text>', hook: 'shout to every session you hold at once' }
458
+ ];
459
+
400
460
  /** registry: the entries to list (default: this install's visible registry; tests pass a fixture). */
401
461
  export function helpModel(topic, caps = {}, registry = visibleRegistry()) {
402
462
  if (topic) {
@@ -425,6 +485,11 @@ export function helpModel(topic, caps = {}, registry = visibleRegistry()) {
425
485
  out.push({ kind: 'gap' }, { kind: 'head', text: 'Coming', dim: true });
426
486
  for (const e of planned) out.push({ kind: 'dim', key: e.usage, text: e.desc });
427
487
  }
488
+ const tries = TRY_THESE.filter(t => registry.some(e => e.status === 'active' && (e.id === t.id || (e.aliases || []).includes(t.id))));
489
+ if (tries.length) {
490
+ out.push({ kind: 'gap' }, { kind: 'subhead', text: 'Try these' });
491
+ for (const t of tries) out.push({ kind: 'row', key: t.usage, text: t.hook });
492
+ }
428
493
  out.push({ kind: 'gap' }, { kind: 'dim', text: '/help <command> for detail, e.g. /help esc' });
429
494
  return out;
430
495
  }
@@ -467,7 +532,7 @@ export function nearestCommands(input, max = 3) {
467
532
  }
468
533
 
469
534
  // Commands whose last argument is free text: a pasted line that the terminal wrapped is still one command.
470
- const FREE_TEXT_CMDS = new Set(['ask', 'aside', 'afk', 'observe', 'roar', 'awaken', 'poke', 'wake', 'nudge', 'ping', 'slap', 'feedback', 'huh', 'r', 'reply', 'say', 'page', 'broadcast', 'gripe', 'gripe!', 'note', 'dragon', 'deny', 'wish', 'challenge']);
535
+ const FREE_TEXT_CMDS = new Set(['ask', 'aside', 'afk', 'observe', 'roar', 'awaken', 'poke', 'wake', 'nudge', 'ping', 'slap', 'feedback', 'huh', 'r', 'reply', 'say', 'page', 'broadcast', 'gripe', 'gripe!', 'note', 'devnote', 'dragon', 'deny', 'wish', 'challenge']);
471
536
  /** "/say a long\n text" -> { text: '/say a long text', lines: 2 }; null when it is not a wrapped free-text command. */
472
537
  export function joinWrappedSlash(text) {
473
538
  const t = String(text || '');
@@ -1,6 +1,15 @@
1
1
  /** The restart countdown line, fitted to the width: full text when it fits, shorter forms below, never wrapped. */
2
- export function countdownLine(secs, cols) {
2
+ export function countdownLine(secs, cols, { waiting = false } = {}) {
3
3
  const room = Math.max(20, (cols || 80) - 4); // paddingLeft 2 plus a margin
4
+ if (waiting) { // the countdown is over but the Quox server is down (a land is redeploying it): hold the restart until it answers
5
+ const w = [
6
+ '↻ New version ready, waiting for Quox server to come back before restarting · Esc to stay · Enter to restart now',
7
+ '↻ Waiting for Quox server to come back before restarting · Esc stay · Enter now',
8
+ '↻ Waiting for Quox server before restarting · Esc stay',
9
+ '↻ Waiting for Quox server · Esc stay',
10
+ ];
11
+ return w.find(f => f.length <= room) || w[w.length - 1];
12
+ }
4
13
  const forms = [
5
14
  `↻ New version ready. Restarting in ${secs}s · Esc to stay on this version · Enter to restart now`,
6
15
  `↻ New version · restarting in ${secs}s · Esc stay · Enter now`,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * QuoxDeck compatibility shim. The deck is no longer part of core: it is an installable extension ("quoxdeck") run by the
3
+ * generic host in extensions.js (see EXTENSIONS.md). This file only keeps the old deck-shaped surface (on/off/status, the
4
+ * QUOXDECK_MODULE override, the "not installed" text) as a thin wrapper over that host. New code should use extensions.js.
5
+ */
6
+ import { createExtensionHost, buildSnapshot, pickDraft, notInstalled, THROTTLE_MS } from './extensions.js';
7
+
8
+ export const DECK_PACKAGE = 'quoxdeck';
9
+ export const DECK_NOT_INSTALLED = notInstalled(DECK_PACKAGE);
10
+ export const DECK_THROTTLE_MS = THROTTLE_MS;
11
+ export const buildDeckState = buildSnapshot;
12
+ export const deckPickDraft = pickDraft;
13
+
14
+ export function createDeckHost({ env = process.env, ...rest } = {}) {
15
+ const legacy = env?.QUOXDECK_MODULE; // the old override still works, mapped to QUOX_EXT_QUOXDECK_MODULE
16
+ const h = createExtensionHost({ ...rest, env: legacy ? { ...env, QUOX_EXT_QUOXDECK_MODULE: legacy } : env });
17
+ return {
18
+ armed: h.armed,
19
+ emit: (msg) => h.emit(msg),
20
+ push: h.push,
21
+ async on() {
22
+ if (h.armed()) return { ok: true, text: 'QuoxDeck is already on.' };
23
+ const r = await h.load(DECK_PACKAGE);
24
+ if (r.ok) return { ok: true, text: 'QuoxDeck is on. Pads can open an approval or preselect an answer; you confirm in this window.' };
25
+ return { ok: false, text: r.notFound ? `QuoxDeck ${DECK_NOT_INSTALLED}` : `QuoxDeck ${r.text.replace(/^quoxdeck /, '')}`.replace('could not load: the package has no create().', 'could not load: the package has no createDeck.') };
26
+ },
27
+ off() { const was = h.armed(); h.unloadAll(); return was ? { ok: true, text: 'QuoxDeck is off.' } : { ok: true, text: 'QuoxDeck was not on.' }; },
28
+ status: () => (h.armed() ? 'QuoxDeck: on (armed).' : 'QuoxDeck: off. /deck on arms it.')
29
+ };
30
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export const AUTOPLAY_LABEL = 'DEMO: demo data, nothing real runs.';
6
6
  export const AUTOPLAY_REQUEST = 'Show me what you can do (demo)';
7
- export const AUTOPLAY_PROOF = '/proof';
7
+ export const AUTOPLAY_PROOF = '/proof 1'; // bare /proof lists envelope ids (d73f04ab4); 1 is the newest chain, which the journey shows
8
8
  export const AUTOPLAY = Object.freeze({ seed: 20261013, startMs: 900, typeMinMs: 38, typeMaxMs: 78, afterTypeMs: 450, approvalHoldMs: 2500, afterKeyMs: 400, afterTurnMs: 900, finalHoldMs: 600 });
9
9
 
10
10
  /** Small seeded PRNG (mulberry32): deterministic across runs and platforms. */
@@ -19,13 +19,15 @@ function decisionStore(file) {
19
19
  const wait = (ms) => new Promise(r => setTimeout(r, ms || 0));
20
20
  return {
21
21
  // staleMs: an answered item is still listed for this long (a server that lags behind its own answers)
22
- async open() { const d = load(); await wait(d.approvalsDelayMs); return d.items.filter(x => x.status === 'open' || (d.staleMs && x.answeredAt && Date.now() - x.answeredAt < d.staleMs)); },
22
+ // failAfterAnswers: the server is down once anything was answered (the walkthrough's closing re-check)
23
+ async open() { const d = load(); await wait(d.approvalsDelayMs); if (d.failAfterAnswers && d.log.length) { const e = new Error('connect ECONNREFUSED'); e.status = 503; throw e; } return d.items.filter(x => x.status === 'open' || (d.staleMs && x.answeredAt && Date.now() - x.answeredAt < d.staleMs)); },
23
24
  async answer(id, key, note) {
24
25
  const d0 = load(); await wait(d0.answerDelayMs);
25
26
  const d = load(); const it = d.items.find(x => x.id === id);
26
27
  if (!it || it.status !== 'open') { const e = new Error('already handled'); e.status = 404; throw e; }
27
28
  it.status = 'answered'; it.answeredAt = Date.now(); it.answer = { key, note: note || null };
28
29
  d.log.push({ id, key, note: note || null, at: Date.now() });
30
+ if (d.arriveOnAnswer && !d.arrived) { d.items.push(...d.arriveOnAnswer); d.arrived = true; } // arriveOnAnswer: new items land while the owner is answering
29
31
  writeFileSync(file, JSON.stringify(d));
30
32
  return { ok: true };
31
33
  }
@@ -153,13 +153,14 @@ export function chainCells(d, now, { still = false } = {}) {
153
153
  * Cells without `rgb` take the rule's own colour; `label: true` takes the label's own colour.
154
154
  * `still` (a slow redraw): no blink and no katakana, but every lit dot and the end colours stay.
155
155
  */
156
- export function dividerFrame({ cols, label, d, now, mono = false, reduced = false, still = false, seed = 0 }) {
156
+ export function dividerFrame({ cols, label, extra = null, d, now, mono = false, reduced = false, still = false, seed = 0 }) {
157
157
  if (mono || reduced || !d || cols < MIN_COLS || !label || !dividerLive(d, now)) return null;
158
158
  const lab = ` ${label} `;
159
159
  const lw = stringWidth(lab);
160
160
  const chain = chainCells(d, now, { still });
161
161
  const cw = chain.cells.length;
162
- const left = cols - lw - 1 - cw;
162
+ const ew = extra ? stringWidth(` ${extra.text} `) : 0;
163
+ const left = cols - lw - 1 - cw - ew;
163
164
  if (left < 4) return null;
164
165
  // the newest lit layer names itself to the left of the chain, in its colour, while it is new
165
166
  const tag = chain.newest && now - chain.newestAt < TAG_MS ? ` ${chain.newest} ${LAYER_NAMES[chain.newest] || ''} ` : '';
@@ -184,7 +185,7 @@ export function dividerFrame({ cols, label, d, now, mono = false, reduced = fals
184
185
  }
185
186
  } else labelCells = [{ t: lab, label: true }];
186
187
  }
187
- return { cells: [...leftCells, ...chain.cells, ...labelCells, { t: '─' }] };
188
+ return { cells: [...leftCells, ...chain.cells, ...labelCells, ...(extra ? [{ t: ` ${extra.text} `, hex: extra.hex, bold: true }] : []), { t: '─' }] };
188
189
  }
189
190
 
190
191
  /** Plain text of a frame (tests and pty proofs). */