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
@@ -192,22 +192,32 @@ export async function connect(deps, w) {
192
192
  export function setupScript(nodeMajor, { docker = false } = {}) {
193
193
  return [
194
194
  'set -e',
195
+ // set -e does not fire inside an && list or on the left of a pipe, so every install step below ends in || exit
196
+ 'set -o pipefail',
195
197
  'SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo -n"',
196
198
  `NEED=${Number(nodeMajor)}`,
197
199
  'export DEBIAN_FRONTEND=noninteractive',
200
+ // A fresh pool VM runs apt-daily/unattended-upgrades at boot; wait for it (bounded) instead of failing on the dpkg lock
201
+ 'for i in $(seq 1 120); do pgrep -x apt-get >/dev/null || pgrep -f unattended-upgr >/dev/null || pgrep -x dpkg >/dev/null || break; sleep 5; done',
202
+ 'APT="$SUDO apt-get -o DPkg::Lock::Timeout=600"',
198
203
  'MISSING=""',
199
204
  'for p in git rsync build-essential python3 curl ca-certificates; do dpkg -s $p >/dev/null 2>&1 || MISSING="$MISSING $p"; done',
200
- 'if [ -n "$MISSING" ]; then $SUDO apt-get update -qq && $SUDO apt-get install -y -qq $MISSING; fi',
205
+ 'if [ -n "$MISSING" ]; then { $APT update -qq && $APT install -y -qq $MISSING; } || { echo "offload setup: apt install of$MISSING failed" >&2; exit 1; }; fi',
201
206
  'HAVE=$(node -p "process.versions.node.split(\'.\')[0]" 2>/dev/null || echo none)',
202
- 'if [ "$HAVE" != "$NEED" ]; then curl -fsSL https://deb.nodesource.com/setup_$NEED.x | $SUDO -E bash - && $SUDO apt-get install -y -qq nodejs; fi',
207
+ // a worker already on a NEWER major is fine (callers on this box run 20 and 22); never flip it between majors
208
+ 'case "$HAVE" in none|"") HAVE=0;; esac',
209
+ 'if [ "$HAVE" -lt "$NEED" ]; then { curl -fsSL https://deb.nodesource.com/setup_$NEED.x | ${SUDO:+$SUDO -E} bash - && $APT install -y -qq nodejs; } || { echo "offload setup: node $NEED install failed" >&2; exit 1; }; fi',
203
210
  ...(docker ? [
204
211
  // --docker: the daemon plus buildx (the sandbox Dockerfiles use COPY --chmod, which needs BuildKit)
205
212
  'DMISSING=""',
206
213
  'for p in docker.io docker-buildx; do dpkg -s $p >/dev/null 2>&1 || DMISSING="$DMISSING $p"; done',
207
- 'if [ -n "$DMISSING" ]; then $SUDO apt-get update -qq && $SUDO apt-get install -y -qq $DMISSING; fi',
214
+ 'if [ -n "$DMISSING" ]; then { $APT update -qq && $APT install -y -qq $DMISSING; } || { echo "offload setup: docker install failed" >&2; exit 1; }; fi',
208
215
  '$SUDO systemctl enable --now docker >/dev/null 2>&1 || true',
209
216
  `id -nG ${REMOTE_USER} | grep -qw docker || $SUDO usermod -aG docker ${REMOTE_USER}`,
210
217
  ] : []),
218
+ // Never record a worker as ready unless the toolchain is really there (2026-10-20: workers marked ready with no npm).
219
+ 'command -v npm >/dev/null || { echo "offload setup: npm missing after setup" >&2; exit 1; }',
220
+ '[ "$(node -p "process.versions.node.split(\'.\')[0]")" -ge "$NEED" ] || { echo "offload setup: node is older than $NEED" >&2; exit 1; }',
211
221
  `mkdir -p ${REMOTE_ROOT}`,
212
222
  ].join('\n');
213
223
  }
@@ -236,18 +246,21 @@ export function sandboxImagesScript(root, sudo = 'sudo -n') {
236
246
  ].join('\n');
237
247
  }
238
248
 
249
+ export const SETUP_REV = 2;
250
+
239
251
  export async function ensureTooling(deps, w, { docker = false } = {}) {
240
252
  const st = loadState(deps);
241
253
  const major = String(deps.nodeVersion).replace(/^v/, '').split('.')[0];
242
- const done = String(st.worker?.setup || '');
254
+ // SETUP_REV: bumped when the setup script's guarantees change, so a worker recorded under an older script redoes setup.
255
+ const done = String(st.worker?.setup || '').startsWith(`r${SETUP_REV}:`) ? String(st.worker.setup).slice(`r${SETUP_REV}:`.length) : '';
243
256
  // setup is recorded as "<node major>" or "<node major>+docker"; a docker run needs the +docker form
244
- if (st.worker?.vmid === w.vmid && done.split('+')[0] === major && (!docker || done.endsWith('+docker'))) return false;
257
+ if (st.worker?.vmid === w.vmid && Number(done.split('+')[0]) >= Number(major) && (!docker || done.endsWith('+docker'))) return false;
245
258
  const withDocker = docker || done.endsWith('+docker');
246
259
  deps.log(`preparing the worker (node, git, rsync, build tools${withDocker ? ', docker' : ''})...`);
247
260
  const r = await ssh(deps, w.ip, `bash -c ${shq(setupScript(major, { docker: withDocker }))}`);
248
261
  if (r.status !== 0) throw new OffloadError(`worker setup failed (exit ${r.status}): ${r.cmdline}\n${(r.stderr || '').trim()}`);
249
262
  const st2 = loadState(deps);
250
- if (st2.worker) { st2.worker.setup = withDocker ? `${major}+docker` : major; saveState(deps, st2); }
263
+ if (st2.worker) { st2.worker.setup = `r${SETUP_REV}:` + (withDocker ? `${major}+docker` : major); saveState(deps, st2); }
251
264
  return true;
252
265
  }
