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,87 @@
1
+ /**
2
+ * QLOOP-LIVE: the client for a running loop. Reads GET /api/v1/loops/:id (snapshot) and
3
+ * GET /api/v1/loops/:id/events (SSE; ?since=<event_seq> resumes; a heartbeat comment every 15 s; ends after loop.finished).
4
+ * Contract: ~/quoxon-work/qloop-live-design.md section 1. Every event carries a monotonic event_seq.
5
+ *
6
+ * Reconnect is the part that must not lie. A drop is counted, backed off (500 ms doubling to 10 s, reset by any event) and
7
+ * reported to the card as 'reconnecting'; after MAX_FAILS drops in a row with no event between, or at once on 401/403/404,
8
+ * the watcher stops and says 'lost' with the reason. It never reports a lost stream as a finished loop.
9
+ */
10
+ export const BACKOFF_START_MS = 500;
11
+ export const BACKOFF_CAP_MS = 10000;
12
+ export const MAX_FAILS = 8;
13
+ const FATAL = new Set([401, 403, 404]);
14
+ const TERMINAL_STATES = new Set(['completed', 'completed_with_errors', 'failed', 'interrupted', 'halted', 'cancelled', 'aborted', 'stopped']);
15
+ const isTerminal = (snap) => Boolean(snap && (snap.finished === true || TERMINAL_STATES.has(snap.state)));
16
+
17
+ const sleepFor = (ms, signal) => new Promise((resolve) => {
18
+ if (signal?.aborted) return resolve();
19
+ const t = setTimeout(done, ms); t.unref?.();
20
+ function done() { signal?.removeEventListener?.('abort', done); clearTimeout(t); resolve(); }
21
+ signal?.addEventListener?.('abort', done, { once: true });
22
+ });
23
+
24
+ /**
25
+ * Follow one loop until loop.finished, abort, or a fatal failure.
26
+ *
27
+ * api: { loopSnapshot(id) -> object, loopEvents(id, { since }, signal) -> async iterable of events }
28
+ * handlers: { onSnapshot(snap), onEvent(ev), onConn({ conn: 'live'|'reconnecting'|'lost', attempt, note }) }
29
+ * -> { finished: boolean, lost: boolean, lastSeq, reason? }
30
+ */
31
+ export async function watchLoop({ api, id, signal, since = 0, onSnapshot = () => {}, onEvent = () => {}, onConn = () => {}, sleep = sleepFor, backoffStart = BACKOFF_START_MS, backoffCap = BACKOFF_CAP_MS, maxFails = MAX_FAILS }) {
32
+ let lastSeq = Number(since) || 0, fails = 0, delay = backoffStart, finished = false;
33
+ try { onSnapshot(await api.loopSnapshot(id)); }
34
+ catch (e) { // the snapshot is a head start, not a gate: the replayed events carry the same facts. A fatal status still stops.
35
+ if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: 0, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
36
+ }
37
+ while (!signal?.aborted && !finished) {
38
+ let sawEvent = false;
39
+ try {
40
+ for await (const ev of api.loopEvents(id, { since: lastSeq || undefined }, signal)) {
41
+ if (signal?.aborted) break;
42
+ if (!ev || typeof ev.type !== 'string') continue;
43
+ const seq = Number(ev.event_seq);
44
+ if (Number.isFinite(seq) && seq <= lastSeq) continue; // the boundary event of a resume
45
+ if (Number.isFinite(seq)) lastSeq = seq;
46
+ if (!sawEvent) { sawEvent = true; fails = 0; delay = backoffStart; onConn({ conn: 'live', attempt: 0, note: null }); }
47
+ onEvent(ev);
48
+ if (ev.type === 'loop.finished') { finished = true; break; }
49
+ }
50
+ if (!finished && !signal?.aborted && !sawEvent) {
51
+ // A clean close with no events is not a failure: a loop's silent final turn, or a loop that already finished, ends this way.
52
+ // Ask the snapshot. Terminal: finish quietly with the final state. Still running: reconnect with ?since, failures reset.
53
+ // Only a snapshot read that itself fails counts (a real error).
54
+ let snap = null;
55
+ try { snap = await api.loopSnapshot(id); }
56
+ catch (e) {
57
+ if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
58
+ fails += 1;
59
+ if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
60
+ onConn({ conn: 'reconnecting', attempt: fails, note: describe(e) });
61
+ await sleep(delay, signal); delay = Math.min(delay * 2, backoffCap);
62
+ continue;
63
+ }
64
+ if (isTerminal(snap)) { onSnapshot(snap); return { finished: true, lost: false, lastSeq, final: snap }; }
65
+ fails = 0; // still running: quiet, not a failure
66
+ await sleep(backoffStart, signal);
67
+ continue;
68
+ }
69
+ else if (!finished && sawEvent) fails = 0;
70
+ } catch (e) {
71
+ if (signal?.aborted) break;
72
+ if (FATAL.has(e?.status)) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
73
+ fails += 1;
74
+ if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: describe(e) }); return { finished: false, lost: true, lastSeq, reason: describe(e) }; }
75
+ onConn({ conn: 'reconnecting', attempt: fails, note: describe(e) });
76
+ await sleep(delay, signal); delay = Math.min(delay * 2, backoffCap);
77
+ continue;
78
+ }
79
+ if (finished || signal?.aborted) break;
80
+ if (fails >= maxFails) { onConn({ conn: 'lost', attempt: fails, note: 'the stream keeps closing' }); return { finished: false, lost: true, lastSeq, reason: 'the stream keeps closing' }; }
81
+ onConn({ conn: 'reconnecting', attempt: Math.max(1, fails), note: null });
82
+ await sleep(delay, signal); delay = Math.min(delay * 2, backoffCap);
83
+ }
84
+ return { finished, lost: false, lastSeq };
85
+ }
86
+
87
+ const describe = (e) => `${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}`.trim();
@@ -12,7 +12,7 @@ import { loadRules, rulesContext } from './rules.js';
12
12
  import { createTranscript } from './transcript.js';
13
13
  import { createScreenRecorder, screenFramesEnabled } from './screenFrames.js';
14
14
  import { applySettings } from './slash/settings.js';
