quoxcli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/lib/api/endpoints/actions.js +15 -0
  2. package/lib/api/endpoints/admin.js +20 -0
  3. package/lib/api/endpoints/blog.js +16 -0
  4. package/lib/api/endpoints/chat.js +7 -0
  5. package/lib/api/endpoints/contactsq.js +4 -0
  6. package/lib/api/endpoints/devServerRegistry.js +25 -0
  7. package/lib/api/endpoints/engagement.js +26 -0
  8. package/lib/api/endpoints/evidence.js +20 -0
  9. package/lib/api/endpoints/hat.js +14 -0
  10. package/lib/api/endpoints/index.js +12 -0
  11. package/lib/api/endpoints/loops.js +26 -0
  12. package/lib/api/endpoints/mon.js +173 -0
  13. package/lib/api/endpoints/objectives.js +6 -0
  14. package/lib/api/endpoints/quoxseo.js +6 -0
  15. package/lib/api/endpoints/server.js +21 -0
  16. package/lib/api/endpoints/setup.js +6 -0
  17. package/lib/api/endpoints/v1.js +34 -0
  18. package/lib/commands/actions.js +81 -11
  19. package/lib/commands/agent.js +11 -4
  20. package/lib/commands/agentAttach.js +46 -7
  21. package/lib/commands/approvals.js +5 -0
  22. package/lib/commands/ask.js +3 -1
  23. package/lib/commands/blog.js +110 -0
  24. package/lib/commands/chat.js +127 -28
  25. package/lib/commands/chatCompanion.js +27 -0
  26. package/lib/commands/chatState.js +45 -0
  27. package/lib/commands/claude.js +3 -32
  28. package/lib/commands/contacts.js +85 -4
  29. package/lib/commands/contactsSendAt.js +21 -0
  30. package/lib/commands/engagement.js +90 -0
  31. package/lib/commands/evidence.js +154 -0
  32. package/lib/commands/ext.js +23 -0
  33. package/lib/commands/fleet.js +25 -0
  34. package/lib/commands/hat.js +76 -0
  35. package/lib/commands/image.js +68 -0
  36. package/lib/commands/kuma.js +1050 -0
  37. package/lib/commands/kumaSla.js +174 -0
  38. package/lib/commands/licence.js +39 -1
  39. package/lib/commands/loop.js +291 -0
  40. package/lib/commands/objective.js +48 -1
  41. package/lib/commands/org.js +21 -4
  42. package/lib/commands/provider.js +58 -0
  43. package/lib/commands/quoxseo.js +31 -0
  44. package/lib/commands/research.js +79 -0
  45. package/lib/commands/rows.js +9 -0
  46. package/lib/commands/run.js +18 -1
  47. package/lib/commands/scope.js +2 -2
  48. package/lib/commands/server.js +113 -0
  49. package/lib/commands/shortcut.js +41 -0
  50. package/lib/commands/skills.js +20 -4
  51. package/lib/commands/vm.js +2 -2
  52. package/lib/commands/youtube.js +172 -0
  53. package/lib/core/actionItems.js +115 -0
  54. package/lib/core/agentAlias.js +2 -0
  55. package/lib/core/agentFamilies.js +7 -0
  56. package/lib/core/client.js +10 -2
  57. package/lib/core/commandRegistrars.js +9 -0
  58. package/lib/core/config.js +1 -1
  59. package/lib/core/findingReconcile.js +90 -0
  60. package/lib/core/global-flags.js +1 -1
  61. package/lib/core/governedSession.js +1 -3
  62. package/lib/core/liveSessionsReport.js +1 -1
  63. package/lib/core/loopGlyph.js +97 -0
  64. package/lib/core/offload.js +36 -7
  65. package/lib/core/output.js +7 -7
  66. package/lib/core/sessionHeartbeat.js +12 -8
  67. package/lib/core/shortcut.js +83 -0
  68. package/lib/core/version.js +1 -1
  69. package/lib/core/vm.js +5 -0
  70. package/lib/dragon/companionView.js +34 -0
  71. package/lib/dragon/frames.js +118 -0
  72. package/lib/dragon/jobs.js +123 -0
  73. package/lib/dragon/renderWorker.js +15 -0
  74. package/lib/dragon/select.js +72 -0
  75. package/lib/evidence/bundleExport.js +47 -0
  76. package/lib/evidence/bundleVerify.js +77 -0
  77. package/lib/quoxon/EXTENSIONS.md +23 -0
  78. package/lib/quoxon/api.js +192 -6
  79. package/lib/quoxon/app.js +655 -89
  80. package/lib/quoxon/approvalFacts.js +8 -6
  81. package/lib/quoxon/approvalSentence.js +44 -0
  82. package/lib/quoxon/approvalsView.js +63 -4
  83. package/lib/quoxon/commandShort.js +15 -3
  84. package/lib/quoxon/commands.js +75 -10
  85. package/lib/quoxon/countdownLine.js +10 -1
  86. package/lib/quoxon/deck.js +30 -0
  87. package/lib/quoxon/demo.autoplay.js +1 -1
  88. package/lib/quoxon/demo.js +3 -1
  89. package/lib/quoxon/dividerAocl.js +4 -3
  90. package/lib/quoxon/dividerRule.js +3 -3
  91. package/lib/quoxon/dragonCompanion.js +255 -0
  92. package/lib/quoxon/dragonModes.js +1 -0
  93. package/lib/quoxon/dragonMoments.js +82 -0
  94. package/lib/quoxon/dragonReads.js +68 -0
  95. package/lib/quoxon/dragonView.js +2 -2
  96. package/lib/quoxon/events.js +24 -3
  97. package/lib/quoxon/extensions.js +276 -0
  98. package/lib/quoxon/inboxView.js +79 -0
  99. package/lib/quoxon/installCli.js +79 -0
  100. package/lib/quoxon/keys.js +2 -2
  101. package/lib/quoxon/loopCard.js +456 -0
  102. package/lib/quoxon/loopCards.js +180 -0
  103. package/lib/quoxon/loopStream.js +87 -0
  104. package/lib/quoxon/main.js +12 -3
  105. package/lib/quoxon/mouse.js +12 -1
  106. package/lib/quoxon/objectiveOffer.js +82 -0
  107. package/lib/quoxon/objects/index.js +22 -4
  108. package/lib/quoxon/objects/model.js +33 -0
  109. package/lib/quoxon/objects/proof.js +12 -4
  110. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  111. package/lib/quoxon/onboarding/view.js +1 -1
  112. package/lib/quoxon/orgSwitch.js +27 -1
  113. package/lib/quoxon/planSteps.js +51 -0
  114. package/lib/quoxon/print.js +6 -3
  115. package/lib/quoxon/qdragonFly.js +7 -6
  116. package/lib/quoxon/qmotion.js +17 -0
  117. package/lib/quoxon/render.js +130 -33
  118. package/lib/quoxon/roster.js +26 -12
  119. package/lib/quoxon/rows.js +244 -1
  120. package/lib/quoxon/serverChip.js +42 -0
  121. package/lib/quoxon/session.js +10 -0
  122. package/lib/quoxon/slash/cliCommand.js +42 -1
  123. package/lib/quoxon/slash/cliLeaves.json +76 -1
  124. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  125. package/lib/quoxon/slash/palette.js +1 -1
  126. package/lib/quoxon/slash/qpeCmd.js +35 -0
  127. package/lib/quoxon/slash/restart.js +25 -1
  128. package/lib/quoxon/slash/resume.js +18 -1
  129. package/lib/quoxon/slash/todoCmd.js +122 -0
  130. package/lib/quoxon/suggestCommand.js +33 -2
  131. package/lib/quoxon/toolCards.js +30 -0
  132. package/lib/quoxon/whatsnew.jsonl +45 -0
  133. package/lib/quoxon/windowOrg.js +11 -1
  134. package/lib/server/devServers.js +137 -0
  135. package/package.json +2 -1
  136. package/vendor/dragonlab/SOURCE +4 -0
  137. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  138. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  139. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  140. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  141. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  142. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  143. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  144. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  145. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  146. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  147. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  148. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  149. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  150. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  151. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  152. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  153. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  154. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  155. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  156. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  157. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  158. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  159. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  160. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  161. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  162. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  163. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  164. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  165. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  166. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  167. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  168. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  169. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  170. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  171. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  172. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  173. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  174. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  175. package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
  176. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  177. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  178. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  179. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  180. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  181. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  182. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  183. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  184. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  185. package/vendor/dragonlab/lib/power-effects.js +294 -0
  186. package/vendor/dragonlab/lib/power-registry.js +74 -0
  187. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  188. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * TUI-QPE (owner 2026-10-19): /qpe 0|1|2|3|4, how many Quox Proof Engine lines show above the status bar.