253
266
 
@@ -255,7 +268,13 @@ export async function ensureTooling(deps, w, { docker = false } = {}) {
255
268
  // Before a sync, drop synced dirs idle longer than the limit (dir mtime = last use, touched at every sync), never the
256
269
  // one being synced and never one a run is busy in (.offload-busy younger than 6h; an older one is a crashed run).
257
270
  export const PRUNE_IDLE_MIN = 120;
258
- export function pruneScript(root, keep, maxIdleMin = PRUNE_IDLE_MIN) {
271
+ // Under disk pressure (free below minFreePct) the least recently used synced dirs go too, oldest first, never the
272
+ // current one or a busy one, until free space reaches targetFreePct (2026-10-20: ~30 dirs all used within the hour
273
+ // filled the shared worker and the idle rule freed nothing).
274
+ export const PRUNE_MIN_FREE_PCT = 20;
275
+ export const PRUNE_TARGET_FREE_PCT = 35;
276
+ const DF_FREE_PCT = 'df -P . | awk \'NR==2 { gsub("%", "", $5); print 100 - $5 }\'';
277
+ export function pruneScript(root, keep, maxIdleMin = PRUNE_IDLE_MIN, { minFreePct = PRUNE_MIN_FREE_PCT, targetFreePct = PRUNE_TARGET_FREE_PCT, freePctCmd = DF_FREE_PCT } = {}) {
259
278
  return [
260
279
  `cd ${shq(root)} 2>/dev/null || exit 0`,
261
280
  'for d in */; do d=${d%/}; [ -d "$d" ] || continue',
@@ -265,6 +284,16 @@ export function pruneScript(root, keep, maxIdleMin = PRUNE_IDLE_MIN) {
265
284
  'done',
266
285
  // cache entries nobody has used for a day go too (each worktree's copy is only hard links into these)
267
286
  `find ${shq(`${root}/.nm-cache`)} -mindepth 1 -maxdepth 1 -mmin +1440 -exec rm -rf {} + 2>/dev/null; true`,
287
+ `free_pct() { ${freePctCmd}; }`,
288
+ `if [ "$(free_pct)" -lt ${Number(minFreePct)} ]; then`,
289
+ ' for d in $(ls -1tr); do [ -d "$d" ] || continue',
290
+ ` [ "$(free_pct)" -ge ${Number(targetFreePct)} ] && break`,
291
+ ` [ "$d" = ${shq(keep)} ] && continue`,
292
+ ' [ -e "$d/.offload-busy" ] && [ -n "$(find "$d/.offload-busy" -mmin -360 2>/dev/null)" ] && continue',
293
+ ' rm -rf -- "$d"',
294
+ ' done',
295
+ 'fi',
296
+ 'true',
268
297
  ].join('\n');
269
298
  }
270
299
 
@@ -83,9 +83,9 @@ export function formatDetail(obj, options = {}) {
83
83
  const label = (labels[field] || field).padEnd(maxLabel + 2);
84
84
  const raw = obj[field];
85
85
  if (raw !== null && typeof raw === 'object') {
86
- console.log(` ${chalk.dim(label)}${formatNestedValue(raw)}`);
86
+ console.log(` ${chalk.dim(label)}${formatNestedValue(raw, 4, options.full === true)}`);
87
87
  } else {
88
- const value = formatValue(raw);
88
+ const value = formatValue(raw, options.full === true);
89
89
  console.log(` ${chalk.dim(label)} ${value}`);
90
90
  }
91
91
  }
@@ -97,15 +97,15 @@ export function formatDetail(obj, options = {}) {
97
97
  * Kept local to formatDetail (not folded into formatValue) so formatTable /
98
98
  * formatCsv output is unchanged.
99
99
  */
100
- function formatNestedValue(value, indent = 4) {
100
+ function formatNestedValue(value, indent = 4, full = false) {
101
101
  if (value == null) return ` ${chalk.dim('-')}`;
102
102
 
103
103
  if (Array.isArray(value)) {
104
104
  if (value.length === 0) return ` ${chalk.dim('[]')}`;
105
105
  const allPrimitive = value.every(v => v === null || typeof v !== 'object');
106
- if (allPrimitive) return ` ${value.map(v => formatValue(v)).join(', ')}`;
106
+ if (allPrimitive) return ` ${value.map(v => formatValue(v, full)).join(', ')}`;
107
107
  return '\n' + value
108
- .map(v => `${' '.repeat(indent)}- ${typeof v === 'object' && v !== null ? formatNestedValue(v, indent + 2).trimStart() : formatValue(v)}`)
108
+ .map(v => `${' '.repeat(indent)}- ${typeof v === 'object' && v !== null ? formatNestedValue(v, indent + 2, full).trimStart() : formatValue(v, full)}`)
109
109
  .join('\n');
110
110
  }
111
111
 
@@ -115,9 +115,9 @@ function formatNestedValue(value, indent = 4) {
115
115
  .map(k => {
116
116
  const v = value[k];
117
117
  if (v !== null && typeof v === 'object') {
118
- return `${' '.repeat(indent)}${chalk.dim(k + ':')}${formatNestedValue(v, indent + 2)}`;
118
+ return `${' '.repeat(indent)}${chalk.dim(k + ':')}${formatNestedValue(v, indent + 2, full)}`;
119
119
  }
120
- return `${' '.repeat(indent)}${chalk.dim(k + ':')} ${formatValue(v)}`;
120
+ return `${' '.repeat(indent)}${chalk.dim(k + ':')} ${formatValue(v, full)}`;
121
121
  })
122
122
  .join('\n');
123
123
  }
@@ -15,7 +15,7 @@
15
15
  * released on every exit path.
16
16
  * Pure parts take injected tmux output / held slots / attach lookup.
17
17
  */
18
- import { spawn as realSpawn, spawnSync as realSpawnSync } from 'node:child_process';
18
+ import { spawnSync as realSpawnSync } from 'node:child_process';
19
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync, appendFileSync, truncateSync } from 'node:fs';
20
20
  import { homedir } from 'node:os';
21
21
  import { join } from 'node:path';
@@ -70,9 +70,10 @@ export function buildBeat({ probe, backend = null, agent = null, slot = null, he
70
70
  };
71
71
  }
72
72
 
73
- /** Live probe of one tmux session (exact match). null when it does not exist. */
73
+ /** Live probe of one tmux session (exact match). null when it does not exist.
74
+ * `=name:` (session form): tmux 3.4 answers a bare `=name` with an empty line. */
74
75
  export function probeTmux(name, spawnSync = realSpawnSync) {
75
- const r = spawnSync('tmux', ['display-message', '-p', '-t', `=${name}`, PROBE_FORMAT], { encoding: 'utf8' });
76
+ const r = spawnSync('tmux', ['display-message', '-p', '-t', `=${name}:`, PROBE_FORMAT], { encoding: 'utf8' });
76
77
  if (!r || r.error || r.status !== 0) return null;
77
78
  const p = parseProbe(r.stdout);
78
79
  return p && p.name === name ? p : null;
@@ -177,16 +178,19 @@ export function fileLogger(name, dir = HB_DIR()) {
177
178
  * Start the detached heartbeat child for a tmux session the wrapper launched.
178
179
  * The child is this same quox binary; it survives the wrapper exiting on detach.
179
180
  */
180
- export function startHeartbeat({ tmuxSession, backend, agent, slot }, { spawn = realSpawn, argv = process.argv, execPath = process.execPath } = {}) {
181
+ // Spawned by a short-lived node that exits at once, so the heartbeat is never
182
+ // the wrapper's child: the wrapper blocks in spawnSync(tmux) for the session's
183
+ // life and could not reap it (ZOMBIES 2026-10-24, one defunct per wrapper).
184
+ const DETACH_LAUNCHER = "const{spawn}=require('node:child_process');const c=spawn(process.argv[1],process.argv.slice(2),{detached:true,stdio:'ignore'});c.on('error',()=>{});c.unref();";
185
+
186
+ export function startHeartbeat({ tmuxSession, backend, agent, slot }, { spawnSync = realSpawnSync, argv = process.argv, execPath = process.execPath } = {}) {
181
187
  if (!argv[1]) return false;
182
188
  const args = [argv[1], 'sessions', 'heartbeat', tmuxSession, '--loop'];
183
189
  if (backend) args.push('--backend', backend);
184
190
  if (agent) args.push('--agent', agent);
185
191
  if (slot) args.push('--slot', slot);
186
192
  try {
187
- const child = spawn(execPath, args, { detached: true, stdio: 'ignore' });
188
- if (child && child.on) child.on('error', () => {});
189
- if (child && child.unref) child.unref();
190
- return true;
193
+ const r = spawnSync(execPath, ['-e', DETACH_LAUNCHER, execPath, ...args], { stdio: 'ignore', timeout: 10_000 });
194
+ return !!r && !r.error && r.status === 0;
191
195
  } catch { return false; }
192
196
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `quox shortcut add|remove` (QUOXON-Q): a short command (default `q`) that is a symlink to this quox.
3
+ *
4
+ * Rules: never shadow an existing command (a `q` already on PATH that is not ours is refused and named);
5
+ * a user-writable directory only (the one quox lives in, else ~/.local/bin); no sudo; no shell rc edits (a symlink
6
+ * works in every shell and script); idempotent; remove only ever removes a link that points at this quox.
7
+ * Local only: nothing is sent anywhere.
8
+ */
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ export const DEFAULT_NAME = 'q';
15
+ export const NAME_OK = /^[A-Za-z][A-Za-z0-9_.-]{0,30}$/;
16
+ export const QUOX_ENTRY = fileURLToPath(new URL('../../bin/quox.js', import.meta.url));
17
+
18
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
19
+ const isExec = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
20
+ const writable = (d) => { try { fs.accessSync(d, fs.constants.W_OK); return fs.statSync(d).isDirectory(); } catch { return false; } };
21
+ const pathDirs = (env) => String(env.PATH || '').split(path.delimiter).filter(Boolean);
22
+
23
+ /** `command -v name` over PATH -> the first executable path, or null. */
24
+ export function whichOnPath(name, env = process.env) {
25
+ for (const d of pathDirs(env)) { const p = path.join(d, name); if (isExec(p)) return p; }
26
+ return null;
27
+ }
28
+
29
+ /** Where the link goes: { dir, onPath, target }. target is what `q` will point at. */
30
+ export function planShortcut({ env = process.env, home = os.homedir(), entry = QUOX_ENTRY } = {}) {
31
+ const realEntry = real(entry) || entry;
32
+ const quoxOnPath = whichOnPath('quox', env);
33
+ // The `quox` on PATH may be a wrapper script rather than a link to this entry: it is still "this quox".
34
+ const onPathReal = quoxOnPath;
35
+ const target = onPathReal || realEntry;
36
+ const reals = new Set([realEntry, quoxOnPath && real(quoxOnPath)].filter(Boolean));
37
+ const dirs = pathDirs(env).map(d => path.resolve(d));
38
+ const local = path.join(home, '.local', 'bin');
39
+ const cands = [];
40
+ if (onPathReal) cands.push(path.dirname(onPathReal));
41
+ cands.push(local);
42
+ const dir = cands.find(writable) || (fs.existsSync(local) ? null : local);
43
+ return { dir, onPath: dir ? dirs.includes(path.resolve(dir)) : false, target, realEntry, reals };
44
+ }
45
+
46
+ const ours = (p, reals) => { try { return fs.lstatSync(p).isSymbolicLink() && reals.has(real(p)); } catch { return false; } };
47
+
48
+ /** -> { ok, status: 'created'|'exists'|'refused'|'error', message, link?, onPath? } */
49
+ export function addShortcut({ name = DEFAULT_NAME, env = process.env, home = os.homedir(), entry = QUOX_ENTRY } = {}) {
50
+ if (!NAME_OK.test(name)) return { ok: false, status: 'error', message: `"${name}" is not a usable command name (letters, digits, _ . -, starting with a letter).` };
51
+ const plan = planShortcut({ env, home, entry });
52
+ const found = whichOnPath(name, env);
53
+ if (found && plan.reals.has(real(found))) return { ok: true, status: 'exists', link: found, message: `${name} already runs quox (${found}). Nothing changed.` };
54
+ if (found) return { ok: false, status: 'refused', message: `Not created: "${name}" already runs ${found}. Quox never shadows an existing command. Pick another name with: quox shortcut add --name <name>` };
55
+ if (!plan.dir) return { ok: false, status: 'error', message: 'No writable directory for the link (tried the one quox lives in and ~/.local/bin). Nothing changed.' };
56
+ const link = path.join(plan.dir, name);
57
+ if (ours(link, plan.reals)) return { ok: true, status: 'exists', link, onPath: plan.onPath, message: `${name} already points at quox (${link}).${plan.onPath ? '' : ` ${plan.dir} is not on your PATH, so ${name} will not run yet.`}` };
58
+ let present = false;
59
+ try { fs.lstatSync(link); present = true; } catch { /* free */ }
60
+ if (present) return { ok: false, status: 'refused', message: `Not created: ${link} already exists and is not a link to quox. Left alone.` };
61
+ try {
62
+ fs.mkdirSync(plan.dir, { recursive: true });
63
+ fs.symlinkSync(plan.target, link);
64
+ } catch (e) { return { ok: false, status: 'error', message: `Could not create ${link}: ${e.message}` }; }
65
+ const warn = plan.onPath ? '' : ` ${plan.dir} is not on your PATH, so ${name} will not run until you add it.`;
66
+ return { ok: true, status: 'created', link, onPath: plan.onPath, message: `Created ${link} -> ${plan.target}.${warn}` };
67
+ }
68
+
69
+ /** Removes the link only if it points at this quox. */
70
+ export function removeShortcut({ name = DEFAULT_NAME, env = process.env, home = os.homedir(), entry = QUOX_ENTRY } = {}) {
71
+ if (!NAME_OK.test(name)) return { ok: false, status: 'error', message: `"${name}" is not a usable command name.` };
72
+ const plan = planShortcut({ env, home, entry });
73
+ const dirs = [...new Set([...(plan.dir ? [plan.dir] : []), ...pathDirs(env), path.join(home, '.local', 'bin')])];
74
+ for (const d of dirs) {
75
+ const link = path.join(d, name);
76
+ if (ours(link, plan.reals)) {
77
+ try { fs.unlinkSync(link); } catch (e) { return { ok: false, status: 'error', message: `Could not remove ${link}: ${e.message}` }; }
78
+ return { ok: true, status: 'removed', link, message: `Removed ${link}.` };
79
+ }
80
+ }
81
+ const other = whichOnPath(name, env);
82
+ return { ok: true, status: 'none', message: other ? `Nothing removed: ${name} (${other}) is not a link to quox, so it is left alone.` : `Nothing to remove: no ${name} link to quox found.` };
83
+ }
@@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { dirname, resolve } from 'node:path';
20
20
 
21
- export const BASE_VERSION = '0.3.1';
21
+ export const BASE_VERSION = '0.4.0';
22
22
 
23
23
  // The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
24
24
  // levels up is the tree whose HEAD identifies the running build.
package/lib/core/vm.js CHANGED
@@ -134,6 +134,11 @@ export async function listVms(exec, { node, type } = {}) {
134
134
  * does not know it, fall back to client-side exact match over proxmox_vms + proxmox_containers.
135
135
  * Ambiguity is an error listing candidates; never a guess.
136
136
  */
137
+ /** DEVFLEET INV-1: list rows for `quox vm list`, with each VM's Proxmox tags as one cell ('' when untagged). */
138
+ export function vmListRows(vms) {
139
+ return vms.map(({ raw, ...r }) => ({ ...r, tags: Array.isArray(raw && raw.tags) ? raw.tags.join(', ') : '' }));
140
+ }
141
+
137
142
  export async function resolveTarget(exec, target) {
138
143
  const t = String(target ?? '').trim();
139
144
  if (!t) throw new Error('a VM name or vmid is required');
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The companion view Quoxon actually uses: same contract as dragonlab's integration/companions-v1/view.mjs
3
+ * (initial / reduce / view -> {frame, caption, hidden, name, scene}), same reducer, same character, same scene and caption
4
+ * logic, but sized to the classic dragon's footprint and played from a frame cache filled by a worker (frames.js).
5
+ * view() never renders; it reads. `ready` resolves once the worker has loaded the art.
6
+ */
7
+ import { pathToFileURL } from 'node:url';
8
+ import { createPlayhead, createWorkerBackend, companionSlot, quantise } from './frames.js';
9
+
10
+ export async function loadCompanionView(root, id, { still = false, arrival = true, mono = false, persist = true, makeBackend = createWorkerBackend, onFrame = () => {} } = {}) {
11
+ const url = (p) => pathToFileURL(`${root}/integration/${p}`).href;
12
+ const [chars, act] = await Promise.all([import(url('companions-v1/characters.mjs')), import(url('hyperion-v3/activity.mjs'))]);
13
+ const spec = chars.character(id);
14
+ const backend = await makeBackend({ root, id, still, mono });
15
+ const play = createPlayhead(backend, { onFirst: onFrame });
16
+ return {
17
+ id, name: spec.name, arrivalMs: spec.arrival.duration,
18
+ initial: act.initialActivity,
19
+ reduce: act.activityEvent,
20
+ playhead: play,
21
+ view(state, now, { cols = 80, rows = 24, mood = null } = {}) {
22
+ if (!persist && !still && now - state.started >= spec.arrival.duration && !state.approvals.length) return { frame: null, caption: null, hidden: true, name: spec.name, scene: null };
23
+ const slot = companionSlot(cols, Math.max(1, rows));
24
+ let v = chars.activityView(id, state, now, { arrival: arrival && !still });
25
+ // The dragon's job read found something wrong (a down route, an unhealthy host, a failed check): it acts concerned, the same scene
26
+ // a recorded issue gets. Never the other way round: a good read does not pick a happier scene than the activity allows.
27
+ if (mood === 'concerned' && !state.approvals.length && !state.issue) { const c = chars.activityView(id, { ...state, issue: true }, now, { arrival: false }); v = { ...v, scene: c.scene, time: c.time, motionTime: c.motionTime }; }
28
+ const frame = slot.showArt ? play.frame({ scene: v.scene, time: v.time, motionTime: v.motionTime, cols: slot.cols, rows: slot.rows }, { still }) : null;
29
+ return { frame, caption: v.caption, slot, name: spec.name, scene: v.scene };
30
+ },
31
+ dispose() { play.dispose(); },
32
+ };
33
+ }
34
+ export { companionSlot, quantise };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Smooth companion playback. Rendering a dragonlab frame costs 100 ms to over a second (Hyperion), and the old path did it
3
+ * synchronously inside every Ink render, so the whole TUI hitched and the dragon juddered. The classic Q dragon is smooth
4
+ * because its frames are cheap and the shared clock paces them. This does the same for companions: time is quantised to
5
+ * the clock's frame step, frames are rendered OFF the main thread (a worker, see renderWorker.js) a few steps ahead of the
6
+ * playhead into a cache, and the renderer only ever READS the cache. A frame that is not ready yet is held (the last one
7
+ * shown stays), never rendered inline. The same quantised time returns the same frame object, so an unchanged frame costs
8
+ * Ink no output at all.
9
+ */
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ /** One animation step, the shared clock's FRAME_MS (about 12 fps). */
13
+ export const STEP_MS = 83;
14
+ /** How many steps ahead of the playhead the worker is asked to fill. */
15
+ export const LOOKAHEAD = 3;
16
+ const CACHE_MAX = 240;
17
+
18
+ export const quantise = (ms) => Math.max(0, Math.round((Number(ms) || 0) / STEP_MS) * STEP_MS);
19
+
20
+ /**
21
+ * Where the art goes. Sized to the classic dragon's footprint (15 rows when the terminal is short, 22 when it is tall
22
+ * enough that the live region still fits), not the old 39x11 cap. Below ART_MIN a one-line label is used instead.
23
+ */
24
+ export const ART_MIN_COLS = 64, ART_MIN_ROWS = 30;
25
+ export function companionSlot(cols, rows) {
26
+ const big = cols >= ART_MIN_COLS && rows >= ART_MIN_ROWS;
27
+ if (!big) return { showArt: false, cols: 0, rows: 0 };
28
+ const tall = rows >= 40;
29
+ return { showArt: true, cols: Math.max(28, Math.min(tall ? 66 : 52, cols - 26)), rows: tall ? 22 : 15 };
30
+ }
31
+
32
+ /**
33
+ * Cache + playhead. `backend.render(req) -> Promise<frame>` does the real work somewhere else; at most one request is in
34
+ * flight, and the next one asked for is always the soonest frame not cached yet (current step first, then lookahead).
35
+ */
36
+ export function createPlayhead(backend, { onFirst = () => {}, lookahead = LOOKAHEAD, clock = () => Date.now() } = {}) {
37
+ const cache = new Map(), pending = new Set();
38
+ const conc = Math.max(1, backend.size || 1); // the backend may be a pool: keep every worker busy
39
+ let last = null, disposed = false, failed = null, renders = 0, ema = 100;
40
+ const keyOf = (r) => `${r.scene}|${r.time}|${r.motionTime}|${r.cols}x${r.rows}`;
41
+ const put = (k, f) => { cache.set(k, f); if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); };
42
+ function pump(wants) {
43
+ for (const next of wants) {
44
+ if (pending.size >= conc || disposed || failed) return;
45
+ const k = keyOf(next);
46
+ if (cache.has(k) || pending.has(k)) continue;
47
+ pending.add(k); renders++;
48
+ const began = clock();
49
+ Promise.resolve().then(() => backend.render(next)).then((f) => {
50
+ ema = ema * 0.7 + (clock() - began) * 0.3;
51
+ if (f) { put(k, f); const first = !last; last = f; if (first) onFirst(); }
52
+ }, (e) => { failed = e; }).finally(() => { pending.delete(k); });
53
+ }
54
+ }
55
+ return {
56
+ /**
57
+ * The frame to draw for this scene/time: the cached one for this step, else the newest earlier one (held), else the last
58
+ * one shown. null before the first ever arrives. The worker is asked for the step the playhead will be at when the
59
+ * render lands (now + the measured render time), so a slow character still plays at the rate it can render, never stalls the TUI.
60
+ */
61
+ frame(req, { still = false } = {}) {
62
+ const base = (i) => ({ ...req, time: quantise(req.time) + i * STEP_MS, motionTime: quantise(req.motionTime) + i * STEP_MS });
63
+ const lead = still ? 0 : Math.min(6, Math.ceil(ema / STEP_MS));
64
+ pump(still ? [base(0)] : Array.from({ length: Math.max(lookahead, conc * 2) + 1 }, (_, i) => base(lead + i)));
65
+ for (let back = 0; back <= 4; back++) {
66
+ const hit = cache.get(keyOf(base(-back)));
67
+ if (hit) { last = hit; return hit; }
68
+ }
69
+ return last;
70
+ },
71
+ get error() { return failed; },
72
+ get renders() { return renders; },
73
+ get size() { return cache.size; },
74
+ get busy() { return pending.size; },
75
+ get lead() { return Math.min(6, Math.ceil(ema / STEP_MS)); },
76
+ dispose() { disposed = true; cache.clear(); backend.dispose?.(); },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The real backend: worker threads that load the character once and render on request (a small pool, because the heavier
82
+ * characters take several frame steps to render and the pool renders upcoming steps in parallel). Rejects if no worker can start.
83
+ */
84
+ export async function createWorkerBackend({ root, id, still = false, mono = false, size = null }, { Worker, cpus = null } = {}) {
85
+ const W = Worker || (await import('node:worker_threads')).Worker;
86
+ const avail = cpus ?? (await import('node:os')).availableParallelism?.() ?? 2;
87
+ const n = Math.max(1, Math.min(size ?? 3, avail - 1));
88
+ const file = fileURLToPath(new URL('./renderWorker.js', import.meta.url));
89
+ const waiting = new Map();
90
+ let seq = 0, dead = null;
91
+ const fail = (e) => { dead = e; for (const w of waiting.values()) w.reject(e); waiting.clear(); };
92
+ const spawn = () => new Promise((resolve, reject) => {
93
+ const worker = new W(file, { workerData: { root, id } });
94
+ worker.unref?.();
95
+ worker.once('error', (e) => { fail(e); reject(e); });
96
+ worker.on('message', (m) => {
97
+ if (m.type === 'ready') return resolve({ worker, load: 0 });
98
+ const w = waiting.get(m.seq); if (!w) return;
99
+ waiting.delete(m.seq); w.slot.load--;
100
+ if (m.type === 'frame') w.resolve(m.frame); else w.reject(new Error(m.code || 'render failed'));
101
+ });
102
+ worker.once('exit', (c) => { fail(new Error(`render worker exited (${c})`)); reject(dead); });
103
+ });
104
+ const slots = await Promise.all(Array.from({ length: n }, spawn));
105
+ return {
106
+ size: n,
107
+ render: (req) => new Promise((resolve, reject) => {
108
+ if (dead) return reject(dead);
109
+ const slot = slots.reduce((a, b) => (b.load < a.load ? b : a));
110
+ const s = ++seq; slot.load++; waiting.set(s, { resolve, reject, slot });
111
+ slot.worker.postMessage({ seq: s, req: { ...req, still, mono } });
112
+ }),
113
+ dispose() { fail(new Error('disposed')); for (const s of slots) s.worker.terminate(); },
114
+ };
115
+ }
116
+
117
+ /** Test/fallback helper: path-to-URL for a dragonlab module. */
118
+ export const labModule = (root, ...p) => pathToFileURL([root, 'integration', 'companions-v1', ...p].join('/')).href;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Dragon jobs: each companion dragon has ONE truthful job, and its caption comes from a real read.
3
+ * sentinel WARD state of the last answer's envelope (POST /ward/verify-receipt, the route /proof uses)
4
+ * cipher route health: kuma monitors up/down (the `quox kuma` read)
5
+ * seer the plan: QuoxPlan focus stream and x of y phases
6
+ * hyperion fleet: hosts up of total (the Bastion host read)
7
+ * drago open security findings (the engagement store)
8
+ * lucid recovery: the last failure this session saw, and whether a later turn completed (no network read)
9
+ * Rules: every read is bounded (timeoutMs); a failed or silent read says "Couldn't check <thing> (<why>)" and never a number;
10
+ * results (failures too) are cached for JOB_TTL_MS so a dragon that appears often does not hammer the server; nothing here
11
+ * polls, run() is only called when the dragon is about to be shown; read-only. Pure apart from the injected reads.
12
+ * A result is { caption, mood } where mood is 'ok' | 'concerned' | null (null = unknown or neutral). The host may pick a
13
+ * concerned scene for 'concerned'; it never celebrates on null.
14
+ */
15
+ export const JOB_FOR = Object.freeze({ sentinel: 'ward', cipher: 'routes', seer: 'plan', hyperion: 'fleet', drago: 'findings', lucid: 'recovery' });
16
+ export const JOB_TTL_MS = 30000;
17
+ export const JOB_TIMEOUT_MS = 6000;
18
+ const LABEL = { ward: 'WARD', routes: 'routes', plan: 'plan', fleet: 'fleet', findings: 'findings' };
19
+ const READ_FOR = { ward: 'verifyReceipt', routes: 'routes', plan: 'plan', fleet: 'fleet', findings: 'findings' };
20
+
21
+ const why = (e) => (e?.status ? `HTTP ${e.status}` : /timed out/i.test(e?.message || '') ? 'timed out' : String(e?.message || 'failed').slice(0, 60));
22
+ const ago = (ms) => { const m = Math.floor(ms / 60000); return m < 1 ? 'under a minute ago' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`; };
23
+ const failed = (job, reason) => ({ caption: `Couldn't check ${LABEL[job]} (${reason}).`, mood: null });
24
+
25
+ /** Pure: a read's data -> { caption, mood }. */
26
+ export function captionFor(job, d) {
27
+ if (job === 'ward') {
28
+ const r = d?.result || {}, seq = r.entry?.seq;
29
+ if (r.status === 'WITNESSED') return { caption: `Last answer witnessed by WARD (seq ${seq}, signed tip).`, mood: 'ok' };
30
+ if (r.status === 'UNTIPPED') return { caption: `Last answer recorded (seq ${seq}) but no signed tip yet.`, mood: null };
31
+ if (r.status === 'NOT_WITNESSED') return { caption: 'Last answer not witnessed yet.', mood: null };
32
+ if (r.status) return { caption: `WARD check failed: ${r.status}${r.reason ? ` ${r.reason}` : ''}.`, mood: 'concerned' };
33
+ return failed('ward', 'no status in the answer');
34
+ }
35
+ if (job === 'routes') {
36
+ if (!d.total) return { caption: 'No monitors to read.', mood: null };
37
+ const stale = d.ageSeconds > 600 ? ` (snapshot ${Math.round(d.ageSeconds / 60)} min old)` : '';
38
+ if (d.down > 0) return { caption: `Routes: ${d.up} of ${d.total} up. Down: ${d.downNames.slice(0, 3).join(', ')}${d.downNames.length > 3 ? ` and ${d.downNames.length - 3} more` : ''}.`, mood: 'concerned' };
39
+ if (d.up < d.total) return { caption: `Routes: ${d.up} of ${d.total} up${stale}.`, mood: null }; // pending, maintenance, unknown: not counted up
40
+ const ping = Number.isFinite(d.avgPing) && !stale ? `, average ${Math.round(d.avgPing)} ms` : '';
41
+ return { caption: `Routes: ${d.up} of ${d.total} up${stale || ping}.`, mood: stale ? null : 'ok' };
42
+ }
43
+ if (job === 'plan') {
44
+ if (!d) return { caption: 'Nothing in focus on the plan.', mood: null };
45
+ if (d.total === 0) return { caption: `Plan: ${d.id} (no phases listed).`, mood: null };
46
+ return { caption: Number.isFinite(d.total) ? `Plan: ${d.id}, ${d.done} of ${d.total} phases.` : `Plan: ${d.id} (phases not read).`, mood: null };
47
+ }
48
+ if (job === 'fleet') {
49
+ if (!d.total) return { caption: 'No hosts to read.', mood: null };
50
+ const bits = ['degraded', 'unhealthy', 'offline', 'unknown'].filter(k => d[k]).map(k => `${d[k]} ${k}`);
51
+ return { caption: `Fleet: ${d.healthy} of ${d.total} hosts up${bits.length ? ` (${bits.join(', ')})` : ''}.`, mood: d.healthy === d.total ? 'ok' : 'concerned' };
52
+ }
53
+ if (job === 'findings') {
54
+ if (!d.open) return { caption: 'No open security findings recorded.', mood: 'ok' };
55
+ return { caption: `Open findings: ${d.open}${d.severe ? ` (${d.severe} critical or high)` : ''}.`, mood: d.severe ? 'concerned' : null };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * reads: { verifyReceipt(envelopeId), routes(), plan(), fleet(), findings() }, each resolving to data or throwing (verifyReceipt may
62
+ * resolve { ok:false, error }). context(): { envelopeId } of the last answer. observe(event): the session's own events (lucid).
63
+ */
64
+ export function createDragonJobs({ reads = {}, context = () => ({}), now = Date.now, ttlMs = JOB_TTL_MS, timeoutMs = JOB_TIMEOUT_MS } = {}) {
65
+ const cache = new Map(), inflight = new Map();
66
+ let lastFailure = null, recoveredAt = null;
67
+ const keyOf = (job) => (job === 'ward' ? `ward:${context()?.envelopeId || ''}` : job);
68
+
69
+ function recovery() {
70
+ if (!lastFailure) return { caption: 'No failures seen in this session.', mood: null };
71
+ const when = ago(now() - lastFailure.at);
72
+ return recoveredAt != null
73
+ ? { caption: `Recovered: ${lastFailure.what} ${when}, a later turn completed.`, mood: 'ok' }
74
+ : { caption: `Last failure: ${lastFailure.what} ${when}, not recovered yet.`, mood: 'concerned' };
75
+ }
76
+ async function fetchOne(job) {
77
+ const read = reads[READ_FOR[job]];
78
+ if (typeof read !== 'function') return null;
79
+ let timer;
80
+ try {
81
+ const arg = job === 'ward' ? context()?.envelopeId : undefined;
82
+ const data = await Promise.race([
83
+ Promise.resolve().then(() => read(arg)),
84
+ new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('timed out')), timeoutMs); timer.unref?.(); }),
85
+ ]);
86
+ if (job === 'ward' && data && data.ok === false) return failed(job, data.error || 'failed');
87
+ return captionFor(job, data);
88
+ } catch (e) { return failed(job, why(e)); }
89
+ finally { clearTimeout(timer); }
90
+ }
91
+
92
+ return {
93
+ /** Feed the session's events (turn.finished, error): the only input lucid's job has. */
94
+ observe(ev) {
95
+ if (!ev) return;
96
+ if (ev.type === 'error') { lastFailure = { at: now(), what: 'stream error' }; recoveredAt = null; }
97
+ else if (ev.type === 'turn.finished') {
98
+ if (ev.status === 'failed' || ev.status === 'unconfirmed') { lastFailure = { at: now(), what: `turn ${ev.status}` }; recoveredAt = null; }
99
+ else if (ev.status === 'completed' && lastFailure && recoveredAt == null) recoveredAt = now();
100
+ }
101
+ },
102
+ peek(dragonId) {
103
+ const job = JOB_FOR[dragonId];
104
+ if (!job) return null;
105
+ if (job === 'recovery') return recovery();
106
+ const hit = cache.get(keyOf(job));
107
+ return hit && now() - hit.at < ttlMs ? hit.value : null;
108
+ },
109
+ /** The dragon's job result, from cache (30 s) or one bounded read. null = this dragon has no job here. */
110
+ async run(dragonId) {
111
+ const job = JOB_FOR[dragonId];
112
+ if (!job) return null;
113
+ if (job === 'recovery') return recovery();
114
+ if (job === 'ward' && !context()?.envelopeId) return { caption: 'No receipt to check yet: nothing from this session is witnessed.', mood: null };
115
+ const key = keyOf(job), hit = cache.get(key);
116
+ if (hit && now() - hit.at < ttlMs) return hit.value;
117
+ if (inflight.has(key)) return inflight.get(key);
118
+ const p = fetchOne(job).then((value) => { if (value) cache.set(key, { at: now(), value }); return value; }).finally(() => inflight.delete(key));
119
+ inflight.set(key, p);
120
+ return p;
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,15 @@
1
+ /** Worker thread: loads one dragonlab character and renders frames on request, so the TUI thread never does. See frames.js. */
2
+ import { parentPort, workerData } from 'node:worker_threads';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const { root, id } = workerData;
6
+ const mod = await import(pathToFileURL(`${root}/integration/companions-v1/characters.mjs`).href);
7
+ const actor = await mod.loadCharacter(id);
8
+ parentPort.on('message', ({ seq, req }) => {
9
+ try {
10
+ const r = actor.render(req.scene, req.time, { cols: req.cols, rows: req.rows, still: req.still, mono: req.mono, motionTime: req.motionTime });
11
+ if (!r || !r.ok) parentPort.postMessage({ type: 'error', seq, code: r?.code || 'render failed' });
12
+ else parentPort.postMessage({ type: 'frame', seq, frame: r.frame });
13
+ } catch (e) { parentPort.postMessage({ type: 'error', seq, code: e.message }); }
14
+ });
15
+ parentPort.postMessage({ type: 'ready' });