15
- import { headOf, RUNNING_ROOT, execRestart, restartPlan, checkRestart, startBuildWatch, settleMsFrom, restoreTerminal, RESTART_SLOT_ENV, RESTART_ATTACH_ENV, RESTART_NOTE_ENV, RESTART_MODE_ENV } from './slash/restart.js';
15
+ import { headOf, RUNNING_ROOT, execRestart, restartPlan, checkRestart, startBuildWatch, settleMsFrom, minGapMsFrom, restoreTerminal, RESTART_SLOT_ENV, RESTART_ATTACH_ENV, RESTART_NOTE_ENV, RESTART_MODE_ENV } from './slash/restart.js';
16
16
  import { readLastSeen, writeLastSeen } from './away.js';
17
17
  import { fileHistory } from './promptHistory.js';
18
18
  import { takeLauncherHandoff, writeRestartHandoff, RESTART_EXIT_CODE } from './launcher.js';
@@ -39,7 +39,7 @@ export async function main(argv = process.argv.slice(2)) {
39
39
  if (opts.errors.length) { early?.release(); process.stderr.write(`quox: ${opts.errors.join('; ')}\n${USAGE}\n`); process.exitCode = 1; return; }
40
40
  const cwd = process.cwd().replace(process.env.HOME || '\0', '~');
41
41
  const { resolveCliVersion } = await import('../core/version.js');
42
- let version = '0.3.1';
42
+ let version = '0.4.0';
43
43
  try { version = resolveCliVersion?.() || version; } catch { /* keep default */ }
44
44
  if (opts.print) return mainPrint(opts);
45
45
  let replay = null;
@@ -56,6 +56,15 @@ export async function main(argv = process.argv.slice(2)) {
56
56
  // and a fresh install replays the original request as the first real turn
57
57
  replay = r.replay || null;
58
58
  fromGettingStarted = true;
59
+ // QUOXON-Q: ONE offer for a short `q` command, asked once (answer kept in the CLI config)
60
+ if (process.stdin.isTTY && process.stdout.isTTY && !opts.demoOnboarding) {
61
+ try {
62
+ const { offerShortcut } = await import('./onboarding/shortcutOffer.js');
63
+ const { createInterface } = await import('node:readline/promises');
64
+ const config = (await import('../core/config.js')).loadConfig();
65
+ await offerShortcut({ config, ask: async (line) => { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { return await rl.question(`\n${line} `); } finally { rl.close(); } } });
66
+ } catch { /* an offer must never block the first conversation */ }
67
+ }
59
68
  }
60
69
  let props;
61
70
  let restart = null; // set by /restart: run the installed quox here once the app has let go of the terminal
@@ -118,7 +127,7 @@ export async function main(argv = process.argv.slice(2)) {
118
127
  return { ok: true, api: a2, agentLogin: residentNames().includes(name), orgName: (c && !c.error && c.name) || c2.getAuth?.()?.org_name || null, note };
119
128
  };
120
129
  const loginCandidates = profiles.filter(p => !p.loggedIn && !residentNames().includes(p.name)).map(p => p.name);
121
- props = { api, switchProfile, loginCandidates, away: { read: readLastSeen, write: writeLastSeen }, modelChoice: opts.provider, resume: opts.resume ?? null, onRestart: (r) => { restart = r; }, onDemo: () => { restart = { demo: true }; }, restartCheck: checkRestart, buildWatch: (o) => startBuildWatch({ ...o, settleMs: settleMsFrom() }), startSha: headOf(RUNNING_ROOT), rules: loadRules(), profile: pname, agentLogin: (probe.listResidentProfiles?.() || []).some(r => r.name === pname), humanProfiles: profiles.filter(p => p.loggedIn && !(probe.listResidentProfiles?.() || []).some(r => r.name === p.name)).map(p => p.name), org: missing ? null : ((carried && !carried.error && carried.name) || config.getAuth?.()?.org_name || null), startup: startupCarry, healthRecheck: () => api.health(8000), blocked: problem && !signedOut ? problem.message : null, signedOut, autoplay: replay };
130
+ props = { api, switchProfile, loginCandidates, away: { read: readLastSeen, write: writeLastSeen }, modelChoice: opts.provider, resume: opts.resume ?? null, onRestart: (r) => { restart = r; }, onDemo: () => { restart = { demo: true }; }, restartCheck: checkRestart, buildWatch: (o) => startBuildWatch({ ...o, settleMs: settleMsFrom(), minGapMs: minGapMsFrom() }), startSha: headOf(RUNNING_ROOT), rules: loadRules(), profile: pname, agentLogin: (probe.listResidentProfiles?.() || []).some(r => r.name === pname), humanProfiles: profiles.filter(p => p.loggedIn && !(probe.listResidentProfiles?.() || []).some(r => r.name === p.name)).map(p => p.name), org: missing ? null : ((carried && !carried.error && carried.name) || config.getAuth?.()?.org_name || null), startup: startupCarry, healthRecheck: () => api.health(8000), blocked: problem && !signedOut ? problem.message : null, signedOut, autoplay: replay };
122
131
  }
123
132
  // Interactive start: a clean screen under the intro (never when piped; NO_COLOR still clears),
124
133
  // except straight after Getting started, whose finale must stay readable.
@@ -17,6 +17,17 @@
17
17
  */
18
18
  import { graphemes, composerFrom } from './textBuffer.js';
19
19
 
20
+ /** The logical line (between newlines) holding buffer index `idx` -> { from, to }; a triple-click selects it. */
21
+ export function lineAt(text, idx) {
22
+ const t = String(text);
23
+ const i = Math.max(0, Math.min(idx, t.length));
24
+ const from = t.lastIndexOf('\n', i - 1) + 1;
25
+ const nl = t.indexOf('\n', i);
26
+ return { from, to: nl < 0 ? t.length : nl };
27
+ }
28
+
29
+ export const MOUSE_HINT = 'Click your draft to place the cursor, drag to select (copies), double-click a word, triple-click a line. Shift+drag uses your terminal\'s own selection in most terminals. /mouse off gives native selection back.';
30
+
20
31
  export const MOUSE_ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
21
32
  export const MOUSE_OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
22
33
  export const DSR = '\x1b[6n';