3
+ * The lines are the ones whose status-row JSON says grid "qpe" (quox-qpe-row all prints four: headline, movement,
4
+ * coverage, and the cli.quox.ai content line, owner 2026-10-20). The count is a CLI setting
5
+ * on the active profile (quoxon.qpeRows); unset shows every QPE row. 0 hides them; the rows keep refreshing, so
6
+ * turning them back on shows a current value at once. Narrow windows still drop the coverage row (min_cols).
7
+ */
8
+ export const QPE_GRID = 'qpe';
9
+ export const QPE_MAX = 4;
10
+
11
+ /** The saved count, or null (show all) when unset or not a whole number 0..4. */
12
+ export function qpeRowsSetting(settings) {
13
+ const n = settings?.qpeRows;
14
+ return Number.isInteger(n) && n >= 0 && n <= QPE_MAX ? n : null;
15
+ }
16
+
17
+ /** "/qpe <arg>" -> { text, tone, save? }. save is the value to store under qpeRows. Pure. */
18
+ export function qpeCommand(arg, settings) {
19
+ const words = String(arg || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
20
+ const cur = qpeRowsSetting(settings);
21
+ const now = cur == null ? 'all 3 proof rows and the content row' : String(cur);
22
+ if (!words.length || (words[0] === 'rows' && words.length === 1)) {
23
+ return { text: `QPE rows above the status bar: ${now}. /qpe 0|1|2|3|4 or /qpe all changes it (0 hides them, 4 adds content).` };
24
+ }
25
+ // Owner typed `/qpe 3` and `/qpe all` (2026-10-20): "rows" is optional and words map to counts.
26
+ const vals = words[0] === 'rows' ? words.slice(1) : words;
27
+ const WORD = { all: 3, full: 3, on: 3, show: 3, max: 3, off: 0, hide: 0, none: 0, hidden: 0 };
28
+ const v = vals.length === 1 ? (/^[0-4]$/.test(vals[0]) ? Number(vals[0]) : WORD[vals[0]]) : undefined;
29
+ if (v === undefined) {
30
+ return { text: 'Usage: /qpe 0|1|2|3|4, or all / off (0 hides the proof engine rows, 3 shows headline, movement and coverage, 4 adds content).', tone: 'warn' };
31
+ }
32
+ const n = v;
33
+ const what = ['hidden', 'headline only', 'headline and movement', 'headline, movement and coverage', 'headline, movement, coverage and content'][n];
34
+ return { text: `QPE rows: ${n} (${what}). Saved to this profile.`, tone: 'ok', save: n };
35
+ }
@@ -83,7 +83,14 @@ export function settleMsFrom(env = process.env) {
83
83
  return env.QUOX_RESTART_SETTLE_MS != null && env.QUOX_RESTART_SETTLE_MS !== '' && Number.isFinite(n) && n >= 0 ? n : RESTART_SETTLE_MS;
84
84
  }
85
85
 
86
- export function startBuildWatch({ onNotice, everyMs = 60000, check = newerBuild, repeat = false, buildId = installedBuildId, settleMs = 0, now = Date.now, ...opts } = {}) {
86
+ export const RESTART_MIN_GAP_MS = 15 * 60 * 1000; // no auto-restart offer this soon after this process started (builds seen meanwhile are remembered)
87
+
88
+ export function minGapMsFrom(env = process.env) {
89
+ const n = Number(env.QUOX_RESTART_MIN_GAP_MS);
90
+ return env.QUOX_RESTART_MIN_GAP_MS != null && env.QUOX_RESTART_MIN_GAP_MS !== '' && Number.isFinite(n) && n >= 0 ? n : RESTART_MIN_GAP_MS;
91
+ }
92
+
93
+ export function startBuildWatch({ onNotice, everyMs = 60000, check = newerBuild, repeat = false, buildId = installedBuildId, settleMs = 0, minGapMs = 0, now = Date.now, ...opts } = {}) {
87
94
  let last; // repeat mode: the last build announced
88
95
  let seen; let seenAt = 0; let seenNotice = null; // repeat mode: the build waiting to settle, and when it last changed
89
96
  const bootAt = now();
@@ -99,6 +106,7 @@ export function startBuildWatch({ onNotice, everyMs = 60000, check = newerBuild,
99
106
  seenNotice = t;
100
107
  const t0 = now() - bootAt < RESTART_BOOT_WINDOW_MS ? Math.max(seenAt, bootAt) : seenAt;
101
108
  if (now() - t0 < settleMs) return;
109
+ if (now() - bootAt < minGapMs) return; // min gap since start: remembered in seen, offered once the gap has passed
102
110
  last = b;
103
111
  onNotice(seenNotice, b);
104
112
  }, everyMs);
@@ -200,3 +208,19 @@ export function execRestart({ argv, conversationId, profile = null, name = null,
200
208
  if (r.error) return { error: `${r.error.code || 'failed'}: ${r.error.message}`, bin: plan.cmd };
201
209
  return { code: r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1), bin: plan.cmd };
202
210
  }
211
+
212
+ export const SERVER_WAIT_EVERY_MS = 2000; // poll the collector /health this often while a restart waits for it
213
+ export const SERVER_WAIT_MAX_MS = 3 * 60 * 1000; // ...for up to this long, then carry on as before
214
+
215
+ /** Poll `probe` (-> { ok } | truthy/falsy, may throw) until it answers ok. -> true when healthy, false on timeout or cancel. */
216
+ export async function waitForHealthy({ probe, everyMs = SERVER_WAIT_EVERY_MS, maxMs = SERVER_WAIT_MAX_MS, cancelled = () => false, sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); }), now = Date.now } = {}) {
217
+ if (typeof probe !== 'function') return true;
218
+ const end = now() + maxMs;
219
+ for (;;) {
220
+ if (cancelled()) return false;
221
+ let r = null; try { r = await probe(); } catch { r = null; }
222
+ if (r === true || r?.ok === true) return true;
223
+ if (cancelled() || now() + everyMs > end) return false;
224
+ await sleep(everyMs);
225
+ }
226
+ }
@@ -16,13 +16,30 @@ export function age(iso, now = Date.now()) {
16
16
  return `${Math.round(s / 86400)}d ago`;
17
17
  }
