quoxcli 0.0.0-stage → 0.2.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 (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Session usage ledger for /cost, /usage and /context. Fed the raw stream events of each turn; counts only
3
+ * what the server reported (turn.finished.usage, or done.usage on a server that never sends turn.finished).
4
+ * Nothing is estimated: a turn without usage is counted as "not reported", and cost stays blank unless the
5
+ * server sent cost_usd.
6
+ */
7
+ const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
8
+
9
+ export function readUsage(u) {
10
+ const o = u && typeof u === 'object' ? u : {};
11
+ const input = num(o.input_tokens ?? o.prompt_tokens);
12
+ const output = num(o.output_tokens ?? o.completion_tokens);
13
+ const cacheRead = num(o.cache_read_input_tokens);
14
+ const cacheWrite = num(o.cache_creation_input_tokens);
15
+ const cost = num(o.cost_usd);
16
+ return { input, output, cacheRead, cacheWrite, cost, reported: input != null || output != null };
17
+ }
18
+
19
+ export function createLedger() {
20
+ const models = new Map(); // key -> row
21
+ const convs = new Map(); // conversation id -> tokens
22
+ let turn = null;
23
+ const finalize = (usage, convId) => {
24
+ if (!turn) return;
25
+ const t = turn; turn = null;
26
+ const u = readUsage(usage ?? t.doneUsage);
27
+ const key = `${t.provider || '?'}|${t.model || '?'}`;
28
+ const r = models.get(key) || { provider: t.provider, model: t.model, turns: 0, reported: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, costTurns: 0 };
29
+ r.turns++;
30
+ if (u.reported) {
31
+ r.reported++;
32
+ r.input += u.input || 0; r.output += u.output || 0; r.cacheRead += u.cacheRead || 0; r.cacheWrite += u.cacheWrite || 0;
33
+ const id = convId || t.conversationId;
34
+ if (id) convs.set(id, (convs.get(id) || 0) + (u.input || 0) + (u.output || 0));
35
+ }
36
+ if (u.cost != null) { r.cost += u.cost; r.costTurns++; }
37
+ models.set(key, r);
38
+ };
39
+ return {
40
+ observe(ev) {
41
+ if (!ev || typeof ev !== 'object') return;
42
+ if (ev.type === 'turn.started') turn = { provider: ev.provider || null, model: ev.model || null, conversationId: ev.conversation_id || null, doneUsage: null };
43
+ else if (ev.type === 'done' && turn) turn.doneUsage = ev.usage || (ev.cost_usd != null ? { cost_usd: ev.cost_usd } : null);
44
+ else if (ev.type === 'turn.finished') finalize(ev.usage, ev.conversation_id);
45
+ },
46
+ /** The stream closed: settle a turn whose turn.finished never came (legacy servers send only done). */
47
+ end() { if (turn) finalize(null, null); },
48
+ rows: () => [...models.values()],
49
+ conversationTokens: (id) => (id ? convs.get(id) ?? 0 : 0),
50
+ totals() {
51
+ const t = { turns: 0, reported: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, costTurns: 0 };
52
+ for (const r of models.values()) for (const k of Object.keys(t)) t[k] += r[k];
53
+ return t;
54
+ }
55
+ };
56
+ }
57
+
58
+ const n = (v) => Number(v || 0).toLocaleString('en-US');
59
+ const usd = (v) => `$${v < 0.01 ? v.toFixed(4) : v.toFixed(2)}`;
60
+
61
+ function line(r) {
62
+ const parts = [`${r.turns} turn${r.turns === 1 ? '' : 's'}`];
63
+ if (r.reported) {
64
+ parts.push(`in ${n(r.input)}`, `out ${n(r.output)}`);
65
+ if (r.cacheRead || r.cacheWrite) parts.push(`cache ${n(r.cacheRead)} read / ${n(r.cacheWrite)} written`);
66
+ }
67
+ if (r.reported < r.turns) parts.push(`${r.turns - r.reported} without usage from the server`);
68
+ parts.push(r.costTurns === r.turns && r.turns ? usd(r.cost) : r.costTurns ? `${usd(r.cost)} for ${r.costTurns} of ${r.turns} turns` : 'cost not reported');
69
+ return parts.join(' · ');
70
+ }
71
+
72
+ /** -> help model rows for /cost. */
73
+ export function costModel(ledger) {
74
+ const rows = ledger.rows();
75
+ const out = [{ kind: 'head', text: 'Cost and usage, this session' }];
76
+ if (!rows.length) return [...out, { kind: 'plain', text: 'No turns yet in this session.' }];
77
+ for (const r of rows) out.push({ kind: 'row', key: `${r.provider || '?'} ${r.model || ''}`.trim(), text: line(r) });
78
+ const t = ledger.totals();
79
+ if (rows.length > 1) out.push({ kind: 'row', key: 'Total', text: line(t) });
80
+ if (t.costTurns < t.turns) out.push({ kind: 'dim', text: 'Cost is blank where the server sent tokens but no cost_usd for that provider; Quox does not guess prices.' });
81
+ if (t.reported < t.turns) out.push({ kind: 'dim', text: 'Some turns ended without usage (an older server, or a turn that failed or was interrupted).' });
82
+ return out;
83
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * One small list component for everything slash commands pop up under the composer: the command
3
+ * palette, the /resume picker and the /config card. Rows: { key, text, note?, dim? }. The selected
4
+ * row carries a marker; dim rows show their note (the reason) in muted text.
5
+ */
6
+ import React from 'react';
7
+ import { Box, Text } from 'ink';
8
+ import { c, clip } from '../theme.js';
9
+
10
+ const h = React.createElement;
11
+
12
+ export function ListView({ title = null, rows = [], sel = 0, cols = 80, hint = null, empty = null, more = 0 }) {
13
+ const keyW = Math.min(22, Math.max(8, ...rows.map(r => [...String(r.key || '')].length)) + 1);
14
+ const textW = Math.max(10, cols - keyW - 6);
15
+ return h(Box, { flexDirection: 'column', paddingLeft: 1 },
16
+ title ? h(Text, { color: c('muted'), bold: true }, clip(title, cols - 2)) : null,
17
+ !rows.length && empty ? h(Text, { color: c('muted') }, ` ${clip(empty, cols - 4)}`) : null,
18
+ ...rows.map((r, i) => {
19
+ const on = i === sel;
20
+ const body = r.dim && r.note ? `${r.note} · ${r.text}` : r.note ? `${r.text} ${r.note}` : r.text;
21
+ return h(Box, { key: `${i}:${r.key}` },
22
+ h(Text, { color: on ? c('key') : c('muted') }, on ? '▸ ' : ' '),
23
+ h(Box, { width: keyW, flexShrink: 0 }, h(Text, { color: r.dim ? c('muted') : on ? c('key') : c('label'), bold: on && !r.dim, wrap: 'truncate-end' }, String(r.key || ''))),
24
+ h(Text, { color: c(r.dim ? 'muted' : 'text'), wrap: 'truncate-end' }, clip(String(body || ''), textW)));
25
+ }),
26
+ more > 0 ? h(Text, { color: c('muted') }, ` … ${more} more, keep typing to narrow`) : null,
27
+ hint ? h(Text, { color: c('dim') }, ` ${clip(hint, cols - 4)}`) : null);
28
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * L0 command palette: the pure model behind the list that opens under the composer when a draft
3
+ * starts with `/`. Built from the one REGISTRY (commands.js), so the palette, /help and dispatch can
4
+ * never disagree. Only commands that can work here are bright; anything this session cannot run is
5
+ * dim with the reason. Planned commands stay hidden unless typed exactly.
6
+ */
7
+ import { REGISTRY } from '../commands.js';
8
+
9
+ export const PALETTE_MAX = 8;
10
+
11
+ /** The draft as a palette query: '/co' -> 'co'; null when the draft is not a bare command word. */
12
+ export function paletteQuery(draft) {
13
+ const t = String(draft ?? '');
14
+ if (!t.startsWith('/') || t.startsWith('//')) return null;
15
+ if (/[\s]/.test(t)) return null; // args typed (or several lines): the palette steps aside
16
+ return t.slice(1).toLowerCase();
17
+ }
18
+
19
+ /**
20
+ * Why a command cannot run in this session, or null. `api` is the session's server wrapper (a method
21
+ * missing means this mode has no route for it); `caps` is GET /api/v1/capabilities when the server has
22
+ * it ({ id: { available, reason } }), else null; `blocked` is the not-signed-in message.
23
+ */
24
+ export function unavailableReason(entry, { api = {}, caps = null, blocked = null } = {}) {
25
+ const cap = caps ? caps[entry.cap || entry.id] : null;
26
+ if (cap && cap.available === false) return cap.reason || 'not available on this server';
27
+ for (const m of entry.needs || []) if (typeof api[m] !== 'function') return 'not available in this mode';
28
+ if (blocked && entry.server) return 'not signed in';
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * GET /api/v1/capabilities as api.capabilities() returns it ({ ok, capabilities:[{ id, availability, reason, setup }] })
34
+ * -> { id: { available, reason } }, or null when the server has no contract. Only `installed` runs.
35
+ */
36
+ export function capabilityMap(res) {
37
+ if (!res || res.ok !== true || !Array.isArray(res.capabilities)) return null;
38
+ const label = { requires_setup: 'needs setup', unavailable: 'unavailable on this server', planned: 'planned, not built yet' };
39
+ return Object.fromEntries(res.capabilities.filter(c => c && c.id).map(c => {
40
+ const available = c.availability === 'installed';
41
+ return [c.id, { available, reason: available ? null : (c.reason || label[c.availability] || 'not available on this server'), setup: c.setup || null }];
42
+ }));
43
+ }
44
+
45
+ /** All palette rows for this session, registry order. */
46
+ export function paletteItems(ctx = {}, registry = REGISTRY) {
47
+ return registry.filter(e => e.kind === 'command').map(e => ({
48
+ id: e.id,
49
+ name: '/' + e.id,
50
+ usage: e.usage,
51
+ desc: e.desc,
52
+ aliases: (e.aliases || []).map(a => '/' + a),
53
+ planned: e.status === 'planned',
54
+ disabled: e.status === 'planned' ? 'planned, not built yet' : unavailableReason(e, ctx)
55
+ }));
56
+ }
57
+
58
+ /** Subsequence score of q in s (lower is better), or null when q is not a subsequence. */
59
+ export function fuzzy(q, s) {
60
+ q = String(q).toLowerCase(); s = String(s).toLowerCase();
61
+ if (!q) return 0;
62
+ if (s.startsWith(q)) return 0;
63
+ const at = s.indexOf(q);
64
+ if (at >= 0) return 10 + at;
65
+ let i = 0, gaps = 0, last = -1;
66
+ for (let j = 0; j < s.length && i < q.length; j++) {
67
+ if (s[j] === q[i]) { if (last >= 0) gaps += j - last - 1; last = j; i++; }
68
+ }
69
+ return i === q.length ? 100 + gaps : null;
70
+ }
71
+
72
+ /** Filter + rank. Planned rows appear only on an exact name or alias match. */
73
+ export function filterPalette(items, query) {
74
+ const q = String(query ?? '').toLowerCase();
75
+ const exact = (it) => it.id === q || it.aliases.includes('/' + q);
76
+ const scored = [];
77
+ items.forEach((it, idx) => {
78
+ if (it.planned && !exact(it)) return;
79
+ const names = [it.id, ...it.aliases.map(a => a.slice(1))];
80
+ let best = null;
81
+ for (const n of names) { const sc = fuzzy(q, n); if (sc != null && (best == null || sc < best)) best = sc; }
82
+ if (best == null && q.length >= 3 && it.desc.toLowerCase().includes(q)) best = 500; // words in the description
83
+ if (best == null) return;
84
+ if (exact(it)) best = -1;
85
+ if (it.disabled) best += 0.5; // a working command outranks an unavailable one on a tie
86
+ scored.push({ it, best, idx });
87
+ });
88
+ return scored.sort((a, b) => a.best - b.best || a.idx - b.idx).map(x => x.it);
89
+ }
90
+
91
+ /** What Tab puts in the draft: the name, plus a space when the command takes arguments. */
92
+ export function completion(item) {
93
+ const takesArgs = /^\S+\s+\S/.test(String(item.usage).trim());
94
+ return item.name + (takesArgs ? ' ' : '');
95
+ }
96
+
97
+ /** View rows for the palette: a window of PALETTE_MAX around the selection. */
98
+ export function paletteView(rows, sel, max = PALETTE_MAX) {
99
+ if (!rows.length) return { rows: [], sel: 0, more: 0, empty: true };
100
+ const s = Math.max(0, Math.min(sel, rows.length - 1));
101
+ const start = Math.max(0, Math.min(s - max + 1, rows.length - max));
102
+ const win = rows.slice(Math.max(0, start), Math.max(0, start) + max);
103
+ return { rows: win, sel: s - Math.max(0, start), more: rows.length - win.length, empty: false };
104
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The palette's key handling, kept out of app.js. The palette is not modal: it shows while the draft is
3
+ * a bare `/word`, and only Up, Down, Tab, Enter and Esc go to it. Everything else is ordinary typing,
4
+ * which narrows the list because the draft IS the filter.
5
+ */
6
+ import { findEntry } from '../commands.js';
7
+ import { paletteQuery, paletteItems, filterPalette, completion, paletteView, PALETTE_MAX } from './palette.js';
8
+
9
+ export function createPalette({ getDraft, setDraft, run, say, ctx = () => ({}) }) {
10
+ const st = { sel: 0, q: null, dismissed: null };
11
+ const rows = () => {
12
+ const q = paletteQuery(getDraft());
13
+ if (q !== st.q) { st.q = q; st.sel = 0; }
14
+ return q === null ? [] : filterPalette(paletteItems(ctx()), q);
15
+ };
16
+ const isOpen = () => {
17
+ const d = getDraft();
18
+ if (st.dismissed !== null && st.dismissed !== d) st.dismissed = null;
19
+ return paletteQuery(d) !== null && st.dismissed !== d;
20
+ };
21
+ /** -> true when the key was the palette's. */
22
+ function onKey(name) {
23
+ if (!isOpen()) return false;
24
+ const list = rows();
25
+ switch (name) {
26
+ case 'up': if (!list.length) return false; st.sel = (st.sel - 1 + list.length) % list.length; return true;
27
+ case 'down': if (!list.length) return false; st.sel = (st.sel + 1) % list.length; return true;
28
+ case 'escape': st.dismissed = getDraft(); return true;
29
+ case 'tab': {
30
+ const it = list[st.sel];
31
+ if (!it) return true;
32
+ setDraft(completion(it));
33
+ return true;
34
+ }
35
+ case 'enter': {
36
+ const typed = findEntry(getDraft());
37
+ if (typed && typed.kind === 'command') return false; // an exact command runs as typed (planned ones say so)
38
+ const it = list[st.sel];
39
+ if (!it) return false; // nothing matches: the normal path says "Unknown command"
40
+ if (it.disabled) { say(`${it.name} is ${it.disabled}.`, 'warn'); return true; }
41
+ setDraft('');
42
+ run(it.name);
43
+ return true;
44
+ }
45
+ default: return false;
46
+ }
47
+ }
48
+ function view() {
49
+ const list = rows();
50
+ const v = paletteView(list, st.sel, PALETTE_MAX);
51
+ return {
52
+ rows: v.rows.map(it => ({ key: it.name, text: (it.aliases.length ? `(also ${it.aliases.join(', ')}) ` : '') + it.desc, dim: Boolean(it.disabled), note: it.disabled || null })),
53
+ sel: v.sel, more: v.more,
54
+ empty: 'No command matches. Enter sends it anyway; /help lists everything.',
55
+ hint: '↑↓ select · Tab complete · Enter run · Esc close'
56
+ };
57
+ }
58
+ return { isOpen, onKey, view, state: st };
59
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pure panel models for /status, /context and /doctor, rendered with the /help view (head + key/value rows).
3
+ * Every value comes from the caller's real data; anything not known is said plainly, never filled in.
4
+ */
5
+ import { capabilityMap } from './palette.js';
6
+
7
+ const n = (v) => Number(v || 0).toLocaleString('en-US');
8
+ const row = (key, text) => ({ kind: 'row', key, text: String(text) });
9
+
10
+ export function healthText(hl) {
11
+ if (!hl) return '? not checked';
12
+ if (hl.ok) return `✔ answering (HTTP ${hl.status})`;
13
+ if (hl.absent) return `✖ nothing running there (${hl.status})`;
14
+ return `✖ not healthy (${hl.status})`;
15
+ }
16
+
17
+ /** What actually ran (turn.started), else what was asked for, said as such. */
18
+ export function modelText(ctx = {}, choice = null) {
19
+ if (ctx.provider || ctx.model) return `${[ctx.provider, ctx.model].filter(Boolean).join(' ')} (as the server last reported)`;
20
+ const ask = choice ? [choice.provider, choice.model].filter(Boolean).join(' ') : 'auto (server policy)';
21
+ return `no turn yet; asking for ${ask}`;
22
+ }
23
+
24
+ export function statusModel(d = {}) {
25
+ return [
26
+ { kind: 'head', text: 'Status' },
27
+ row('Profile', d.profile || '?'),
28
+ row('Org', d.org || '? not in this login'),
29
+ row('Server', `${d.serverUrl || '?'} · ${healthText(d.health)}`),
30
+ row('Model', modelText(d.ctx, d.choice)),
31
+ row('Agent', d.agent || 'CommanderQ (quox)'),
32
+ row('Slot', d.slot || 'none (/slot <name>)'),
33
+ row('Session', d.sessionName || 'unnamed (/rename <name>)'),
34
+ row('Conversation', d.conversationId || 'new (starts with your next message)'),
35
+ row('Version', d.version || '?'),
36
+ ...(d.local ? [{ kind: 'dim', text: 'Server health not re-checked for this tip; /status checks it live.' }] : [])
37
+ ];
38
+ }
39
+
40
+ export function contextModel(d = {}) {
41
+ const rules = d.rules;
42
+ const ruleFiles = rules?.files || [];
43
+ const out = [
44
+ { kind: 'head', text: 'Context' },
45
+ row('Model', modelText(d.ctx, d.choice)),
46
+ row('Conversation', !d.conversationId ? 'new, nothing sent yet' : d.server ? `${d.server.tokensText}${d.server.turnsText ? ` · ${d.server.turnsText}` : ''}` : `${n(d.tokens)} tokens reported so far in this session${d.serverError ? ` (server context: ${d.serverError})` : ''}`),
47
+ ...(d.conversationId && d.server ? [row('Summary', d.server.summaryText)] : []),
48
+ row('Rules', d.rulesOn === false ? 'off for this session (/rules on)' : ruleFiles.length ? `${ruleFiles.length} QUOX.md file${ruleFiles.length === 1 ? '' : 's'}, ${n(rules.totalChars)} chars${rules.truncated ? ' (truncated to fit)' : ''}` : 'none loaded')
49
+ ];
50
+ if (d.rulesOn !== false) for (const f of ruleFiles) out.push({ kind: 'dim', key: '', text: ` ${f.label || f.path} ${n(f.chars)} chars` });
51
+ const sk = d.skills;
52
+ out.push(row('Skills', !sk ? '? not checked (/context checks it live)' : sk.ok ? `${sk.count} available in your org (/skills)` : `? ${sk.error}`));
53
+ const mem = d.memory;
54
+ out.push(row('Memory', !mem ? '? not checked (/context checks it live)' : mem.ok ? 'connected (persistent memory answers)' : `not reachable: ${mem.error}`));
55
+ out.push(row('Agent', d.agent || 'CommanderQ (quox)'));
56
+ return out;
57
+ }
58
+
59
+ const icon = (s) => (s === 'pass' ? '✔' : s === 'warn' ? '⚠' : s === 'skip' ? '·' : '✖');
60
+
61
+ /** checks: [{ name, status:'pass'|'warn'|'fail'|'error'|'skip', detail?, latency_ms? }] */
62
+ export function doctorModel(platform, chat, platformError = null) {
63
+ const rowOf = (c) => ({ kind: c.status === 'skip' ? 'dim' : 'row', key: `${icon(c.status)} ${c.name}`, text: [c.detail, c.latency_ms != null ? `${c.latency_ms}ms` : null].filter(Boolean).join(' · ') || (c.status === 'pass' ? 'ok' : c.status) });
64
+ const out = [{ kind: 'head', text: 'Doctor' }];
65
+ if (platformError) out.push(row('✖ quox doctor', platformError));
66
+ for (const c of platform || []) out.push(rowOf(c));
67
+ out.push({ kind: 'gap' }, { kind: 'head', text: 'Chat' });
68
+ for (const c of chat || []) out.push(rowOf(c));
69
+ const all = [...(platform || []), ...(chat || [])];
70
+ const count = (s) => all.filter(c => c.status === s || (s === 'fail' && c.status === 'error')).length;
71
+ out.push({ kind: 'gap' }, { kind: 'dim', text: `${count('pass')} passed · ${count('warn')} warning${count('warn') === 1 ? '' : 's'} · ${count('fail')} failed` });
72
+ return out;
73
+ }
74
+
75
+ /** Chat checks, each on its own: the chat routes answer, a provider is connected, the capability contract. */
76
+ export async function chatChecks(api, providerTypes = ['openai', 'anthropic', 'ollama']) {
77
+ const one = async (name, fn) => { try { return { name, ...(await fn()) }; } catch (e) { return { name, status: 'fail', detail: e?.status ? `HTTP ${e.status} ${e.message || ''}`.trim() : (e?.message || 'failed') }; } };
78
+ return Promise.all([
79
+ one('Chat server', async () => {
80
+ if (!api.conversations) return { status: 'skip', detail: 'not available in this mode' };
81
+ const t0 = Date.now(); await api.conversations(1);
82
+ return { status: 'pass', detail: 'conversation routes answer for this login', latency_ms: Date.now() - t0 };
83
+ }),
84
+ one('AI provider', async () => {
85
+ if (!api.connections) return { status: 'skip', detail: 'not available in this mode' };
86
+ const r = await api.connections();
87
+ if (!r.ok) return { status: 'fail', detail: `could not read the vault: ${r.error}` };
88
+ const have = providerTypes.filter(t => r.rows.some(x => x.integration_type === t));
89
+ return have.length ? { status: 'pass', detail: `connected: ${have.join(', ')}` } : { status: 'warn', detail: 'no AI provider in your org vault; /connect openai' };
90
+ }),
91
+ one('Capabilities', async () => {
92
+ if (!api.capabilities) return { status: 'skip', detail: 'not available in this mode' };
93
+ const res = await api.capabilities();
94
+ if (res && res.ok === false) return res.missing ? { status: 'warn', detail: 'this server has no capability contract yet (GET /api/v1/capabilities)' } : { status: 'fail', detail: res.error || 'failed' };
95
+ const m = capabilityMap(res);
96
+ if (!m) return { status: 'warn', detail: 'unexpected capability response' };
97
+ const off = Object.entries(m).filter(([, v]) => v && v.available === false).map(([k]) => k);
98
+ return { status: off.length ? 'warn' : 'pass', detail: `${Object.keys(m).length} known${off.length ? `, unavailable: ${off.slice(0, 6).join(', ')}` : ', all available'}` };
99
+ })
100
+ ]);
101
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A modal list under the composer (the /resume picker, the /config card). While open it takes every key,
3
+ * like the /connect card: Up/Down select, Enter picks, Esc closes, Left/Right/Space go to onAdjust.
4
+ */
5
+ export function createPicker({ refresh = () => {} } = {}) {
6
+ let cur = null; // { title, rows, sel, hint, onPick, onAdjust, onClose, rowsFn }
7
+ const close = () => { const c = cur; cur = null; c?.onClose?.(); refresh(); };
8
+ return {
9
+ isOpen: () => Boolean(cur),
10
+ open(spec) { cur = { sel: 0, ...spec }; refresh(); },
11
+ close,
12
+ rows: () => (cur ? (cur.rowsFn ? cur.rowsFn() : cur.rows) : []),
13
+ onEvent(e) {
14
+ if (!cur) return false;
15
+ const rows = cur.rowsFn ? cur.rowsFn() : cur.rows;
16
+ if (e.type !== 'key' && !(e.type === 'text' && e.text === ' ')) return true; // typing is swallowed while open
17
+ const name = e.type === 'text' ? 'space' : e.name;
18
+ if (name === 'escape' || name === 'ctrl-c') { close(); return true; }
19
+ if (name === 'up' && rows.length) cur.sel = (cur.sel - 1 + rows.length) % rows.length;
20
+ else if (name === 'down' && rows.length) cur.sel = (cur.sel + 1) % rows.length;
21
+ else if ((name === 'left' || name === 'right' || name === 'space') && cur.onAdjust) cur.onAdjust(cur.sel, name === 'left' ? -1 : 1);
22
+ else if (name === 'enter') {
23
+ const c = cur; const pick = rows[c.sel];
24
+ if (c.keepOpen?.(c.sel)) c.onPick?.(pick, c.sel);
25
+ else { cur = null; c.onPick?.(pick, c.sel); }
26
+ }
27
+ refresh();
28
+ return true;
29
+ },
30
+ view() {
31
+ if (!cur) return null;
32
+ const rows = cur.rowsFn ? cur.rowsFn() : cur.rows;
33
+ const max = 10;
34
+ const start = Math.max(0, Math.min(cur.sel - max + 1, rows.length - max));
35
+ return { title: cur.title, rows: rows.slice(start, start + max), sel: cur.sel - start, more: Math.max(0, rows.length - max), hint: cur.hint, empty: cur.empty };
36
+ }
37
+ };
38
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * /restart and the "newer build installed" notice.
3
+ *
4
+ * The installed quox is whatever `quox` on PATH runs: a symlink to cli/bin/quox.js, or a small wrapper
5
+ * script that execs one (the dev box's ~/bin/quox). Its build is the git HEAD of that CLI tree, read
6
+ * locally (no fetch, no network). The running build is that HEAD captured when this client started, so a
7
+ * refresh of the tree under a running client shows up as "newer installed".
8
+ */
9
+ import { execFile, execFileSync, spawnSync } from 'node:child_process';
10
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
11
+ import { delimiter, dirname, join, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import os from 'node:os';
14
+
15
+ /** The CLI tree this code runs from (…/cli). */
16
+ export const RUNNING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
17
+ export const NEWER_NOTICE = 'A newer quox is installed. /restart to use it.';
18
+ export const DIFFERENT_NOTICE = 'A different quox build is installed. /restart to use it.';
19
+
20
+ export function findOnPath(name = 'quox', env = process.env) {
21
+ for (const dir of String(env.PATH || '').split(delimiter)) {
22
+ if (!dir) continue;
23
+ const p = join(dir, name);
24
+ try { accessSync(p, constants.X_OK); if (statSync(p).isFile()) return p; } catch { /* next */ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ const isCliRoot = (d) => existsSync(join(d, 'lib', 'core', 'version.js')) && existsSync(join(d, 'bin', 'quox.js'));
30
+
31
+ /** The CLI root a `quox` executable runs, or null. */
32
+ export function cliRootOf(bin) {
33
+ if (!bin) return null;
34
+ let real;
35
+ try { real = realpathSync(bin); } catch { return null; }
36
+ if (/[/\\]bin[/\\]quox(\.js)?$/.test(real)) { const d = resolve(dirname(real), '..'); if (isCliRoot(d)) return d; }
37
+ try {
38
+ if (statSync(real).size > 65536) return null;
39
+ const m = /(\/[^\s"'$]+)\/bin\/quox\.js/.exec(readFileSync(real, 'utf8'));
40
+ if (m && isCliRoot(m[1])) return m[1];
41
+ } catch { /* unreadable */ }
42
+ return null;
43
+ }
44
+
45
+ const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
46
+ export function headOf(root) { try { return root ? git(root, ['rev-parse', '--short', 'HEAD']) || null : null; } catch { return null; } }
47
+ /** true / false, or null when it cannot be told. */
48
+ export function isAncestor(root, a, b) {
49
+ try { execFileSync('git', ['-C', root, 'merge-base', '--is-ancestor', a, b], { stdio: 'ignore', timeout: 2000 }); return true; }
50
+ catch (e) { return e?.status === 1 ? false : null; }
51
+ }
52
+
53
+ /**
54
+ * Compare the running build with the installed one. -> notice text or null. `deps` lets tests stand in for
55
+ * git and PATH.
56
+ */
57
+ export function newerBuild({ startSha, runningRoot = RUNNING_ROOT, env = process.env, deps = {} } = {}) {
58
+ const d = { findOnPath, cliRootOf, headOf, isAncestor, ...deps };
59
+ if (!startSha) return null;
60
+ const root = d.cliRootOf(d.findOnPath('quox', env)) || runningRoot;
61
+ const head = d.headOf(root);
62
+ if (!head || head === startSha || head.startsWith(startSha) || startSha.startsWith(head)) return null;
63
+ if (d.isAncestor(root, startSha, head) === true) return NEWER_NOTICE;
64
+ if (d.isAncestor(root, head, startSha) === true) return null; // the installed build is older than this one: nothing to gain
65
+ return DIFFERENT_NOTICE; // unrelated or unknown history: say so, never claim "newer
66
+ }
67
+
68
+ /** Poll at most every `everyMs`; calls onNotice once. -> stop(). */
69
+ export function startBuildWatch({ onNotice, everyMs = 60000, check = newerBuild, ...opts } = {}) {
70
+ let said = false;
71
+ const id = setInterval(() => {
72
+ if (said) return;
73
+ let t = null;
74
+ try { t = check(opts); } catch { t = null; }
75
+ if (t) { said = true; onNotice(t); clearInterval(id); }
76
+ }, everyMs);
77
+ id.unref?.();
78
+ return () => clearInterval(id);
79
+ }
80
+
81
+ /**
82
+ * Same arguments, minus any earlier resume target, plus --resume <id> for this conversation. The profile and
83
+ * session name this client is using are carried over when the original command line did not name them.
84
+ */
85
+ export function restartArgs(argv = [], conversationId = null, { profile = null, name = null } = {}) {
86
+ const out = [];
87
+ for (let i = 0; i < argv.length; i++) {
88
+ const a = argv[i];
89
+ if (a === '--conversation') { i++; continue; }
90
+ if (a === '--resume') { const n = argv[i + 1]; if (n !== undefined && !n.startsWith('-')) i++; continue; }
91
+ if (/^--(resume|conversation)=/.test(a)) continue;
92
+ out.push(a);
93
+ }
94
+ const has = (...f) => out.some(a => f.includes(a) || f.some(x => a.startsWith(`${x}=`)));
95
+ if (profile && !has('-p', '--profile') && !out.includes('--demo')) out.push('-p', profile);
96
+ if (name && !has('-n', '--name')) out.push('-n', name);
97
+ return conversationId ? [...out, '--resume', conversationId] : out;
98
+ }
99
+
100
+ /** The quox a restart would run: the one on PATH, else this client's own tree. -> [command, leading args]. */
101
+ export function restartTarget(env = process.env, bin = findOnPath('quox', env)) {
102
+ return bin ? [bin, []] : [process.execPath, [join(RUNNING_ROOT, 'bin', 'quox.js')]];
103
+ }
104
+
105
+ /**
106
+ * Before the client lets go of the terminal: can the installed quox start at all? Runs `quox --version`
107
+ * (no network, no login). -> Promise<{ ok, bin (what runs), onPath (the PATH quox or null), version?, error? }>. A failure keeps the user in this client.
108
+ */
109
+ export function checkRestart({ env = process.env, bin = findOnPath('quox', env), run = execFile, timeoutMs = 15000 } = {}) {
110
+ const [cmd, lead] = restartTarget(env, bin);
111
+ return new Promise((done) => {
112
+ run(cmd, [...lead, '--version'], { env, timeout: timeoutMs, encoding: 'utf8' }, (err, stdout, stderr) => {
113
+ if (err) {
114
+ const why = err.code === 'ENOENT' || err.code === 'EACCES' ? `${err.code}: it cannot be run`
115
+ : err.killed ? `no answer in ${Math.round(timeoutMs / 1000)} s`
116
+ : String(stderr || err.message || 'failed').trim().split('\n')[0].slice(0, 160);
117
+ return done({ ok: false, bin: cmd, onPath: bin || null, error: why });
118
+ }
119
+ done({ ok: true, bin: cmd, onPath: bin || null, version: String(stdout || '').trim().split('\n')[0] || null });
120
+ });
121
+ });
122
+ }
123
+
124
+ /** Terminal modes the client turns on (bracketed paste, focus reporting, hidden cursor): all off before the hand-over. */
125
+ export const TERMINAL_RESTORE = '\x1b[?2004l\x1b[?1004l\x1b[?25h';
126
+
127
+ /** Give the terminal back in a plain state: cooked mode, stdin paused, modes off. */
128
+ export function restoreTerminal({ stdin = process.stdin, stdout = process.stdout } = {}) {
129
+ try { if (stdin?.isTTY) stdin.setRawMode?.(false); stdin?.pause?.(); } catch { /* not a tty */ }
130
+ try { if (stdout?.isTTY) stdout.write(TERMINAL_RESTORE); } catch { /* closed */ }
131
+ }
132
+
133
+ /**
134
+ * Run the installed quox in this terminal, wait for it, and hand back { code } (its exit code), or
135
+ * { error } when it could not be started (the caller stays in the current client and says so).
136
+ */
137
+ export function execRestart({ argv, conversationId, profile = null, name = null, env = process.env, spawn = spawnSync, bin = findOnPath('quox', env), restore = restoreTerminal }) {
138
+ const args = restartArgs(argv, conversationId, { profile, name });
139
+ restore(); // the child owns the terminal now
140
+ const [cmd, lead] = restartTarget(env, bin);
141
+ const r = spawn(cmd, [...lead, ...args], { stdio: 'inherit', env });
142
+ if (r.error) return { error: `${r.error.code || 'failed'}: ${r.error.message}`, bin: cmd };
143
+ return { code: r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1), bin: cmd };
144
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * /resume and `quox --resume`: pick a recent conversation, show its last turns as ordinary history, and
3
+ * continue it. Conversations come from GET /api/v1/conversations (title, updated_at, turn_count); the agent
4
+ * comes from the session index (GET /api/v1/sessions) when that answers, and names set with /rename from
5
+ * the local name store.
6
+ */
7
+ export const RESUME_TURNS = 20;
8
+
9
+ export function age(iso, now = Date.now()) {
10
+ const t = Date.parse(iso);
11
+ if (!Number.isFinite(t)) return '?';
12
+ const s = Math.max(0, Math.round((now - t) / 1000));
13
+ if (s < 60) return `${s}s ago`;
14
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
15
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
16
+ return `${Math.round(s / 86400)}d ago`;
17
+ }
18
+
19
+ /** -> [{ id, title, agent, updated_at, turns }] newest first. */
20
+ export function resumeRows(conversations = [], index = null, names = {}) {
21
+ const agentOf = new Map();
22
+ for (const r of index?.rows || []) if (r.kind === 'conversation' && r.id) agentOf.set(r.id, r.agent || null);
23
+ return conversations.map(c => ({
24
+ id: c.id,
25
+ title: names[c.id]?.name || c.title || '(untitled)',
26
+ agent: agentOf.get(c.id) ?? null,
27
+ updated_at: c.updated_at || null,
28
+ turns: c.turn_count ?? null
29
+ })).sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
30
+ }
31
+
32
+ export const pickerRow = (r, now = Date.now()) => ({ key: r.id.slice(0, 12), text: `${r.title} · ${r.agent || 'agent not recorded'} · ${age(r.updated_at, now)}${r.turns != null ? ` · ${r.turns} turn${r.turns === 1 ? '' : 's'}` : ''}` });
33
+
34
+ /** An id, an id prefix (4+), or a session name/title -> { row } | { ambiguous:[ids] } | { none:true }. */
35
+ export function matchConversation(rows, q) {
36
+ const s = String(q || '').trim();
37
+ if (!s) return { none: true };
38
+ const exact = rows.find(r => r.id === s);
39
+ if (exact) return { row: exact };
40
+ const lower = s.toLowerCase();
41
+ const byName = rows.filter(r => r.title.toLowerCase() === lower);
42
+ if (byName.length === 1) return { row: byName[0] };
43
+ const pre = s.length >= 4 ? rows.filter(r => r.id.startsWith(s)) : [];
44
+ if (pre.length === 1) return { row: pre[0] };
45
+ const hits = byName.length ? byName : pre;
46
+ return hits.length > 1 ? { ambiguous: hits.map(r => r.id) } : { none: true };
47
+ }
48
+
49
+ /** Stored turns -> history items (user prompts and answers, rendered as normal history). */
50
+ export function turnsToHistory(turns = [], max = RESUME_TURNS) {
51
+ const list = turns.filter(t => (t.role === 'user' || t.role === 'assistant') && String(t.content || '').trim());
52
+ const shown = list.slice(-max);
53
+ return {
54
+ items: shown.map(t => (t.role === 'user' ? { kind: 'user', text: String(t.content) } : { kind: 'text', text: String(t.content) })),
55
+ answers: shown.filter(t => t.role === 'assistant').map((t, i) => ({ id: `resumed_${i}`, text: String(t.content) })),
56
+ shown: shown.length,
57
+ total: list.length
58
+ };
59
+ }