@@ -26,7 +37,7 @@ export const DOUBLE_MS = 400;
26
37
  export const DSR_TIMEOUT_MS = 400;
27
38
 
28
39
  export function mouseOnNote() {
29
- return 'Mouse on. Click a ◆ sender to reply, click a card\'s y / n / c to answer, click or drag in your draft to place the cursor or select (Backspace deletes, typing replaces, double-click selects a word). While it is on your terminal sends clicks and the wheel to Quox: hold Shift to select or copy text (Option on macOS Terminal and iTerm2), and use Shift+wheel or the scrollbar to scroll back. /mouse off returns all of it.';
40
+ return 'Mouse on. Click a ◆ sender to reply, click a card\'s y / n / c to answer, click or drag in your draft to place the cursor or select (Backspace deletes, typing replaces, double-click selects a word, triple-click a line). While it is on your terminal sends clicks and the wheel to Quox: hold Shift to select or copy text (Option on macOS Terminal and iTerm2), and use Shift+wheel or the scrollbar to scroll back. /mouse off returns all of it.';
30
41
  }
31
42
 
32
43
  // ---- cell widths --------------------------------------------------------------------------------
@@ -0,0 +1,82 @@
1
+ /**
2
+ * QUOXON objective offer (owner, live 2026-10-25 19:47). Attached to SEER (advisory, no image/X/site tools) the owner asked
3
+ * for a blog article, an image, a publish and a tweet draft, and got "I couldn't". Offer, don't give up: the server sends
4
+ * turn.objective_suggested (from the turn's tool selection and the shape of the ask, never the answer text) and the TUI
5
+ * asks once. It REPLACES the single-turn handoff card (one ask, not two):
6
+ *
7
+ * ⚠ SEER asks: Make this an objective that runs until done?
8
+ * do create objective "<the user's words>"; you approve the plan, then each publish/post step
9
+ * why SEER can't run x post.
10
+ * ▶ Make this an objective that runs until done? y yes · n no
11
+ *
12
+ * y = `quox objective create <goal>` + start (the plan decomposes and lands as an approval card). n does nothing.
13
+ */
14
+ import { askText } from './ask.js';
15
+
16
+ const clipQ = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
17
+
18
+ /** The suggestion from a turn event, when it applies to THIS window (attached to the agent it names). Else null. */
19
+ export function objectiveFromEvent(ev, attachedAgent) {
20
+ if (!ev || ev.type !== 'turn.objective_suggested') return null;
21
+ const o = ev.objective_suggested;
22
+ const at = String(attachedAgent || '').toLowerCase();
23
+ if (!o || typeof o !== 'object' || !o.from || !o.goal || !at) return null;
24
+ if (String(o.from).toLowerCase() !== at) return null; // a suggestion about some other agent
25
+ const loop = o.mode === 'loop' && o.loop && Array.isArray(o.loop.capability_scope) && o.loop.capability_scope.length ? { goal_condition: String(o.loop.goal_condition || o.goal), capability_scope: o.loop.capability_scope.map(String), ...(o.loop.plain ? { plain: true } : {}), ...(Number.isInteger(o.loop.max_iterations) ? { max_iterations: o.loop.max_iterations } : {}), ...(o.loop.summary ? { summary: String(o.loop.summary) } : {}), ...(o.loop.until ? { until: String(o.loop.until) } : {}), ...(Number.isInteger(o.loop.every_seconds) ? { every_seconds: o.loop.every_seconds } : {}), ...(Number.isInteger(o.loop.max_duration_seconds) ? { max_duration_seconds: o.loop.max_duration_seconds } : {}), ...(Array.isArray(o.loop.done_check) && o.loop.done_check.length ? { done_check: o.loop.done_check } : {}) } : null; // QLOOP: a ready, engine-valid loop spec
26
+ return { from: at, fromName: String(o.from_name || at.toUpperCase()), goal: String(o.goal), reason: String(o.reason || '').trim(), tools: Array.isArray(o.tools) ? o.tools : [], ...(loop ? { loop } : {}) };
27
+ }
28
+
29
+ /** The ask line (drawn as the approval-style card). The goal is clipped for the card only, never in what yes creates. */
30
+ export function objectiveAskText(o, cols = 100) {
31
+ if (o.loop && o.loop.plain) { // the user never said "loop": say what it does, in plain words, whole (it wraps; never clipped)
32
+ const mins = o.loop.max_duration_seconds ? Math.max(1, Math.round(o.loop.max_duration_seconds / 60)) : null;
33
+ const q = o.loop.summary ? `${o.loop.summary}?` : `This needs repeated checks: keep going until ${String(o.loop.goal_condition || o.goal).replace(/\s+/g, ' ').trim()}${mins ? `, up to ${mins} minute${mins === 1 ? '' : 's'}` : ''}?`;
34
+ return askText(q, { keys: 'y yes · n just once', who: o.fromName, reason: '', action: `keep going${o.loop.every_seconds ? `, checking about every ${o.loop.every_seconds} seconds,` : ''} using only ${o.loop.capability_scope.join(', ')}; you approve it before it starts` });
35
+ }
36
+ if (o.loop) return askText('Run this as a loop until it is done?', { keys: 'y/N', who: o.fromName, reason: o.reason, action: `create a loop "${clipQ(o.goal, Math.max(20, cols - 70))}" that may use only ${o.loop.capability_scope.join(', ')}; you approve it before it starts` });
37
+ const action = `create objective "${clipQ(o.goal, Math.max(20, cols - 60))}"; you approve the plan, then each publish/post step`;
38
+ return askText('Make this an objective that runs until done?', { keys: 'y/N', who: o.fromName, reason: o.reason, action });
39
+ }
40
+
41
+ export const objectiveStartedNote = (id) => `▶ Objective ${id} started. The plan will land as an approval card.`;
42
+ export const objectiveWatchLine = (id) => `Watch it: quox objective get ${id}`;
43
+ export const objectiveFailedNote = (goal, err) => `Objective not created: ${String(err?.message || err || 'unknown error')}. Retry: quox objective create ${JSON.stringify(goal)} --checkpoint`;
44
+
45
+ // ---- report back into the conversation (owner 2026-10-26) ----
46
+ const TERMINAL = new Set(['completed', 'completed_with_errors', 'failed', 'cancelled', 'aborted']);
47
+ // A finished step's result is the node output JSON; its text is completion.value.
48
+ const textOf = (r) => { const s = String(r ?? ''); if (!s.trimStart().startsWith('{')) return s; try { const o = JSON.parse(s); return typeof o?.completion?.value === 'string' ? o.completion.value : s; } catch { return s; } };
49
+ const reportOf = (tasks) => {
50
+ const done = (tasks || []).filter(t => t.status === 'completed' && textOf(t.result).trim()).sort((a, b) => (a.sequence_num || 0) - (b.sequence_num || 0));
51
+ const pick = [...done].reverse().find(t => /report/i.test(String(t.title || ''))) || done[done.length - 1];
52
+ const text = pick ? textOf(pick.result).trim() : '';
53
+ return text.length > 1500 ? `${text.slice(0, 1499)}…` : text;
54
+ };
55
+
56
+ /** The lines the chat prints when an objective ends: the glyph line, then its report (or what did not complete). */
57
+ export function objectiveFinishedLines(o) {
58
+ const tasks = Array.isArray(o.tasks) ? o.tasks : [];
59
+ const done = tasks.filter(t => t.status === 'completed').length;
60
+ const title = String(o.title || o.id || '').replace(/\s+/g, ' ').slice(0, 80);
61
+ const lines = [`◎ OBJECTIVE │ ${title} │ ${o.status}, ${done}/${tasks.length} steps`];
62
+ const report = reportOf(tasks);
63
+ if (report) lines.push(...report.split('\n').map(l => l.trimEnd()).filter(Boolean));
64
+ if (o.status !== 'completed' && o.error_message) lines.push(String(o.error_message).slice(0, 400));
65
+ return lines;
66
+ }
67
+
68
+ /**
69
+ * Poll an objective while this window is open and say how it ended. get() -> the objective with its tasks; a failed read is
70
+ * retried, never fatal. Resolves { finished, status }. Gives up quietly at maxMs or when stopped() (the inbox item covers a closed window).
71
+ */
72
+ export async function watchObjective({ get, say, intervalMs = 8000, maxMs = 6 * 3600 * 1000, stopped = () => false } = {}) {
73
+ const t0 = Date.now();
74
+ while (Date.now() - t0 < maxMs && !stopped()) {
75
+ try {
76
+ const o = await get();
77
+ if (o && TERMINAL.has(o.status)) { for (const l of objectiveFinishedLines(o)) say(l); return { finished: true, status: o.status }; }
78
+ } catch { /* a transient read: try again */ }
79
+ await new Promise(r => setTimeout(r, intervalMs));
80
+ }
81
+ return { finished: false };
82
+ }
@@ -11,6 +11,7 @@ import { agentCard } from './agent.js';
11
11
  import { memoryFacts } from './memory.js';