18
18
 
19
+ // Box-drawing, block, geometric, braille and halfwidth-kana characters are what the TUI itself paints (dividers, busy strip,
20
+ // dragon art, scrambled labels). A title containing them was derived from rendered UI, never typed by anyone.
21
+ const RENDERED = /[\u2500-\u259F\u25A0-\u25FF\u2800-\u28FF\uFF61-\uFF9F]/g;
22
+ /** A conversation title safe to show, or null when it is made of rendered UI (3+ painted characters) or empty. */
23
+ export function cleanTitle(t) {
24
+ const s = String(t ?? '');
25
+ if ((s.match(RENDERED) || []).length >= 3) return null;
26
+ const c = s.replace(RENDERED, ' ').replace(/\s+/g, ' ').trim();
27
+ return c || null;
28
+ }
29
+ /** The first user message of stored turns as a title (<= 60 chars), or null. */
30
+ export function titleFromTurns(turns = []) {
31
+ const u = (turns || []).find(t => t.role === 'user' && String(t.content || '').trim());
32
+ const c = u ? cleanTitle(String(u.content).split('\n')[0]) : null;
33
+ return c ? (c.length > 60 ? `${c.slice(0, 60).trimEnd()}…` : c) : null;
34
+ }
35
+
19
36
  /** -> [{ id, title, agent, updated_at, turns }] newest first. */
20
37
  export function resumeRows(conversations = [], index = null, names = {}) {
21
38
  const agentOf = new Map();
22
39
  for (const r of index?.rows || []) if (r.kind === 'conversation' && r.id) agentOf.set(r.id, r.agent || null);
23
40
  return conversations.map(c => ({
24
41
  id: c.id,
25
- title: names[c.id]?.name || c.title || '(untitled)',
42
+ title: cleanTitle(names[c.id]?.name) || cleanTitle(c.title) || '(untitled)',
26
43
  agent: agentOf.get(c.id) ?? null,
27
44
  updated_at: c.updated_at || null,
28
45
  turns: c.turn_count ?? null
@@ -0,0 +1,122 @@
1
+ /**
2
+ * /todo <text> adds an Action Tracker item; /todo alone and /todos list the board; /todo move do5 done moves one
3
+ * (owner typed /todo, 2026-10-20). The writes and reads are core/actionItems.js, the same core as `quox actions`.
4
+ * Items get per-listing short ids by column: td1.. (to do), do1.. (doing), dn1.. (done). They are positions in the
5
+ * last list, mapped to real item ids; a move re-reads the board and says so when the list has changed.
6
+ * Pure helpers here: parse, short ids, wording.
7
+ */
8
+ export const DONE_SHOWN = 10;
9
+ const PREFIX = { backlog: 'td', doing: 'do', done: 'dn' };
10
+ const COLUMN_NAME = { backlog: 'To do', doing: 'Doing', done: 'Done' };
11
+ const REF = /^(td|do|dn)(\d+)$/i;
12
+ const STATUS_WORD = { todo: 'todo', 'to-do': 'todo', td: 'todo', doing: 'doing', do: 'doing', wip: 'doing', progress: 'doing', 'in-progress': 'doing', done: 'done', dn: 'done', complete: 'done', completed: 'done', finished: 'done' };
13
+ const USAGE = 'Usage: /todo <text> adds. /todos lists. /todo move do5 done (or /todo do5 done, /todo mark do2 done, /todo td3 doing) moves one.';
14
+
15
+ /** A column word -> 'todo' | 'doing' | 'done' | null. Accepts "to do" and "in progress" as two words. */
16
+ export function statusWord(words) {
17
+ const w = (Array.isArray(words) ? words.join('-') : String(words || '')).trim().toLowerCase();
18
+ return STATUS_WORD[w] || null;
19
+ }
20
+
21
+ /** Leading short ids from words, comma and/or space separated ("td1,td2", "td1 td2", "td1, td2") -> { refs, used } (used = words consumed). */
22
+ export function takeRefs(words) {
23
+ const refs = [];
24
+ let used = 0;
25
+ for (const w of words) {
26
+ const parts = String(w).split(',').filter(Boolean);
27
+ if (!parts.length || !parts.every((p) => REF.test(p))) break;
28
+ refs.push(...parts.map((p) => p.toLowerCase()));
29
+ used += 1;
30
+ }
31
+ return { refs: [...new Set(refs)], used };
32
+ }
33
+
34
+ /** "/done td1,td2" argument -> { kind: 'done', refs } | { kind: 'usage', text }. Nothing but short ids is accepted. */
35
+ export function parseDone(arg) {
36
+ const words = String(arg || '').trim().split(/\s+/).filter(Boolean);
37
+ const { refs, used } = takeRefs(words);
38
+ if (!refs.length || used !== words.length) return { kind: 'usage', text: 'Usage: /done td1,td2 (or /done td1 td2) marks those items done. /todos lists the ids.' };
39
+ return { kind: 'done', refs };
40
+ }
41
+
42
+ export function todoDoneLine(title) {
43
+ return `\u2714 Done: ${title}`;
44
+ }
45
+
46
+ /**
47
+ * "/todo ..." or "/todos ..." + its argument ->
48
+ * { kind: 'list' } | { kind: 'add', text } | { kind: 'move', ref, to } | { kind: 'usage', text }.
49
+ * Only /todo with free text adds. A short id plus a column word moves; an id with a bad column is a usage error, never an add.
50
+ */
51
+ export function parseTodo(command, arg) {
52
+ const text = String(arg || '').trim();
53
+ if (String(command || '').toLowerCase() === '/todos' || !text) return { kind: 'list' };
54
+ const words = text.split(/\s+/);
55
+ const verb = /^(move|mv|mark|set)$/i.test(words[0]);
56
+ const rest = verb ? words.slice(1) : words;
57
+ const lead = takeRefs(rest);
58
+ if (lead.refs.length) {
59
+ const to = statusWord(rest.slice(lead.used));
60
+ if (to) return lead.refs.length > 1 ? { kind: 'move', ref: lead.refs[0], refs: lead.refs, to } : { kind: 'move', ref: lead.refs[0], to };
61
+ return { kind: 'usage', text: USAGE };
62
+ }
63
+ if (verb) return { kind: 'usage', text: USAGE };
64
+ return { kind: 'add', text };
65
+ }
66
+
67
+ /** board {backlog,doing,done} (backlog = the to-do column) -> { map: { td1: id, ... }, shown: { todo, doing, done } } (done is capped at DONE_SHOWN). */
68
+ export function numberBoard(board, doneShown = DONE_SHOWN) {
69
+ const shown = { backlog: board.backlog || [], doing: board.doing || [], done: (board.done || []).slice(0, doneShown) };
70
+ const map = {};
71
+ for (const col of Object.keys(PREFIX)) shown[col].forEach((it, n) => { map[`${PREFIX[col]}${n + 1}`] = it.id; });
72
+ return { map, shown };
73
+ }
74
+
75
+ /** Confirmation after an add: the item id plus where to see the list. */
76
+ export function todoAddedLine(item) {
77
+ return `Added to your Action Tracker: ${item.id} ${item.title}. /todos lists them.`;
78
+ }
79
+
80
+ /** The board as short numbered lines, per column, with short ids. -> string. */
81
+ export function todoBoardText(board, numbered = numberBoard(board)) {
82
+ const total = (board.backlog || []).length + (board.doing || []).length + (board.done || []).length;
83
+ if (!total) return 'Nothing in your Action Tracker. /todo <text> adds an item.';
84
+ const out = [`Your Action Tracker (${(board.backlog || []).length} to do, ${(board.doing || []).length} doing, ${(board.done || []).length} done). /todo <text> adds, /todo move do2 done moves:`];
85
+ for (const col of ['backlog', 'doing', 'done']) {
86
+ const items = numbered.shown[col];
87
+ const all = (board[col] || []).length;
88
+ if (!all) continue;
89
+ out.push(`${COLUMN_NAME[col]}:`);
90
+ items.forEach((i, n) => out.push(` ${PREFIX[col]}${n + 1} ${i.title}${i.due_date ? ` due ${String(i.due_date).slice(0, 10)}` : ''}`));
91
+ if (all > items.length) out.push(` ... and ${all - items.length} more ${col === 'done' ? 'done (quox actions list --status done)' : ''}`.trimEnd());
92
+ }
93
+ return out.join('\n');
94
+ }
95
+
96
+ /**
97
+ * Resolve a short id for a move. oldMap is the map from the last /todos (or null if none yet); fresh is the board
98
+ * read just now. The item is found by its REAL id from the old list, so a list that shifted still moves the item
99
+ * you saw. -> { id, title, note? } | { error }.
100
+ */
101
+ export function resolveRef(ref, oldMap, board) {
102
+ const fresh = numberBoard(board, Infinity);
103
+ const byId = {};
104
+ for (const [short, id] of Object.entries(fresh.map)) byId[id] = short;
105
+ const titleOf = (id) => [...(board.backlog || []), ...(board.doing || []), ...(board.done || [])].find((i) => i.id === id)?.title || '';
106
+ if (!oldMap || !oldMap[ref]) {
107
+ const id = fresh.map[ref];
108
+ if (!id) return { error: `No ${ref} on your board. /todos lists the current ids.` };
109
+ return { id, title: titleOf(id), note: oldMap ? undefined : `No list shown yet in this window, so ${ref} is read from the board as it is now.` };
110
+ }
111
+ const id = oldMap[ref];
112
+ if (!byId[id]) return { error: `${ref} is gone from your board (done elsewhere or deleted). /todos lists the current ids.` };
113
+ const nowRef = byId[id];
114
+ const shownNow = numberBoard(board).map[nowRef] === id; // the id is one the user could see in a fresh listing
115
+ return nowRef === ref
116
+ ? { id, title: titleOf(id) }
117
+ : { id, title: titleOf(id), note: `The list changed since /todos: "${titleOf(id)}" is ${nowRef} now${shownNow ? '' : ' (beyond the short list)'}. Moving that one.` };
118
+ }
119
+
120
+ export function todoMovedLine(item) {
121
+ return `Moved ${item.title} to ${item.status}.`;
122
+ }
@@ -33,14 +33,45 @@ export function vetSuggestion(raw) {
33
33
  */
34
34
  export function namedCommand(text) {
35
35
  const src = String(text || '');
36
+ const closing = closingProposal(src);
37
+ if (closing !== undefined) return closing; // the answer's own closing question names THE command: that one or nothing
36
38
  const seen = new Set();
37
39
  for (const m of src.matchAll(/`(\/[a-z][^`\n]{0,118})`/gi)) {
38
40
  if (mentionsAPath(src, m.index, m[0].length, m[1])) continue; // `/health` the endpoint is not /health the command
39
41
  seen.add(m[1].trim().replace(/\s+/g, ' '));
40
42
  }
41
43
  if (seen.size !== 1) return null;
42
- const v = vetSuggestion([...seen][0]);
43
- return v.ok ? v : null;
44
+ return offerable([...seen][0]);
45
+ }
46
+
47
+ // Commands that SEND or WRITE something: a mention is never an offer (the model must call terminal_suggest_command).
48
+ export const WRITE_COMMANDS = new Set(['broadcast', 'roar', 'say', 'page', 'ack', 'reply', 'r', 'ask', 'aside', 'afk', 'back', 'observe', 'awaken', 'poke', 'wake', 'nudge', 'ping', 'slap', 'feedback', 'gripe', 'gripe!', 'note', 'dragon', 'wish', 'challenge', 'huh']);
49
+
50
+ // vetted AND safe to offer from a mention: not a write/send, and no required <arg> left unfilled.
51
+ function offerable(line) {
52
+ const v = vetSuggestion(line);
53
+ if (!v.ok) return null;
54
+ if (WRITE_COMMANDS.has(v.id) || WRITE_COMMANDS.has(line.slice(1).split(/\s+/)[0].toLowerCase())) return null;
55
+ const entry = findCommandEntry(line.slice(1).split(/\s+/)[0]);
56
+ const bare = line.trim().split(/\s+/).length === 1;
57
+ if (bare && /</.test(String(entry?.usage || ''))) return null; // "/ack <broadcast id>" needs its argument
58
+ return v;
59
+ }
60
+
61
+ // "Do you want me to run X?" / "Shall I run X?" -> undefined (no such question) | the offer for X | null.
62
+ // X as a slash command is offered when safe; X as a `quox <verb>` CLI line is offered only as the matching read-only slash view.
63
+ function closingProposal(src) {
64
+ const m = /(?:do you want me to|want me to|would you like me to|shall i|should i|can i)\s+(?:run|try|use|show you)\s+`?([^?`\n]{1,120}?)`?\s*(?:and\b[^?\n]*)?\?\s*$/i.exec(src.trim());
65
+ if (!m) return undefined;
66
+ const x = m[1].trim();
67
+ if (x.startsWith('/')) return offerable(x.replace(/\s+/g, ' '));
68
+ const cli = /^!?quox\s+([a-z][\w-]*)\s*$/i.exec(x);
69
+ if (cli) {
70
+ const slash = '/' + cli[1].toLowerCase();
71
+ const v = offerable(slash);
72
+ return v && v.readOnly ? v : null;
73
+ }
74
+ return null;
44
75
  }
45
76
 
46
77
  // A URL path, not a command (owner live 05:26: "the `/health` endpoint is erroring" offered "Run /health?").
@@ -0,0 +1,30 @@
1
+ /**
2
+ * HITL-INBOX-BAR (owner 2026-10-26: "more programmatic output, so nice and clear"): when a tool's result is a list, the
3
+ * facts are drawn as a table from the tool result, above whatever the model then says. Keyed by tool name; the server
4
+ * puts a bounded `card` on tool.finished (services/collector/lib/conversationToolEvents.js RESULT_CARDS). To add a
5
+ * list answer: add a renderer here and the matching projection there.
6
+ */
7
+ import { ageOf, fromOf } from './inboxView.js';
8
+
9
+ const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t; };
10
+ const pad = (s, n) => { const t = clip(s, n); return t + ' '.repeat(Math.max(0, n - [...t].length)); };
11
+
12
+ const RENDERERS = {
13
+ /** inbox_pending rows -> `# when kind priority title state` */
14
+ inbox_pending(card, { cols, now }) {
15
+ const rows = card.rows || [];
16
+ const tw = Math.max(16, Math.min(60, cols - 46));
17
+ const out = [{ head: true, text: `${pad('#', 3)}${pad('when', 6)}${pad('kind', 10)}${pad('prio', 8)}${pad('title', tw)} state` }];
18
+ rows.forEach((r, i) => out.push({ text: `${pad(i + 1, 3)}${pad(ageOf(r.created_at, now), 6)}${pad(r.kind || fromOf(r), 10)}${pad(r.priority || '', 8)}${pad(r.title || 'untitled', tw)} ${r.status || ''}` }));
19
+ if (Number.isInteger(card.total) && card.total > rows.length) out.push({ head: true, text: `showing ${rows.length} of ${card.total} · /inbox` });
20
+ return out;
21
+ },
22
+ };
23
+
24
+ /** -> [{ text, head? }] for a settled tool block that carries a card; [] otherwise. Never throws. */
25
+ export function toolCardLines(b, cols = 100, now = Date.now()) {
26
+ const fn = b && b.card && Array.isArray(b.card.rows) && RENDERERS[b.tool];
27
+ if (!fn) return [];
28
+ try { return fn(b.card, { cols, now }); } catch { return []; }
29
+ }
30
+ export const cardTools = () => Object.keys(RENDERERS);
@@ -87,3 +87,48 @@
87
87
  {"id":"footer-tool-count","date":"2026-10-19T12:00:00Z","since":"0.3.1","title":"Turn footer counts tools properly","text":"The line under an answer now reads 1 tool or 3 tools. Some builds printed [object Object]1 tools after a turn that used a tool."}
88
88
  {"id":"cancelled-reads-cancelled","date":"2026-10-19T14:00:00Z","since":"0.3.1","title":"Cancelled reads cancelled","text":"A stopped objective in the activity list now says cancelled (a neutral circle), not failed. Expired and rejected say their own word too, and an empty question no longer draws a stray rule above the composer."}
89
89
  {"id":"one-rule-above-composer","date":"2026-10-19T15:00:00Z","since":"0.3.1","title":"One rule above the composer","text":"Under an approval card the composer no longer draws a blank line and a second rule: the card's closing rule is the only one."}
90
+ {"id":"install-claude-code-codex","date":"2026-10-19T19:30:00Z","since":"0.3.1","title":"Install Claude Code and Codex from Quox","text":"Quox works best with both Claude and OpenAI models. If this server has no Claude Code or Codex, Quox offers to install it the moment you need it (a y/N card), or run quox provider install claude-code. It is the vendor's own npm package, downloaded to your server.","shell":"quox provider install claude-code","sha":"e802e90ce"}
91
+ {"id":"polish8-card-lists","date":"2026-10-19T19:40:00Z","since":"0.3.1","title":"Approval cards keep list lines, ask loosening takes --recommend","text":"A walk card now shows a file list one item per line under WHY, and text with HTML escapes shows as plain characters. A session can add its advice to a test-edit approval with --recommend y or n.","shell":"quox ask loosening --help"}
92
+ {"id":"polish8-exec-entities","date":"2026-10-19T20:00:00Z","since":"0.3.1","title":"Approval cards show commands as typed","text":"An approval for a command no longer shows HTML escapes like > in the command, reason or request lines.","shell":"quox ask loosening --help"}
93
+ {"id":"restart-min-gap","date":"2026-10-19T20:10:00Z","since":"0.3.1","title":"At most one auto-restart offer every 15 minutes","text":"The window no longer offers a restart within 15 minutes of starting; the newest settled build is offered once after that, and /restart still works any time.","try":"/restart"}
94
+ {"id":"named-offer-one-readonly","date":"2026-10-19T20:20:00Z","since":"0.3.1","title":"Only one complete read-only command is offered from an answer","text":"When an answer merely mentions a command, the window no longer offers a write like /broadcast or one missing its required text; it offers nothing unless there is exactly one safe command."}
95
+ {"id":"fun-commands-try-these","date":"2026-10-19T20:30:00Z","since":"0.3.1","title":"Ask Quox for fun commands and get a real list","text":"Ask for fun or cool commands, or what Quox can do, and the answer is a hand-picked set (the dragon verbs, /challenge, /spawn, /crystallize and more), each with a one-line reason to try it. /help ends with a short Try these block.","try":"/help"}
96
+ {"id":"q-shortcut","date":"2026-10-19T20:30:00Z","since":"0.3.1","title":"A short q command for quox","text":"Run quox shortcut add to make q start quox. It never replaces a q you already have, and quox shortcut remove undoes it.","shell":"quox shortcut add --help"}
97
+ {"id":"note-notesq","date":"2026-10-19T20:30:00Z","title":"/note saves a NotesQ note","text":"/note <text> now creates a NotesQ note and /notes lists them. The developer observer note moved to /devnote (// text still works).","try":"/note buy milk","since":"0.3.0"}
98
+ {"id":"findings-reconcile","date":"2026-10-19T21:15:00Z","since":"0.3.1","title":"Fixed security findings stop raising alarms","text":"quox engagement reconcile checks each open finding against its linked issue and fix commit. A finding whose fix is on main or in the running build now reads \"fix deployed, awaiting verification\" in /sitrep instead of an alarm. It is closed only when the issue is closed AND the fix is deployed, a critical never closes on its own, and quox engagement reconciliations shows the audit trail.","shell":"quox engagement reconcile --dry-run"}
99
+ {"id":"walk-recheck","date":"2026-10-19T22:40:00Z","since":"0.3.1","title":"The walkthrough ends by re-checking the server","text":"After your last answer the approvals walkthrough fetches the list fresh and ends with \"Nothing else is currently waiting for you.\", or tells you how many new ones arrived, or that it could not re-check. No need to run /approvals again.","try":"/walkthrough"}
100
+ {"id":"slash-plugins","date":"2026-10-19T23:55:00Z","since":"0.3.1","title":"/plugins lists your installed extensions","text":"/plugins (also /extensions, /addons, /apps) shows the plugins your org has installed with their state and version, and a mistyped slash command now suggests the nearest real one.","try":"/plugins"}
101
+ {"id":"qpe-rows","date":"2026-10-19T23:59:00Z","since":"0.3.1","title":"The proof engine rows are coloured, lined up and three deep","text":"The QPE rows now read as three rows on one grid: the score coloured by health with its sparkline, 24 h change and release-proven jobs; what got fixed and broke, the last run and who ran it, the next run; coverage coloured by ratio with the weakest area named. Narrow windows drop whole items rather than cutting words. Status rows can now send coloured cells (quox rows contract).","try":"/qpe rows 3"}
102
+ {"id":"qpe-content","date":"2026-10-20T03:30:00Z","since":"0.3.1","title":"A fourth QPE line: your cli.quox.ai content","text":"The proof engine lines now add, not just respread: the newest broken case by name, what the newest release is waiting on, the next run (and why today's was deferred), and a fourth line for cli.quox.ai content: pages backed by proof, guides, things to try, journeys and command pages. One call draws all four. /qpe 4 (or unset) shows them all.","try":"/qpe 4"}
103
+ {"id":"slash-todo","date":"2026-10-20T10:00:00Z","since":"0.3.1","title":"/todo adds Action Tracker items, /todos moves them","text":"/todo <text> adds an item and confirms with its id. /todos lists every item by column as td1 (to do), do1 (doing), dn1 (done); then /todo move do2 done, /todo td3 doing. From a shell: quox actions add \"<text>\" [--due tomorrow], quox actions move <id> done, quox actions list (every page).","try":"/todos"}
104
+ {"id":"approve-says-who","date":"2026-10-23T12:00:00Z","since":"0.3.1","title":"Approving or denying says who, what and where","text":"After you answer a card, the confirmation now reads: You approved VULCAN-SEC to run a shell command on web01. Denials use the same shape. Anything the card did not say is left out.","try":"/approvals"}
105
+ {"id":"bar-approvals","date":"2026-10-23T12:00:00Z","since":"0.3.1","title":"The line above the input says what is waiting","text":"The divider above the input box now always shows your approvals: ✔ nothing waiting, ⏸ 5 approvals waiting, or approvals ? when the inbox cannot be read or you are signed out (never a false zero). On narrow windows the model name goes first, the approvals note stays whole.","try":"/approvals"}
106
+ {"id":"done-cmd","date":"2026-10-23T14:00:00Z","since":"0.3.1","title":"/done marks Action Tracker items done","text":"Type /done td1,td2 (ids from /todos) to mark several items done at once; /todo move td1,td2 done takes lists too.","try":"/done"}
107
+ {"id":"server-chip","date":"2026-10-23T20:00:00Z","since":"0.3.1","title":"The status line names the server you are on","text":"If the machine Quox runs on is a registered server, the status line now shows it, for example server dev01 (fleet), and the remote instance when your profile talks to another one.","shell":"quox fleet hosts"}
108
+ {"id":"evidence-bundle","date":"2026-10-23T19:10:00Z","since":"0.3.1","title":"/evidence exports this conversation as a signed bundle","text":"/evidence writes every envelope, WARD receipt and VOLT run of this conversation to ~/quox-evidence/ as a signed zip and prints how to check it offline (quox evidence verify <file>). /proof now lists the envelope ids of the conversation, newest first. /ward, /envelope, /envelopes, /aee and /volt point at the same evidence.","try":"/evidence"}
109
+ {"id":"server-go","date":"2026-10-23T21:00:00Z","since":"0.3.1","title":"Open a Quox window on any dev server","text":"quox server list shows your registered dev servers and which are reachable; quox server go <name> opens Quox on one. The status line now shows the server purpose (for example main dev). /server lists them in the app.","try":"/server"}
110
+ {"id":"dragon-companion","date":"2026-10-23T21:00:00Z","since":"0.3.1","title":"Dragon companions: your attached agent's dragon greets you","text":"Dragon companions: your attached agent's dragon greets you. /dragon companion on|greeting|off.","try":"/dragon companion status"}
111
+ {"id":"stop-cmd","date":"2026-10-23T21:00:00Z","since":"0.3.1","title":"/stop and /cancel stop the running turn","text":"Type /stop (or /cancel) to stop the current turn, same as Esc. /clear now cancels a running turn too, and a stopped turn no longer leaves the status line animating.","try":"/stop"}
112
+ {"id":"approvals-ux","date":"2026-10-23T22:00:00Z","since":"0.3.1","title":"Approvals: readable rows, full content, walkthrough covers them","text":"/approvals rows now show what each approval says (the start of an X post), /approvals N shows the full content, a bare y after a list asks which one instead of going to chat, and /walkthrough walks plain approvals as cards.","try":"/walkthrough"}
113
+ {"id": "dragon-transient", "date": "2026-10-23T23:00:00Z", "since": "0.3.1", "title": "The dragon comes and goes", "text": "The dragon companion no longer stays on screen. It appears when you attach, at key moments (a new approval, a failed turn, a long task finishing) and when you type /dragon, then leaves after a few seconds or on your next keypress.", "try": "/dragon"}
114
+ {"id": "resume-attach-coherent", "date": "2026-10-24T01:00:00Z", "since": "0.3.1", "title": "Resume keeps the right agent", "text": "Resuming a conversation now attaches you to the agent it was with, so the footer and your next turn agree. After a restart, if the saved attachment and conversation differ, you get one warning and a fresh conversation. Resumed titles never show painted screen characters.", "try": "/resume"}
115
+ {"id": "dragon-smooth", "date": "2026-10-24T03:30:00Z", "since": "0.3.1", "title": "Companion dragons animate smoothly, and bigger", "text": "Lucid, Hyperion and Seer now play through frames drawn off the main thread, so they move as smoothly as the Quox dragon and no longer stutter the screen. They are larger, stay for 10 seconds (a keypress in the first 2 seconds will not send them away), every typed /attach greets, and /dragon companion calls the dragon too.", "try": "/dragon"}
116
+ {"id": "say-last-target", "date": "2026-10-24T19:00:00Z", "since": "0.3.1", "title": "/say remembers who you were talking to", "text": "If the first word of a /say is not a slot (a URL, a path, plain text), Quox offers to send the whole message to the slot you last said something to. A URL is never taken as a slot name.", "try": "/say"}
117
+ {"id": "full-envelope-ids", "date": "2026-10-25T08:00:00Z", "since": "0.3.1", "title": "/proof and tool lines show the whole envelope id", "text": "Envelope ids, WARD seqs and bundle paths now wrap instead of being cut off, even in a narrow window. /proof puts the full id on its own line to copy, with the quox aee get and quox evidence verify commands, and the model can quote the id too.", "try": "/proof"}
118
+ {"id": "contacts-from-chat", "date": "2026-10-25T09:00:00Z", "since": "0.3.1", "title": "Add and list contacts by just asking", "text": "Ask Quox to add Sam Lee to your contacts with a number and it does, through the same approval rules as the CLI; ask to list your contacts and it pages through all of them. /contact new now builds a valid quox contacts add command (--mobile and --tel become --phone, multi-word names are quoted) and refuses flags the command does not have. quox contacts list pages with --offset.", "try": "/contact new"}
119
+ {"id": "contacts-send-at", "date": "2026-10-25T09:00:00Z", "since": "0.3.1", "title": "Schedule an SMS for a time, and see why a send failed", "text": "quox contacts send now takes --at (10am, 14:30 or a full ISO time, read in Europe/London unless you pass --tz) and the server delivers it then, even if you close the terminal. The answer states the exact time, and quox contacts scheduled list or cancel manages the queue. A failed send now says why in plain words.", "shell": "quox contacts send <contact> --channel sms --at 10am \"hello\""}
120
+ {"id": "attach-agent-first", "date": "2026-10-25T12:00:00Z", "since": "0.3.1", "title": "/attach seer now picks the agent first", "text": "When a name is both an agent and a slot, /attach <name> attaches the agent and tells you the slot exists. Use /attach slot:<name> for the slot or /attach agent:<name> to be explicit. A name that is only a slot still attaches the slot.", "try": "/attach slot:seer"}
121
+ {"id": "dragons-sentinel-drago", "date": "2026-10-25T19:00:00Z", "since": "0.3.1", "title": "Two new dragons: Sentinel (Warden) and Drago", "text": "Two new dragons: Sentinel (Warden) and Drago. Attach Warden or the security lead and they greet you, wait with you at approvals and show when something needs attention. Sentinel shows its shield icon on smaller terminals.", "try": "/attach sentinel"}
122
+ {"id": "dragons-cipher", "date": "2026-10-25T21:00:00Z", "since": "0.3.1", "title": "New dragon: Cipher", "text": "New dragon: Cipher, the network operations dragon. Attach cipher and it greets you, listens while you work, waits with you at approvals and shows when something needs attention.", "try": "/attach cipher"}
123
+ {"id": "objective-offer", "date": "2026-10-25T22:00:00Z", "since": "0.3.1", "title": "Ask for something your agent cannot do, get an objective", "text": "Ask an attached agent for a multi-step job it has no tools for and Quox now offers (y or n) to make it an objective that runs until done. You approve the plan, then each step.", "try": "/attach seer"}
124
+ {"id": "dragon-jobs", "date": "2026-10-25T23:00:00Z", "since": "0.3.1", "title": "Dragons now report real state", "text": "Each companion dragon has one job and says it from a live read: Sentinel the WARD state of your last answer, Cipher route health, Seer the plan, Hyperion the fleet, Drago open security findings, Lucid recovery. If a read fails it says it could not check.", "try": "/attach cipher"}
125
+ {"id": "dragon-summons", "date": "2026-10-25T23:30:00Z", "since": "0.3.1", "title": "Dragons turn up when your work finishes", "text": "With the dragon companion on, a finished or failed objective or loop calls Seer for a few seconds with the real result, even with no agent attached. Never while you are typing, and each dragon waits a minute before it can be called again.", "try": "/dragon companion status"}
126
+ {"id": "loop-card", "date": "2026-10-26T00:00:00Z", "since": "0.3.1", "title": "Watch a loop as it runs", "text": "A loop you start from chat now shows a live card that updates in place: turns, receipts witnessed, and the last lines of its output. Milestones and errors land as chat lines under it, and a finished loop prints its proof command.", "try": "/loop list"}
127
+ {"id": "loop-offer", "date": "2026-10-26T01:00:00Z", "since": "0.3.1", "title": "Ask for a loop and Quox offers one", "text": "Say \"keep checking until it is green\" or \"run it as a loop\" and Quox offers to create a loop with a tool list worked out from your goal (read-only unless the goal writes a file or runs a command). You see exactly which tools it may use and approve it before it starts. A fixed cadence like \"every 15 minutes\" is offered as a schedule instead.", "try": "/loop list"}
128
+ {"id": "dragon-fit", "date": "2026-10-26T01:00:00Z", "since": "0.3.1", "title": "Dragons fit your window, or tell you why not", "text": "/dragon fire and /dragon-fly now size to your real window and say how many rows they need when it is too small. /detach brings the Q dragon back to greet you when dragons are on.", "try": "/dragon-fly"}
129
+ {"id":"restart-waits-server","date":"2026-10-26T02:10:00Z","title":"Auto restart waits for Quox to come back","text":"When a new version lands while the Quox server is restarting, the window now waits for the server before it restarts, and picks its slot back up once the server answers.","try":"/restart","since":"0.3.1"}
130
+ {"id":"keep-going-plain-words","date":"2026-10-26T04:00:00Z","title":"Ask for a watch in plain words","text":"Say \"keep an eye on quox.ai for the next 10 minutes\" or \"watch the build until it is green\" and Quox offers to keep going for you, no need to say loop. One key to start it, one to just answer once.","try":"/watch quox.ai for downtime","since":"0.3.1"}
131
+ {"id": "objective-offer-reports-back", "date": "2026-10-26T05:30:00Z", "title": "Objectives report back in the chat", "text": "Say a long multi-step ask to an advisory agent and Quox offers to run it as an objective. The plan routes each step to an agent that holds the tool, a fallback like \"if something goes wrong, save the draft\" is honoured, and when it ends the chat tells you what happened and where things are.", "try": "/attach seer", "since": "0.3.1"}
132
+ {"id": "loop-card-polish", "date": "2026-10-26T13:00:00Z", "since": "0.3.1", "title": "The loop card keeps its own count", "text": "The card now counts the loop's own turns (turn 2 of 5, not the message count), shows the whole runway, fills a receipts bar as each one is witnessed, and says a milestone once. With an empty composer, press Down to reach its keys. A finished loop calls its dragon.", "try": "/loop list"}
133
+ {"id": "org-picker", "date": "2026-10-26T16:30:00Z", "since": "0.3.1", "title": "Switch org in one keystroke", "text": "Type /org for a list of your orgs, arrow to one and press Enter: this window moves there at once and the status bar follows. Press d instead to also make it your default. /org hampshire switches straight away when only one org matches, and quox org switch with no argument gives a numbered list.", "try": "/org"}
134
+ {"id":"quoxcli-0-4-0","date":"2026-10-26T18:00:00Z","since":"0.4.0","title":"quoxon extensions, an inbox bar, history and mouse editing","text":"Add and manage extensions with quox ext add, quox ext remove and quox ext list, or /ext in the window. /inbox and a notification bar show what is waiting. Up and Ctrl+R search your history. /mouse turns on optional mouse editing. Plan approval cards now list their steps, and live loop cards no longer freeze the window.","try":"/ext","shell":"quox ext list"}
@@ -97,7 +97,17 @@ export function createWindowOrg({ auth, baseClient, now = () => Date.now() }) {
97
97
  */
98
98
  export function createWindowClient({ config, auth, createClient }) {
99
99
  const windowOrg = createWindowOrg({ auth, baseClient: createClient(config, auth) });
100
- return { client: createClient(config, windowOrg.auth), auth: windowOrg.auth, windowOrg };
100
+ // A client on the SAME user's token for another org, without moving this window (cached; each has its own overlay).
101
+ const scoped = new Map();
102
+ const clientForOrg = async (orgId) => {
103
+ if (scoped.has(orgId)) return scoped.get(orgId);
104
+ let cl;
105
+ if (orgId === (() => { try { return auth.getOrgId(); } catch { return null; } })()) cl = createClient(config, auth);
106
+ else { const sub = createWindowOrg({ auth, baseClient: createClient(config, auth) }); await sub.switchTo(orgId); cl = createClient(config, sub.auth); }
107
+ scoped.set(orgId, cl);
108
+ return cl;
109
+ };
110
+ return { client: createClient(config, windowOrg.auth), auth: windowOrg.auth, windowOrg, clientForOrg };
101
111
  }
102
112
 
103
113
  /**
@@ -0,0 +1,137 @@
1
+ // DEVFLEET: the dev-server list behind `quox server list|go|mark` and the TUI /server picker.
2
+ // A dev server is a Bastion host (`quox fleet hosts`) that has a local entry in dev-servers.json
3
+ // (purpose, and optionally project, repo, workdir, ci, user, jump). Bastion supplies address + state;
4
+ // the entry supplies what the server is FOR. Pure functions here; the file read/write is the only IO.
5
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
6
+ import { homedir, hostname } from 'node:os';
7
+ import { join, dirname } from 'node:path';
8
+ import { matchServer, localIps } from '../quoxon/serverChip.js';
9
+ import { resolveEndpoint } from '../api/endpoints.js';
10
+
11
+ const FIELDS = ['purpose', 'project', 'repo', 'workdir', 'ci', 'user', 'jump', 'lastDeploy'];
12
+ const SAFE = /^[A-Za-z0-9._@:\/~+-]+$/;
13
+
14
+ export function registryPath(env = process.env) {
15
+ return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'quox', 'dev-servers.json');
16
+ }
17
+
18
+ /** -> { servers: { [name]: entry } }; a missing file is empty, a corrupt one throws (never clobbered). */
19
+ export function readRegistry(path = registryPath()) {
20
+ let raw;
21
+ try { raw = readFileSync(path, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return { servers: {} }; throw e; }
22
+ const j = JSON.parse(raw);
23
+ return { servers: j && typeof j.servers === 'object' && j.servers ? j.servers : {}, defaults: j && typeof j.defaults === 'object' && j.defaults ? j.defaults : {} };
24
+ }
25
+
26
+ /** Additive upsert of one server's entry; unspecified fields are kept. Atomic write. */
27
+ export function markServer(name, fields, path = registryPath()) {
28
+ if (!name || !SAFE.test(name)) throw new Error(`invalid server name: ${name}`);
29
+ const reg = readRegistry(path);
30
+ const next = { ...(reg.servers[name] || {}) };
31
+ for (const k of FIELDS) if (fields[k] != null && fields[k] !== '') next[k] = String(fields[k]);
32
+ reg.servers[name] = next;
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ const tmp = `${path}.${process.pid}.tmp`;
35
+ writeFileSync(tmp, JSON.stringify(reg, null, 2) + '\n', { mode: 0o600 });
36
+ renameSync(tmp, path);
37
+ return next;
38
+ }
39
+
40
+ function writeCache(reg, path) {
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ const tmp = `${path}.${process.pid}.tmp`;
43
+ writeFileSync(tmp, JSON.stringify(reg, null, 2) + '\n', { mode: 0o600 });
44
+ renameSync(tmp, path);
45
+ }
46
+
47
+ const pickFields = (e) => Object.fromEntries(FIELDS.filter(k => e && e[k] != null && e[k] !== '').map(k => [k, String(e[k])]));
48
+
49
+ /**
50
+ * The registry lives SERVER-SIDE (org-scoped, collector); the local file is an offline cache only.
51
+ * -> { servers, source: 'server' | 'cache', migrated }. A good server read refreshes the cache. If the server
52
+ * has no entries and the cache has some, they are uploaded once (migration). Unreachable or a non-registry
53
+ * answer -> the cache (a corrupt cache throws, never clobbered).
54
+ */
55
+ export async function loadRegistry({ client, path = registryPath() } = {}) {
56
+ let remote = null;
57
+ let defaults = {}; // local-only preferences (e.g. defaults.jump, the ssh ProxyJump alias): never uploaded, kept across cache refreshes
58
+ try { defaults = readRegistry(path).defaults; } catch { /* corrupt cache: no local defaults */ }
59
+ try {
60
+ const ep = resolveEndpoint('devServerRegistry.list');
61
+ const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 5000 });
62
+ if (d && typeof d.servers === 'object' && d.servers && !Array.isArray(d.servers)) remote = d.servers;
63
+ } catch { /* offline: fall back to the cache */ }
64
+ if (!remote) return { servers: readRegistry(path).servers, defaults, source: 'cache', migrated: 0 };
65
+ let migrated = 0;
66
+ let intact = true;
67
+ if (!Object.keys(remote).length) {
68
+ let local = {};
69
+ try { local = readRegistry(path).servers; } catch { /* unreadable cache: nothing to migrate */ }
70
+ for (const [name, e] of Object.entries(local)) {
71
+ try {
72
+ const ep = resolveEndpoint('devServerRegistry.put', { name });
73
+ await client.put(ep.path, { service: ep.service, body: pickFields(e) });
74
+ remote[name] = pickFields(e); migrated++;
75
+ } catch { intact = false; break; /* the cache keeps what did not upload; the next read retries */ }
76
+ }
77
+ // A failed upload must never be followed by a cache overwrite: the cache is then the only copy.
78
+ if (!intact) return { servers: { ...local, ...remote }, defaults, source: 'cache', migrated };
79
+ }
80
+ try { writeCache({ servers: remote, defaults }, path); } catch { /* cache is best effort */ }
81
+ return { servers: remote, defaults, source: 'server', migrated };
82
+ }
83
+
84
+ /** Upsert one entry on the server (additive), then refresh the cache. Offline -> throws, nothing written. */
85
+ export async function saveServer({ client, name, fields, path = registryPath() }) {
86
+ if (!name || !SAFE.test(name)) throw new Error(`invalid server name: ${name}`);
87
+ const ep = resolveEndpoint('devServerRegistry.put', { name });
88
+ let r;
89
+ try { r = await client.put(ep.path, { service: ep.service, body: pickFields(fields) }); }
90
+ catch (e) { throw new Error(`could not save to the Quox server (${e?.message || 'failed'}); nothing was recorded`); }
91
+ let entry = r && r.entry && typeof r.entry === 'object' ? pickFields(r.entry) : null;
92
+ if (!entry) { try { entry = markServer(name, fields, path); } catch { entry = pickFields(fields); } }
93
+ else { try { const reg = readRegistry(path); reg.servers[name] = entry; writeCache(reg, path); } catch { /* cache is best effort */ } }
94
+ return entry;
95
+ }
96
+
97
+ /** Bastion host list + registry -> rows for each registered server. `here` = { hostname, ips }. */
98
+ export function buildRows(hosts, registry, here = {}) {
99
+ const list = Array.isArray(hosts) ? hosts : [];
100
+ const me = matchServer(list, here);
101
+ return Object.entries(registry?.servers || {}).map(([name, e]) => {
102
+ const h = list.find(x => x && x.id === name) || null;
103
+ return {
104
+ name, purpose: e.purpose || null, project: e.project || null, repo: e.repo || null, workdir: e.workdir || null,
105
+ ci: e.ci || null, lastDeploy: e.lastDeploy || null,
106
+ ip: h?.ip || null,
107
+ registered: Boolean(h),
108
+ reachable: h ? (h.status?.state ?? h.state) === 'active' : false,
109
+ current: Boolean(me && me.name === name),
110
+ user: e.user || null, jump: e.jump || null, defaultJump: registry?.defaults?.jump || null
111
+ };
112
+ });
113
+ }
114
+
115
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
116
+
117
+ /**
118
+ * What `go` will run. -> { error } | { local: true, cmd, args, cwd, text } | { cmd:'ssh', args, text }.
119
+ * Remote: the user's own ssh config/keys via the jump host alias; nothing is copied anywhere.
120
+ */
121
+ export function planGo(row) {
122
+ if (!row) return { error: 'unknown server' };
123
+ if (!row.registered) return { error: `${row.name} is not in the Bastion host registry (quox fleet hosts), so go cannot reach it` };
124
+ if (!row.reachable) return { error: `${row.name} is not reachable according to the Bastion registry; try: quox fleet ping ${row.name}` };
125
+ if (row.current) {
126
+ const cwd = row.workdir || process.cwd();
127
+ return { local: true, cmd: 'quox', args: [], cwd, text: `cd ${cwd} && quox (this is ${row.name}; local)` };
128
+ }
129
+ if (!row.ip) return { error: `${row.name} has no address in the Bastion registry` };
130
+ const target = `${row.user || 'control'}@${row.ip}`;
131
+ const remote = `${row.workdir ? `cd ${shq(row.workdir)} && ` : ''}exec quox`;
132
+ const jump = row.jump || row.defaultJump || null; // a per-server --jump, else the registry's defaults.jump, else a direct ssh
133
+ const args = ['-t', ...(jump ? ['-o', `ProxyJump=${jump}`] : []), target, remote];
134
+ return { cmd: 'ssh', args, text: `ssh ${args.slice(0, 4).join(' ')} ${shq(remote)}` };
135
+ }
136
+
137
+ export const hereIdentity = () => ({ hostname: hostname(), ips: localIps() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quoxcli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Quox: the conversational front door to your self-hosted AI agent control plane",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,6 +53,7 @@
53
53
  "files": [
54
54
  "bin/",
55
55
  "lib/",
56
+ "vendor/",
56
57
  "!lib/**/*.test.js",
57
58
  "!lib/**/*.test.jsx",
58
59
  "!lib/**/__tests__/",
@@ -0,0 +1,4 @@
1
+ repo: quoxai/dragonlab
2
+ commit: 5b478a813554eb80793b848d14da1468ea3bdcef
3
+ branch: HEAD
4
+ files: 52