12
12
  import { rulesLadder } from './ladder.js';
13
13
  import { proofChain, reconcileView } from './proof.js';
14
+ import { wrapHang } from './model.js';
14
15
  import { awayCard } from './away.js';
15
16
  import { sitrepCard } from './sitrep.js';
16
17
  import { fleetView } from './fleet.js';
@@ -91,10 +92,27 @@ export async function runObjectCommand(cmd, arg, ctx) {
91
92
  });
92
93
  }
93
94
  case '/proof': {
94
- const calls = toolCalls(ctx.items).filter(b => b.envelopeId);
95
- const t = a ? calls.find(b => b.envelopeId === a || b.callId === a) : calls[calls.length - 1];
96
- if (!t) return { note: a ? `No tool call "${a}" with an envelope in this session.` : 'No tool call with an envelope id in this session yet.', tone: 'warn' };
97
- return wrap('proof', () => o.proof({ tool: t.tool, status: t.status, envelopeId: t.envelopeId, callId: t.callId }));
95
+ const all = toolCalls(ctx.items);
96
+ const calls = all.filter(b => b.envelopeId);
97
+ const newest = [...calls].reverse(); // 1 = newest
98
+ const num = /^\d{1,3}$/.test(a) ? Number(a) : 0;
99
+ const pick = (b) => wrap('proof', () => o.proof({ tool: b.tool, status: b.status, envelopeId: b.envelopeId, callId: b.callId }));
100
+ if (num >= 1 && newest[num - 1]) return pick(newest[num - 1]);
101
+ const t = a && !num ? calls.find(b => b.envelopeId === a || b.callId === a) : (calls.length === 1 ? calls[0] : null);
102
+ if (t) return pick(t);
103
+ if (a) return { note: `No tool call "${a}" with an envelope in this session. /proof lists them; /evidence exports the whole conversation.`, tone: 'warn' };
104
+ if (!calls.length) {
105
+ const ran = all.length ? ` ${all.length} tool call${all.length === 1 ? '' : 's'} ran, but the server reported no envelope id for ${all.length === 1 ? 'it' : 'them'}.` : '';
106
+ return { note: `No tool call with an envelope id in this session yet.${ran} /evidence exports what the server filed for this conversation.`, tone: 'warn' };
107
+ }
108
+ // Several calls: list the envelope ids, newest first (ten), then how many more.
109
+ const shown = newest.slice(0, 10);
110
+ const W = Math.max(20, (ctx.cols || 1e9) - 4);
111
+ const lines = [...wrapHang(`${calls.length} tool call${calls.length === 1 ? '' : 's'} with an envelope in this conversation (newest first):`, W, 2),
112
+ ...shown.flatMap((b, i) => wrapHang(` ${i + 1}. ${b.envelopeId} ${b.tool || 'tool'} ${b.status || ''}`.trimEnd(), W, 5))];
113
+ if (newest.length > shown.length) lines.push(` ${newest.length - shown.length} more (older)`);
114
+ lines.push(...wrapHang('/proof <number or id> shows one chain. /evidence exports the signed bundle of all of them.', W, 2));
115
+ return { note: lines.join('\n'), lines, tone: 'dim' };
98
116
  }
99
117
  case '/reconcile': {
100
118
  const unk = toolCalls(ctx.items).filter(b => b.status === 'effect_unknown');
@@ -21,6 +21,39 @@ export const fit = (s, n) => {
21
21
  return g.length > n ? g.slice(0, Math.max(0, n - 1)).join('') + '…' : g.join('');
22
22
  };
23
23
 
24
+ /**
25
+ * Wrap a string to `width` display chars, never clipping: breaks at spaces, indents each continuation by `hang`
26
+ * spaces beyond the line's own leading indent, and hard-splits a token longer than the line (an id stays whole
27
+ * across rows, no ellipsis). -> string[]
28
+ */
29
+ export function wrapHang(text, width, hang = 4) {
30
+ const src = String(text ?? '');
31
+ const lead = (src.match(/^ */) || [''])[0];
32
+ const words = src.trim().split(/\s+/).filter(Boolean);
33
+ if (!words.length) return [src];
34
+ const w = Math.max(8, width);
35
+ const lines = [];
36
+ let cur = lead;
37
+ let curHas = false;
38
+ const pad = () => lead + ' '.repeat(hang);
39
+ const push = () => { lines.push(cur); cur = pad(); curHas = false; };
40
+ for (let word of words) {
41
+ while (len(cur) + (curHas ? 1 : 0) + len(word) > w) {
42
+ if (curHas) { push(); continue; }
43
+ const room = Math.max(1, w - len(cur));
44
+ const g = [...word];
45
+ if (g.length <= room) break;
46
+ cur += g.slice(0, room).join('');
47
+ word = g.slice(room).join('');
48
+ push();
49
+ }
50
+ cur += (curHas ? ' ' : '') + word;
51
+ curHas = true;
52
+ }
53
+ if (curHas) lines.push(cur);
54
+ return lines;
55
+ }
56
+
24
57
  /** Clip a row of segments to n chars total; later segments shrink first, the last one gets the ellipsis. */
25
58
  export function fitRow(row, n) {
26
59
  const out = [];
@@ -10,7 +10,7 @@
10
10
  * D3: a tool that ended effect_unknown is reconciled against the server's own record of the call
11
11
  * (the ops.tool envelope's payload.success). No envelope id means it cannot be reconciled from here.
12
12
  */
13
- import { seg, fit, short, plural } from './model.js';
13
+ import { seg, fit, plural, wrapHang } from './model.js';
14
14
 
15
15
  const LINKS = ['AEE', 'AOCL', 'TOOL', 'WARD'];
16
16
 
@@ -19,7 +19,7 @@ export function chainLinks(d) {
19
19
  const t = d.tool || {};
20
20
  const env = d.envelope?.ok ? d.envelope.env : null;
21
21
  const out = [];
22
- out.push(env ? { name: 'AEE', ok: true, sub: `envelope ${short(env.id || t.envelopeId, 14)}` }
22
+ out.push(env ? { name: 'AEE', ok: true, sub: 'envelope read, id above' }
23
23
  : { name: 'AEE', ok: d.envelope?.status === 404 ? false : null, sub: d.envelope?.status === 404 ? 'envelope not found' : `envelope not read${d.envelope?.error ? ` (${fit(d.envelope.error, 24)})` : ''}` });
24
24
  const aocl = d.aocl?.ok ? d.aocl.list || [] : null;
25
25
  if (aocl && aocl.length) {
@@ -52,7 +52,13 @@ const markOf = (ok) => (ok === true ? ['✔', 'ok'] : ok === false ? ['✖', 'er
52
52
  export function proofChain(d, cols) {
53
53
  const links = chainLinks(d);
54
54
  const rows = [];
55
- rows.push([seg('proof ', 'muted'), seg(fit(`${d.tool?.tool || 'tool call'}${d.tool?.envelopeId ? ` · ${short(d.tool.envelopeId, 18)}` : ''}`, cols - 10), 'value', true)]);
55
+ const envId = d.tool?.envelopeId || (d.envelope?.ok ? d.envelope.env?.id : null);
56
+ rows.push([seg('proof ', 'muted'), seg(fit(d.tool?.tool || 'tool call', cols - 10), 'value', true)]);
57
+ // The full id on its own row, never clipped, so it can be selected and copied whole.
58
+ if (envId) {
59
+ wrapHang(envId, cols - 7, 0).forEach((l, i) => rows.push([seg(i ? ' ' : 'id ', 'muted'), seg(l, 'value', true)]));
60
+ for (const t of [`read it back: quox aee get ${envId}`, 'check a bundle: quox evidence verify <file>']) for (const l of wrapHang(t, cols - 4, 2)) rows.push([seg(l, 'muted')]);
61
+ }
56
62
  if (cols >= 90) {
57
63
  const head = [], sub = [], cw = Math.max(23, Math.floor((cols - 4) / 4));
58
64
  links.forEach((l, i) => {
@@ -69,7 +75,9 @@ export function proofChain(d, cols) {
69
75
  links.forEach((l, i) => {
70
76
  const role = `proto:${l.name.toLowerCase() === 'tool' ? 'volt' : l.name.toLowerCase()}`;
71
77
  const [m, mr] = markOf(l.ok);
72
- rows.push([seg(m, mr), seg(' '), seg(l.name.padEnd(5), l.name === 'TOOL' ? 'value' : role, true), seg(fit(l.sub, cols - 12), l.ok === false ? 'error' : 'muted')]);
78
+ const subs = wrapHang(l.sub, cols - 11, 0);
79
+ rows.push([seg(m, mr), seg(' '), seg(l.name.padEnd(5), l.name === 'TOOL' ? 'value' : role, true), seg(subs[0], l.ok === false ? 'error' : 'muted')]);
80
+ for (const more of subs.slice(1)) rows.push([seg(' '.repeat(7)), seg(more, l.ok === false ? 'error' : 'muted')]);
73
81
  if (i < 3) rows.push([seg(l.ok === true && links[i + 1].ok === true ? '┃' : '╎', l.ok === true && links[i + 1].ok === true ? 'indigo' : 'faint')]);
74
82
  });
75
83
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * QUOXON-Q: after first-run onboarding succeeds, ONE offer (y/N, default No, asked once) to make `q` run quox.
3
+ * The answer is remembered in the CLI config (shortcutOffer = 'yes' | 'no'). A yes runs the same code as
4
+ * `quox shortcut add`. Nothing is offered when q already resolves to something (ours or not): never shadow.
5
+ */
6
+ import { askText } from '../ask.js';
7
+ import { addShortcut, whichOnPath, DEFAULT_NAME } from '../../core/shortcut.js';
8
+
9
+ export const SHORTCUT_QUESTION = 'Want a shorter command? Type `q` instead of `quox`.';
10
+ export const shortcutOfferLine = () => askText(SHORTCUT_QUESTION, { keys: 'y/N', who: 'Quox', action: `make \`${DEFAULT_NAME}\` a link to quox in a folder you own (no sudo, no shell edits); undo with: quox shortcut remove` });
11
+
12
+ /** -> true when the offer should be shown now. */
13
+ export function shouldOfferShortcut({ config, env = process.env, which = whichOnPath } = {}) {
14
+ if (config?.get?.('shortcutOffer')) return false; // asked once
15
+ return !which(DEFAULT_NAME, env);
16
+ }
17
+
18
+ /** Runs the offer. ask(line) -> string answer. -> { offered, accepted, result? } */
19
+ export async function offerShortcut({ config, ask, out = (s) => process.stdout.write(s), env = process.env, add = addShortcut, which = whichOnPath } = {}) {
20
+ if (!shouldOfferShortcut({ config, env, which })) return { offered: false };
21
+ const answer = String(await ask(shortcutOfferLine()) ?? '').trim();
22
+ const accepted = /^y(es)?$/i.test(answer);
23
+ config.set('shortcutOffer', accepted ? 'yes' : 'no');
24
+ if (!accepted) return { offered: true, accepted: false };
25
+ const result = add({ name: DEFAULT_NAME, env });
26
+ out(`${result.ok ? '✔' : '✖'} ${result.message}\n`);
27
+ return { offered: true, accepted: true, result };
28
+ }
@@ -101,7 +101,7 @@ const Hint = ({ text, cols }) => h(Box, { paddingLeft: 2 }, h(Text, { color: c('
101
101
 
102
102
  // ---- the view ---------------------------------------------------------------------------------
103
103
  export function GettingStarted({
104
- columns, version = '0.3.1', cwd = '', welcome = false, animateIntro, earlyInput = null,
104
+ columns, version = '0.4.0', cwd = '', welcome = false, animateIntro, earlyInput = null,
105
105
  journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findOllama = detectOllama,
106
106
  requestCredential, broker = null, finisher = null, onExit = () => {}, now = () => Date.now()
107
107
  }) {
@@ -5,7 +5,7 @@ export const orgRows = (list) => (Array.isArray(list) ? list : []).map((o) => ({
5
5
 
6
6
  /**
7
7
  * Resolve a typed name or id against the user's orgs. Exact id, exact slug/name, then every word of the
8
- * query found in the name or slug (so "hot tubs hampshire" finds "Hot Tubs Hampshire Ltd"). Never guesses between ties.
8
+ * query found in the name or slug (so "acme widgets" finds "Acme Widgets Ltd"). Never guesses between ties.
9
9
  * -> { org } | { ambiguous: [orgs] } | { none: true }
10
10
  */
11
11
  export function matchOrg(query, list) {
@@ -31,3 +31,29 @@ export function orgLines(list, currentId) {
31
31
  const w = Math.min(36, Math.max(4, ...rows.map((o) => [...o.name].length)));
32
32
  return rows.map((o) => `${o.id === currentId ? '* ' : ' '}${o.name.padEnd(w)} ${o.slug || o.id}${o.role ? ` (${o.role})` : ''}`);
33
33
  }
34
+
35
+ /** `quox org switch` with no argument: the same list, numbered from 1, current org marked. */
36
+ export function numberedOrgLines(list, currentId) {
37
+ const rows = orgRows(list);
38
+ const w = Math.min(36, Math.max(4, ...rows.map((o) => [...o.name].length)));
39
+ return rows.map((o, i) => `${o.id === currentId ? '*' : ' '} ${String(i + 1).padStart(String(rows.length).length)}) ${o.name.padEnd(w)} ${o.slug || o.id}${o.role ? ` (${o.role})` : ''}`);
40
+ }
41
+
42
+ /** A typed answer to the numbered list: an exact slug/name/id first (so a numeric slug is never shadowed), then a 1-based number. */
43
+ export function pickOrgByInput(input, list) {
44
+ const rows = orgRows(list);
45
+ const t = String(input ?? '').trim();
46
+ if (!t) return { none: true };
47
+ const m = matchOrg(t, rows);
48
+ const exactly = m.org && (m.org.slug === t || String(m.org.id).toLowerCase() === t.toLowerCase() || norm(m.org.name) === norm(t));
49
+ if (exactly) return m;
50
+ if (/^\d+$/.test(t)) { const n = Number(t); return n >= 1 && n <= rows.length ? { org: rows[n - 1] } : { none: true }; }
51
+ return m;
52
+ }
53
+
54
+ /** After a profile-wide `quox org switch`: how the other windows on that profile pick the change up. */
55
+ export function sharedSwitchHint({ name, pids }) {
56
+ const n = pids.length;
57
+ return `${n} other quox session${n === 1 ? '' : 's'} ${n === 1 ? 'uses' : 'use'} profile "${name}" (pid ${pids.join(', ')}). `
58
+ + 'They pick this org up on their next token refresh; run /restart in a window to pick it up now. /org <name> moves one window only.';
59
+ }
@@ -0,0 +1,51 @@
1
+ // QUOXON plan approval card (owner 2026-10-26 18:05: "i would deny based on the fact it's not even presenting a plan"):
2
+ // an objective's "approve plan" item must list the plan's steps, and show the org the item really lives in.
3
+ // Pure helpers plus one async enrich step; the card renders what this returns and nothing else.
4
+
5
+ export const PLAN_STEPS_MAX = 8;
6
+ /** Verbs that change things. Anything else reads as read-only (inspect, list, audit, report). */
7
+ const CHANGES = /\b(upgrad\w*|updat\w*|restart\w*|redeploy\w*|deploy\w*|writ\w*|delet\w*|remov\w*|install\w*|migrat\w*|patch\w*|rollback|roll back|reboot\w*|creat\w*|apply|applies|rotat\w*|scal\w*|provision\w*|stop|start|kill|drop|reset|modify|modifies|change\w*|configur\w*)\b/i;
8
+ export const changesThings = (title) => CHANGES.test(String(title || ''));
9
+
10
+ /** Does this inbox item ask for approval of an objective's plan? */
11
+ export const isPlanApproval = (it) => it?.payload?.source === 'objective_checkpoint'
12
+ && it?.payload?.checkpoint_type === 'plan_approval' && typeof it?.payload?.objective_id === 'string';
13
+
14
+ /** Steps from an objective read: [{ n, title, agent, writes }]. Agent display name from `names` (id -> name), else the id. */
15
+ export function stepsOf(objective, names = new Map()) {
16
+ const tasks = Array.isArray(objective?.tasks) ? objective.tasks : [];
17
+ return tasks.filter(t => t && t.title).map((t, i) => ({
18
+ n: i + 1, title: String(t.title), agent: t.agent_id ? String(names.get(t.agent_id) || t.agent_id) : null, writes: changesThings(t.title)
19
+ }));
20
+ }
21
+
22
+ /** Rows for the card: numbered lines plus "+N more" past the cap. Each row is a plain string. */
23
+ export function planLines(steps, max = PLAN_STEPS_MAX) {
24
+ const list = Array.isArray(steps) ? steps : [];
25
+ const lines = list.slice(0, max).map(s => `${s.n}. ${s.writes ? '[changes]' : '[read-only]'} ${s.title}${s.agent ? ` · ${s.agent}` : ''}`);
26
+ if (list.length > max) lines.push(`+${list.length - max} more`);
27
+ return lines;
28
+ }
29
+
30
+ /**
31
+ * Attach { steps, orgId, orgName } as item._plan for a plan-approval item. Reads the objective once (cached by objective id
32
+ * in `cache`). Never throws: when the read fails the item is returned as it came and the card says the plan could not be loaded.
33
+ */
34
+ export async function enrichPlanItem(item, { objectiveGet, agents = null, orgs = null, cache = new Map() } = {}) {
35
+ if (!isPlanApproval(item) || typeof objectiveGet !== 'function') return item;
36
+ const id = item.payload.objective_id;
37
+ if (!cache.has(id)) {
38
+ cache.set(id, (async () => {
39
+ try {
40
+ const o = await objectiveGet(id);
41
+ let names = new Map();
42
+ try { for (const a of (agents ? await agents() : []) || []) if (a?.agent_id && a.name) names.set(a.agent_id, a.name); } catch { /* ids stand in */ }
43
+ const orgId = item.org_id || o?.org_id || null;
44
+ let orgName = null;
45
+ try { orgName = orgId && orgs ? ((await orgs()) || []).find(x => x?.id === orgId)?.name || null : null; } catch { /* the short id stands in */ }
46
+ return { steps: stepsOf(o, names), orgId, orgName, loaded: true };
47
+ } catch (e) { cache.delete(id); return { steps: [], orgId: item.org_id || null, orgName: null, loaded: false, error: e?.message || 'failed' }; }
48
+ })());
49
+ }
50
+ return { ...item, _plan: await cache.get(id) };
51
+ }
@@ -46,7 +46,7 @@ export async function runPrint({ api, prompt, agent = 'quox', output = 'text', c
46
46
  const sum = { tools: 0, approvals: 0, receipts: [] };
47
47
  let convo = conversation, doneSession = null, status = null, text = '', sawApproval = false, errorMsg = null, authFail = false;
48
48
  const seen = new Set();
49
- let offer = null, handoff = null;
49
+ let offer = null, handoff = null, objective = null;
50
50
  let gate = null;
51
51
  try {
52
52
  for await (const ev of api.stream({ turnId, message: prompt, agent: agent || 'quox', conversationId: conversation, context, ...streamFields(choice), ...(promptProfile ? { promptProfile } : {}), ...(timeoutS > 0 ? { turnBudgetMs: timeoutS * 1000 } : {}) }, ctrl.signal)) {
@@ -62,6 +62,7 @@ export async function runPrint({ api, prompt, agent = 'quox', output = 'text', c
62
62
  else if (ev.type === 'tool.finished') { if (!seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; } if (ev.receipt?.ward_seq != null) sum.receipts.push(ev.receipt.ward_seq); }
63
63
  else if (ev.type === 'approval.requested') { sawApproval = true; sum.approvals++; }
64
64
  else if (ev.type === 'approval.resolved') sawApproval = false;
65
+ else if (ev.type === 'turn.objective_suggested' && ev.objective_suggested?.goal) objective = ev.objective_suggested; // QUOXON objective offer
65
66
  else if (ev.type === 'turn.handoff_suggested' && ev.handoff_suggested?.agent) handoff = ev.handoff_suggested; // QUOXON handoff offer
66
67
  else if (ev.type === 'turn.finished') status = ev.status || 'completed';
67
68
  else if (ev.type === 'error') errorMsg = isConsentError(ev) ? consentPrintMessage(ev, profile) : (ev.error || 'stream error');
@@ -85,7 +86,7 @@ export async function runPrint({ api, prompt, agent = 'quox', output = 'text', c
85
86
  if (!status) status = authFail ? 'auth' : 'failed';
86
87
 
87
88
  if (output === 'json') {
88
- out(JSON.stringify({ type: 'summary', status, turn_id: turnId, conversation_id: convo, duration_ms: Date.now() - t0, server_stopped: serverStopped, ...sum, ...(handoff ? { handoff_suggested: handoff } : {}) }) + '\n');
89
+ out(JSON.stringify({ type: 'summary', status, turn_id: turnId, conversation_id: convo, duration_ms: Date.now() - t0, server_stopped: serverStopped, ...sum, ...(handoff ? { handoff_suggested: handoff } : {}), ...(objective ? { objective_suggested: objective } : {}) }) + '\n');
89
90
  } else if (text) out(text.endsWith('\n') ? text : text + '\n');
90
91
  if (authFail) err(`Not signed in for chat. Run: quox login -p <profile>\n`);
91
92
  else if (code === EXIT.approval) err('Held for approval: approve it in the dashboard or with `quox` /approvals.\n');
@@ -93,6 +94,8 @@ export async function runPrint({ api, prompt, agent = 'quox', output = 'text', c
93
94
  else if (errorMsg) err(`quox: ${errorMsg}\n`);
94
95
  if (offer && code !== EXIT.ok) err(`${offer}\n`);
95
96
  // QUOXON handoff offer: no y/N here, so name the exact one-turn command (same question, same conversation).
96
- if (handoff && output !== 'json' && code === EXIT.ok) err(`quox: ${handoff.reason ? `${String(handoff.reason).replace(/\s+$/, '')} ` : ''}${handoff.name || handoff.agent} can answer it: quox --print --agent ${handoff.agent}${convo ? ` --conversation ${convo}` : ''} ${JSON.stringify(prompt)}\n`);
97
+ // QUOXON objective offer: no y/N here, so name the exact command that makes it an objective.
98
+ if (objective && output !== 'json' && code === EXIT.ok) err(`quox: ${objective.reason ? `${String(objective.reason).replace(/\s+$/, '')} ` : ''}Run it until done: quox objective create ${JSON.stringify(objective.goal)} --checkpoint\n`);
99
+ else if (handoff && output !== 'json' && code === EXIT.ok) err(`quox: ${handoff.reason ? `${String(handoff.reason).replace(/\s+$/, '')} ` : ''}${handoff.name || handoff.agent} can answer it: quox --print --agent ${handoff.agent}${convo ? ` --conversation ${convo}` : ''} ${JSON.stringify(prompt)}\n`);
97
100
  return code;
98
101
  }
@@ -37,17 +37,18 @@ export function flyPose(ms) {
37
37
  /** The frame at `ms` since the pass began. still: the one fixed frame (mid-sky, wings raised). mono: shapes only. */
38
38
  /** The sky's size for a terminal `termRows` tall: the mid Q, or the small Q (headroom scaled to match) when the mid sky would
39
39
  * not leave 10 rows for the transcript and composer. Never taller than max(12, termRows - 10). */
40
- export function flySky(termRows) {
41
- const room = Math.max(12, (termRows || 40) - 10);
40
+ export function flySky(termRows, chromeH = null) {
41
+ const room = chromeH == null ? Math.max(12, (termRows || 40) - 10) : (termRows || 40) - chromeH - 2; // measured chrome (app.js): the sky + margin + chrome must stay under the terminal height or Ink wipes the screen
42
42
  const fits = (size, top) => dragonLayout(120, { size, lm: 0, ty: top }).rows <= room;
43
- if (fits(SIZE, TOP)) return { size: SIZE, top: TOP, bob: BOB };
43
+ if (fits(SIZE, TOP)) return { size: SIZE, top: TOP, bob: BOB, fits: true };
44
44
  const k = DSIZES.small / DSIZES[SIZE];
45
- return { size: 'small', top: Math.round(TOP * k), bob: Math.max(1, Math.round(BOB * k)) };
45
+ const top = Math.round(TOP * k);
46
+ return { size: 'small', top, bob: Math.max(1, Math.round(BOB * k)), fits: chromeH == null || dragonLayout(120, { size: 'small', lm: 0, ty: top }).rows <= room };
46
47
  }
47
48
 
48
- export function flyFrame(ms, cols, { still = false, mono = false, theme = 'dark', termRows } = {}) {
49
+ export function flyFrame(ms, cols, { still = false, mono = false, theme = 'dark', termRows, chromeH = null } = {}) {
49
50
  const t = still ? 0 : Math.max(0, Math.min(ms, FLY_PASS_MS));
50
- const { size: SIZE, top: TOP, bob: BOB } = flySky(termRows);
51
+ const { size: SIZE, top: TOP, bob: BOB } = flySky(termRows, chromeH);
51
52
  const probe = dragonLayout(cols, { size: SIZE, lm: 0, ty: TOP });
52
53
  const rows = probe.rows;
53
54
  const xc = still ? cols / 2 : -SPAN + (cols + 2 * SPAN) * kf(t, [[0, 0], [FLY_PASS_MS, 1]]);
@@ -11,6 +11,7 @@ import { helloCells, helloGradient, helloMs } from './qhello.js';
11
11
  import { mono, c, clip, isLight } from './theme.js';
12
12
  import { CLIPS, clipCells, clipCaption, eggCells, EGG_MS } from './qdragonScenes.js';
13
13
  import { reducedMotion } from './motion/clock.js';
14
+ import { flySky } from './qdragonFly.js';
14
15
  import { FrameContext, useFrame } from './motion/frame.js';
15
16
  const isQuiet = () => reducedMotion(); // motion only (NO_COLOR is colour only; QGlyph falls back to the circled Q itself)
16
17
  import { termRows } from './term.js';
@@ -130,3 +131,19 @@ export function DragonLive({ cols, rows }) {
130
131
  ...cells.map((row, k) => h(Text, { key: k, wrap: 'truncate' }, ...cellNodes(row, `d${k}-`))),
131
132
  h(Text, { color: c('dim') }, clip(caption, Math.max(10, cols - 2))));
132
133
  }
134
+
135
+ /** Rows the 15-row dragon clips need besides the chrome: the clip, its caption and the margin above it. */
136
+ export const CLIP_ROWS = 17;
137
+ /** Does an explicit /dragon <clip> fit? { ok } or { ok:false, note } (one line, never silent). Unknown size (null rows) is ok. */
138
+ export function clipRoom(clipName, rows, chromeH = 10) {
139
+ if (!rows) return { ok: true };
140
+ const need = CLIP_ROWS + chromeH + 2;
141
+ return rows >= need ? { ok: true } : { ok: false, note: `${clipName} needs ${need} rows, you have ${rows}. Make the window taller.` };
142
+ }
143
+ /** Does /dragon-fly fit? Same shape as clipRoom; the smallest sky is 12 rows. */
144
+ export function flyRoom(rows, chromeH = 10) {
145
+ if (!rows) return { ok: true };
146
+ if (flySky(rows, chromeH).fits) return { ok: true };
147
+ const need = 12 + 1 + chromeH + 2;
148
+ return { ok: false, note: `fly needs ${need} rows, you have ${rows}. Make the window taller.` };
149
+ }