quoxcli 0.2.0 → 0.3.1

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 (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -5,7 +5,7 @@
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { extname } from 'node:path';
7
7
  import yaml from 'js-yaml';
8
- import { formatOutput, formatDetail, printWarning, printInfo } from '../core/output.js';
8
+ import { formatOutput, formatDetail, printWarning, printInfo, getOutputFormat } from '../core/output.js';
9
9
  import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
10
10
  import { resolveOrgId, parseJSON } from './helpers.js';
11
11
  import { resolveEndpoint } from '../api/endpoints.js';
@@ -77,6 +77,33 @@ function printApplyDiff(workflowId, changes) {
77
77
  console.log('');
78
78
  }
79
79
 
80
+ // Read one engine workflow by id, or by its exact name when no id matches (proof cases and people know
81
+ // names; engine ids are wf-<ms>-<6>). An ambiguous name is refused with the matching ids, never guessed.
82
+ async function getEngineWorkflow(client, ref) {
83
+ const ep = resolveEndpoint('workflows.applyGet', { workflowId: ref });
84
+ try {
85
+ const res = await client.get(ep.path, { service: ep.service });
86
+ return res.data || res;
87
+ } catch (err) {
88
+ if (err.status !== 404) throw err;
89
+ const listEp = resolveEndpoint('workflows.applyList');
90
+ const matches = [];
91
+ for (let offset = 0, guard = 0; guard < 200; guard++) {
92
+ const data = await client.get(listEp.path, { service: listEp.service, query: { limit: 100, offset } });
93
+ const items = data.data || data.items || data.workflows || (Array.isArray(data) ? data : []);
94
+ for (const w of items) if (w && w.name === ref) matches.push(w);
95
+ offset += items.length;
96
+ const pg = data.pagination;
97
+ if (!pg || !items.length || !(offset < pg.total)) break;
98
+ }
99
+ if (matches.length > 1) throw new Error(`Workflow name "${ref}" matches ${matches.length} workflows (${matches.map((m) => m.id).join(', ')}); give the id instead.`);
100
+ if (!matches.length) throw err;
101
+ const byId = resolveEndpoint('workflows.applyGet', { workflowId: matches[0].id });
102
+ const res = await client.get(byId.path, { service: byId.service });
103
+ return res.data || res;
104
+ }
105
+ }
106
+
80
107
  export function registerWorkflowCommands(program, ctx) {
81
108
  const { client, auth } = ctx;
82
109
 
@@ -105,9 +132,17 @@ export function registerWorkflowCommands(program, ctx) {
105
132
 
106
133
  try {
107
134
  const ep = resolveEndpoint('workflows.applyList');
108
- const data = await client.get(ep.path, { service: ep.service });
109
- const items = data.data || data.items || data.workflows || (Array.isArray(data) ? data : []);
110
- for (const item of items) rows.push({ ...item, source: 'engine' });
135
+ // The engine pages (default 20, max 100). Walk every page so the list is the
136
+ // whole store, not the first page (121 workflows read as 20 before).
137
+ const PAGE = 100;
138
+ for (let offset = 0, guard = 0; guard < 200; guard++) {
139
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: PAGE, offset } });
140
+ const items = data.data || data.items || data.workflows || (Array.isArray(data) ? data : []);
141
+ for (const item of items) rows.push({ ...item, source: 'engine' });
142
+ const pg = data.pagination;
143
+ offset += items.length;
144
+ if (!pg || !items.length || !(offset < pg.total)) break;
145
+ }
111
146
  } catch (err) {
112
147
  printWarning(`Engine (quoxflow) workflow list failed: ${err.message}`);
113
148
  }
@@ -131,16 +166,14 @@ export function registerWorkflowCommands(program, ctx) {
131
166
  workflow
132
167
  .command('get')
133
168
  .description('Get workflow details')
134
- .argument('<workflowId>', 'workflow ID')
169
+ .argument('<workflowId>', 'workflow ID or exact name')
135
170
  .action(async (workflowId) => {
136
171
  auth.requireAuth();
137
172
  const orgId = resolveOrgId(auth, program);
138
173
 
139
174
  let data, source;
140
175
  try {
141
- const ep = resolveEndpoint('workflows.applyGet', { workflowId });
142
- const res = await client.get(ep.path, { service: ep.service });
143
- data = res.data || res;
176
+ data = await getEngineWorkflow(client, workflowId);
144
177
  source = 'engine';
145
178
  } catch (err) {
146
179
  if (err.status !== 404) throw err;
@@ -160,20 +193,85 @@ export function registerWorkflowCommands(program, ctx) {
160
193
  formatDetail({ ...data, source }, { format: program.opts().output, title: data.name });
161
194
  });
162
195
 
196
+ // ── workflow schedule <id> ──────────────────────────────────────────────────
197
+ // Is the schedule REALLY registered, and when does it fire next? Read from the live scheduler
198
+ // (GET /api/triggers/schedules), never inferred from the saved config (SITE-CLI 2026-10-17).
199
+ workflow
200
+ .command('schedule')
201
+ .description('Show whether a workflow\'s schedule trigger is registered with the live scheduler, and its next fire time')
202
+ .argument('<workflowId>', 'workflow ID or exact name')
203
+ .action(async (workflowId) => {
204
+ auth.requireAuth();
205
+ const wf = await getEngineWorkflow(client, workflowId);
206
+ const id = wf.id || workflowId;
207
+ const nodes = (wf.definition && wf.definition.nodes) || wf.nodes || [];
208
+ const schedNodes = nodes.filter((n) => n && n.type === 'trigger:schedule');
209
+
210
+ const liveEp = resolveEndpoint('triggers.schedules');
211
+ const liveRes = await client.get(liveEp.path, { service: liveEp.service });
212
+ const live = liveRes.data || liveRes;
213
+ const ownedByTasks = live.owned_by === 'tasks';
214
+ const rows = (live.schedules || []).filter((r) => r.workflowId === id);
215
+
216
+ const schedules = schedNodes.map((n) => {
217
+ const cfg = n.config || {};
218
+ const saved = cfg.expression || cfg.cron || null;
219
+ const reg = rows.find((r) => r.nodeId === n.id);
220
+ if (ownedByTasks) {
221
+ return { nodeId: n.id, registered: null, state: 'unknown', saved, expression: null, timezone: cfg.timezone || 'UTC', nextFireAt: null,
222
+ reason: 'Scheduling is owned by the tasks service on this instance; the workflow engine cannot see registrations.' };
223
+ }
224
+ if (!reg) {
225
+ return { nodeId: n.id, registered: false, state: 'not registered', saved, expression: null, timezone: cfg.timezone || 'UTC', nextFireAt: null,
226
+ reason: wf.active === false ? 'The workflow is not active, so its schedule is not registered. Activate it to register.' : 'Not registered with the scheduler.' };
227
+ }
228
+ return { nodeId: n.id, registered: true, state: reg.active ? 'active' : 'paused', saved, expression: reg.expression, timezone: reg.timezone || 'UTC', nextFireAt: reg.nextFireAt || null };
229
+ });
230
+
231
+ const result = { workflowId: id, name: wf.name, active: wf.active, ...(live.owned_by ? { owned_by: live.owned_by } : {}), schedules,
232
+ ...(schedNodes.length ? {} : { note: 'This workflow has no schedule trigger.' }) };
233
+ if (getOutputFormat() === 'json' || program.opts().output === 'json') { formatDetail(result, { format: 'json' }); return; }
234
+ if (!schedNodes.length) { printInfo(`${id}: ${result.note}`); return; }
235
+ if (ownedByTasks) printInfo(live.note || 'Schedules are managed by the tasks service.');
236
+ formatOutput({
237
+ columns: ['nodeId', 'registeredLabel', 'state', 'expression', 'timezone', 'nextFireAt', 'reason'],
238
+ headers: ['Node', 'Registered', 'State', 'Cron (live)', 'Timezone', 'Next Fire', 'Note'],
239
+ rows: schedules.map((r) => ({ ...r, registeredLabel: r.registered === true ? 'yes' : r.registered === false ? 'no' : 'unknown', expression: r.expression || '-', nextFireAt: r.nextFireAt || '-', reason: r.reason || '' })),
240
+ }, { format: program.opts().output });
241
+ });
242
+
163
243
  workflow
164
244
  .command('create')
165
- .description('Create a new workflow')
245
+ .description('Create a new workflow in the workflow engine (quoxflow). --steps takes a JSON array of nodes (chained in order) or an object with nodes and optional edges')
166
246
  .requiredOption('--name <name>', 'workflow name')
167
247
  .option('--description <desc>', 'description')
168
- .option('--steps <json>', 'workflow steps as JSON')
248
+ .option('--steps <json>', 'nodes as a JSON array (chained sequentially), or {"nodes":[...],"edges":[...]}')
169
249
  .action(async (options) => {
170
250
  auth.requireAuth();
171
- const orgId = resolveOrgId(auth, program);
172
- const body = { name: options.name };
251
+ // #1190: the engine is the one workflow store. Same id shape as `workflow apply`.
252
+ const id = `wf-${Date.now()}-${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}`;
253
+ let nodes = [];
254
+ let edges = [];
255
+ if (options.steps) {
256
+ const steps = parseJSON(options.steps, '--steps');
257
+ if (Array.isArray(steps)) {
258
+ nodes = steps;
259
+ edges = steps.slice(1).map((n, i) => ({ id: `e${i + 1}`, source: steps[i].id ?? `n${i}`, target: n.id ?? `n${i + 1}` }));
260
+ } else if (steps && typeof steps === 'object' && Array.isArray(steps.nodes)) {
261
+ nodes = steps.nodes;
262
+ edges = Array.isArray(steps.edges) ? steps.edges : [];
263
+ } else {
264
+ throw new Error('--steps must be a JSON array of nodes, or an object with a "nodes" array (and optional "edges").');
265
+ }
266
+ }
267
+ const body = { id, name: options.name, nodes, edges };
173
268
  if (options.description) body.description = options.description;
174
- if (options.steps) body.steps = parseJSON(options.steps, '--steps');
175
- await client.post(resolveEndpoint('workflows.create', { orgId }).path, { service: resolveEndpoint('workflows.create', { orgId }).service, body });
176
- printEvent(events.created('quox', 'workflow', options.name));
269
+ const ep = resolveEndpoint('workflows.applyCreate');
270
+ const res = await client.post(ep.path, { service: ep.service, body });
271
+ const created = res?.data || res;
272
+ printEvent(events.created('quox', 'workflow', `${options.name} (quoxflow) id:${created.id || id}`));
273
+ // #1000: --json gets the saved workflow (the event line is suppressed in json mode, which left nothing)
274
+ if (getOutputFormat() === 'json') formatDetail(created, { format: 'json' });
177
275
  });
178
276
 
179
277
  workflow
@@ -190,8 +288,9 @@ export function registerWorkflowCommands(program, ctx) {
190
288
  if (options.name) body.name = options.name;
191
289
  if (options.description) body.description = options.description;
192
290
  if (options.status) body.status = options.status;
193
- await client.patch(resolveEndpoint('workflows.update', { orgId, workflowId }).path, { service: resolveEndpoint('workflows.update', { orgId, workflowId }).service, body });
291
+ const data = await client.patch(resolveEndpoint('workflows.update', { orgId, workflowId }).path, { service: resolveEndpoint('workflows.update', { orgId, workflowId }).service, body });
194
292
  printEvent(events.updated('quox', 'workflow', workflowId));
293
+ if (getOutputFormat() === 'json') formatDetail(data?.workflow || data, { format: 'json' });
195
294
  });
196
295
 
197
296
  workflow
@@ -201,8 +300,17 @@ export function registerWorkflowCommands(program, ctx) {
201
300
  .action(async (workflowId) => {
202
301
  auth.requireAuth();
203
302
  const orgId = resolveOrgId(auth, program);
204
- await client.delete(resolveEndpoint('workflows.delete', { orgId, workflowId }).path, { service: resolveEndpoint('workflows.delete', { orgId, workflowId }).service });
205
- printEvent(glyphLine('quox', 'workflow deleted', { result: workflowId, status: 'ok' }));
303
+ // #997: engine (quoxflow) first, the auth store only on an engine 404 (same order as `workflow get`)
304
+ let source = 'engine';
305
+ try {
306
+ const ep = resolveEndpoint('workflows.applyDelete', { workflowId });
307
+ await client.delete(ep.path, { service: ep.service });
308
+ } catch (err) {
309
+ if (err.status !== 404) throw err;
310
+ source = 'auth';
311
+ await client.delete(resolveEndpoint('workflows.delete', { orgId, workflowId }).path, { service: resolveEndpoint('workflows.delete', { orgId, workflowId }).service });
312
+ }
313
+ printEvent(glyphLine('quox', 'workflow deleted', { result: `${workflowId} (${source})`, status: 'ok' }));
206
314
  });
207
315
 
208
316
  workflow
@@ -232,7 +340,8 @@ export function registerWorkflowCommands(program, ctx) {
232
340
  }
233
341
  if (engineExists) {
234
342
  const exEp = resolveEndpoint('workflows.applyExecute', { workflowId });
235
- const run = await client.post(exEp.path, { service: exEp.service, body: body.context || {} });
343
+ // #1029: the engine reads triggerData (a bare body lost --input), and async returns the run id at once
344
+ const run = await client.post(exEp.path, { service: exEp.service, body: { triggerData: body.context || {}, async: true } });
236
345
  const runId = run.executionId || run.execution_id || run.runId || run.run_id || run.id || run.execution?.id || 'ok';
237
346
  const st = run.status || run.execution?.status;
238
347
  printEvent(glyphLine('quox', 'workflow run', { result: `${workflowId} (quoxflow) -> run ${runId}${st ? ` [${st}]` : ''}`, status: 'ok' }));
@@ -299,15 +408,27 @@ export function registerWorkflowCommands(program, ctx) {
299
408
  .action(async (workflowId) => {
300
409
  auth.requireAuth();
301
410
  const orgId = resolveOrgId(auth, program);
302
- // No dedicated GET .../steps route exists — steps are a field on the
303
- // workflow object itself.
304
- const data = await client.get(resolveEndpoint('workflows.get', { orgId, workflowId }).path, { service: resolveEndpoint('workflows.get', { orgId, workflowId }).service });
305
- const workflow = data.workflow || data;
411
+ // No dedicated GET .../steps route exists: steps are a field on the
412
+ // workflow object itself. quox#928: look in the same stores `workflow get`
413
+ // does, engine (quoxflow) first, so engine workflows (wf-obj-*) resolve too.
414
+ let rows;
415
+ try {
416
+ const ep = resolveEndpoint('workflows.applyGet', { workflowId });
417
+ const res = await client.get(ep.path, { service: ep.service });
418
+ const wf = res.data || res;
419
+ const nodes = wf?.definition?.nodes || wf?.nodes;
420
+ if (!Array.isArray(nodes)) throw Object.assign(new Error('not an engine workflow'), { status: 404 });
421
+ rows = nodes.map((n, idx) => ({ idx, label: n.name || n.id, step_type: n.type, ref_id: n.config?.tool || n.config?.toolName || '-' }));
422
+ } catch (err) {
423
+ if (err.status !== 404) throw err;
424
+ const data = await client.get(resolveEndpoint('workflows.get', { orgId, workflowId }).path, { service: resolveEndpoint('workflows.get', { orgId, workflowId }).service });
425
+ rows = (data.workflow || data).steps || [];
426
+ }
306
427
  formatOutput({
307
428
  // fleet #392: workflow_steps columns are idx/label/step_type/ref_id.
308
429
  columns: ['idx', 'label', 'step_type', 'ref_id'],
309
430
  headers: ['#', 'Name', 'Type', 'Tool'],
310
- rows: workflow.steps || [],
431
+ rows,
311
432
  }, { format: program.opts().output });
312
433
  });
313
434
 
@@ -319,10 +440,35 @@ export function registerWorkflowCommands(program, ctx) {
319
440
  .action(async (workflowId, options) => {
320
441
  auth.requireAuth();
321
442
  const orgId = resolveOrgId(auth, program);
322
- await client.put(resolveEndpoint('workflows.steps', { orgId, workflowId }).path, {
323
- service: resolveEndpoint('workflows.steps', { orgId, workflowId }).service,
324
- body: { steps: parseJSON(options.steps, '--steps') },
325
- });
443
+ const stepList = parseJSON(options.steps, '--steps');
444
+ try {
445
+ await client.put(resolveEndpoint('workflows.steps', { orgId, workflowId }).path, {
446
+ service: resolveEndpoint('workflows.steps', { orgId, workflowId }).service,
447
+ body: { steps: stepList },
448
+ });
449
+ } catch (err) {
450
+ if (err.status !== 404) throw err;
451
+ // #997: `steps list` reads engine workflows too, so an engine id 404'd here with a bare 'Workflow not found'.
452
+ // An engine workflow is a node/edge graph, not a step list: refuse it by name, like `workflow publish`.
453
+ let engineExists = false;
454
+ try {
455
+ const getEp = resolveEndpoint('workflows.applyGet', { workflowId });
456
+ await client.get(getEp.path, { service: getEp.service });
457
+ engineExists = true;
458
+ } catch (engineErr) {
459
+ if (engineErr.status !== 404) throw engineErr;
460
+ }
461
+ if (engineExists) {
462
+ throw new Error(
463
+ `Workflow "${workflowId}" lives in the engine (quoxflow) store, where it is a graph of nodes and edges, `
464
+ + 'not the step list `workflow steps set` replaces. Edit it with `quox workflow apply -f <file>` '
465
+ + `(start from \`quox -o json workflow get ${workflowId}\`).`
466
+ );
467
+ }
468
+ throw new Error(
469
+ `Workflow "${workflowId}" was not found in either the engine (quoxflow) or auth-service workflow store.`
470
+ );
471
+ }
326
472
  printEvent(events.updated('quox', 'workflow steps', workflowId));
327
473
  });
328
474
 
@@ -390,7 +536,10 @@ export function registerWorkflowCommands(program, ctx) {
390
536
  }
391
537
 
392
538
  const createEp = resolveEndpoint('workflows.applyCreate');
393
- const data = await client.post(createEp.path, { service: createEp.service, body: wf });
539
+ // #1000: quoxflow's WorkflowSchema requires an id, so "no id = create" 400'd. Mint one in the
540
+ // engine's own wf-<ms>-<6> shape; the created id is printed, so the file can be given it to update later.
541
+ const body = wf.id ? wf : { ...wf, id: `wf-${Date.now()}-${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}` };
542
+ const data = await client.post(createEp.path, { service: createEp.service, body });
394
543
  const created = data.data || data;
395
544
  printEvent(events.created('quox', 'workflow', `${created.name || wf.name || created.id} (quoxflow) id:${created.id}`));
396
545
  formatDetail(created, { format: program.opts().output });
@@ -421,10 +570,18 @@ export function registerWorkflowCommands(program, ctx) {
421
570
  try {
422
571
  await client.get(getEp.path, { service: getEp.service });
423
572
  } catch (err) {
424
- if (err.status === 404) {
425
- throw new Error(`Workflow "${workflowId}" was not found on the read path \`quox workflow get\` uses. Use -f <file> to validate a local file instead.`);
573
+ if (err.status !== 404) throw err;
574
+ // `workflow get` also reads the engine (quoxflow) store; engine-only workflows
575
+ // (most of them) are not in the auth store, so fall back the same way.
576
+ try {
577
+ const engEp = resolveEndpoint('workflows.applyGet', { workflowId });
578
+ await client.get(engEp.path, { service: engEp.service });
579
+ } catch (err2) {
580
+ if (err2.status === 404) {
581
+ throw new Error(`Workflow "${workflowId}" was not found on the read path \`quox workflow get\` uses. Use -f <file> to validate a local file instead.`);
582
+ }
583
+ throw err2;
426
584
  }
427
- throw err;
428
585
  }
429
586
  targetId = workflowId;
430
587
  } else {
package/lib/commands/x.js CHANGED
@@ -340,10 +340,19 @@ export function registerXCommands(program, ctx) {
340
340
  const orgId = resolveOrgId(auth, program);
341
341
  const ep = resolveEndpoint('v1.tools.execute');
342
342
 
343
- const budgetData = await client.post(ep.path, {
344
- service: ep.service,
345
- body: { tool_name: 'x_budget_status', input: {}, org_id: orgId },
346
- });
343
+ // Budget over the org-scoped REST route, not the x_budget_status tool: that tool left the
344
+ // Default hat (cap room), so the tool path answered "not granted" for every caller.
345
+ let budgetData;
346
+ try {
347
+ const actEp = resolveEndpoint('x.activity');
348
+ const act = await client.get(actEp.path, { service: actEp.service, query: { org_id: orgId } });
349
+ const b = act && act.budget;
350
+ budgetData = b && typeof b === 'object'
351
+ ? { success: true, daily_used: b.dailyUsed, daily_limit: b.dailyLimit, monthly_used: b.monthlyUsed, monthly_limit: b.monthlyLimit, org_id: act.org_id || orgId }
352
+ : { success: false, error: (act && act.error) || 'budget missing from /x/activity response' };
353
+ } catch (err) {
354
+ budgetData = { success: false, error: err.message };
355
+ }
347
356
 
348
357
  let credResult = null;
349
358
  let credError = null;
@@ -83,14 +83,14 @@ export function hasExplicitProfile(argv, env = process.env) {
83
83
  * session's sole attach (implicit default), else the profile identity.
84
84
  * @returns {{name: string|null, source: 'flag'|'env'|'self'|'profile'|'default'|null}}
85
85
  */
86
- export async function resolveActing({ argv = process.argv.slice(2), env = process.env, resolveAttach } = {}) {
86
+ export async function resolveActing({ argv = process.argv.slice(2), env = process.env, resolveAttach, explicitProfile = false } = {}) {
87
87
  const explicit = actingAsName(env);
88
88
  if (explicit) {
89
89
  const source = env.QUOX_AS_FLAG === '1' ? 'flag' : 'env';
90
90
  if (explicit.toLowerCase() === 'self') return { name: null, source: 'self', via: source };
91
91
  return { name: explicit, source };
92
92
  }
93
- if (hasExplicitProfile(argv, env)) return { name: null, source: 'profile' };
93
+ if (explicitProfile || hasExplicitProfile(argv, env)) return { name: null, source: 'profile' };
94
94
  if (isDefaultExempt(argv)) return { name: null, source: null };
95
95
  const mine = resolveAttach ? resolveAttach() : (await import('../commands/agentAttach.js')).resolveMyAttach();
96
96
  if (mine?.agentId) return { name: mine.agentId, source: 'default' };
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `quox active` / TUI `/active` (QUOXON-1208, owner: "summary of who is doing what"). ONE simplified list over the
3
+ * org session index (GET /api/v1/sessions: tmux sessions, held slots, residents, work, working chats), one line per
4
+ * active worker, newest activity first, grouped Working / Waiting on you / Idle (+ Failing when there is any).
5
+ * Real data only: a worker's "doing" text is what the index carries (its state reason, slot, agent), never invented;
6
+ * a source the index could not read is named in one line. Pure: activeLines(index, opts) -> [{ text, tone }].
7
+ */
8
+ import { formatAge } from './claudeLs.js';
9
+
10
+ export const ACTIVE_KINDS = ['sessions', 'slots', 'agents'];
11
+ export const PER_GROUP = 10;
12
+ export const ASLEEP_AFTER_S = 6 * 3600; // a resting worker with no activity for this long reads as asleep
13
+
14
+ const clip = (v, n) => { const a = [...String(v ?? '')]; return a.length > n ? a.slice(0, n - 1).join('') + '…' : String(v ?? ''); };
15
+ const t = (r) => { const x = Date.parse(r.last_active); return Number.isFinite(x) ? x : -Infinity; };
16
+
17
+ /** Which filter(s) a row belongs to. sessions = terminal sessions, slots = held slots, agents = residents or agent-attached rows. */
18
+ export function rowKinds(r) {
19
+ const k = new Set();
20
+ if (r.kind === 'tmux') k.add('sessions');
21
+ if (r.kind === 'slot') k.add('slots');
22
+ if (r.kind === 'resident' || r.agent) k.add('agents');
23
+ return k;
24
+ }
25
+
26
+ function doing(r) {
27
+ const bits = [];
28
+ if (r.state_reason) bits.push(String(r.state_reason).replace(/\s+/g, ' ').trim());
29
+ if (r.kind === 'tmux' && r.backend) bits.push(`${r.backend} session`);
30
+ else if (r.kind === 'slot') bits.push('holding a slot');
31
+ else if (r.kind === 'conversation') bits.push('chat');
32
+ else if (r.kind) bits.push(r.kind);
33
+ if (r.slot && r.kind !== 'slot') bits.push(`slot ${r.slot}`);
34
+ if (r.agent) bits.push(`agent ${r.agent}`);
35
+ return bits.length ? bits.join(', ') : 'no activity detail recorded';
36
+ }
37
+
38
+ function stateWord(r, ageS) {
39
+ if (r.state === 'working') return 'working';
40
+ if (r.state === 'waiting') return 'waiting on you';
41
+ if (r.state === 'failing') return 'failing';
42
+ if (ageS != null && ageS >= ASLEEP_AFTER_S) return 'asleep';
43
+ return ageS != null ? `idle ${formatAge(ageS)}` : 'idle';
44
+ }
45
+
46
+ /** -> { rows: [{ name, doing, state, group, since, ts, kinds }], groups: {working, waiting, failing, idle}, down: [..] } */
47
+ export function prepareActive(index, { filter = null, now = Date.now() } = {}) {
48
+ const rows = (index?.rows || (index?.groups || []).flatMap((g) => g.rows) || [])
49
+ .filter((r) => r.kind !== 'conversation' || r.state === 'working' || r.state === 'waiting')
50
+ .filter((r) => !filter || rowKinds(r).has(filter))
51
+ .sort((a, b) => t(b) - t(a))
52
+ .map((r) => {
53
+ const ts = t(r);
54
+ const ageS = Number.isFinite(ts) ? Math.max(0, (now - ts) / 1000) : null;
55
+ const group = r.state === 'working' ? 'working' : r.state === 'waiting' ? 'waiting' : r.state === 'failing' ? 'failing' : 'idle';
56
+ const name = r.kind === 'slot' ? (r.holder_session || r.name || '(unnamed slot)') : (r.name || '(unnamed)');
57
+ return { name, doing: doing(r), state: stateWord(r, ageS), group, since: ageS == null ? 'no activity recorded' : `${formatAge(ageS)} ago`, ts };
58
+ });
59
+ const groups = { working: [], waiting: [], failing: [], idle: [] };
60
+ for (const r of rows) groups[r.group].push(r);
61
+ const down = (index?.sources || []).filter((s) => s.state !== 'connected');
62
+ return { rows, groups, down };
63
+ }
64
+
65
+ const TITLES = { working: 'Working', waiting: 'Waiting on you', failing: 'Failing', idle: 'Idle' };
66
+ const TONES = { working: 'ok', waiting: 'warn', failing: 'warn', idle: 'dim' };
67
+
68
+ export function activeLines(index, { filter = null, now = Date.now(), all = false } = {}) {
69
+ const p = prepareActive(index, { filter, now });
70
+ const out = [{ text: `Active${filter ? ` ${filter}` : ''}: ${p.rows.length} · ${p.groups.working.length} working · ${p.groups.waiting.length} waiting on you · ${p.groups.idle.length} idle${p.groups.failing.length ? ` · ${p.groups.failing.length} failing` : ''}`, tone: 'accent' }];
71
+ if (!p.rows.length) out.push({ text: p.down.some((s) => s.state !== 'unavailable') ? 'Nothing to show (see the sources below).' : `No active ${filter || 'workers'} in this org.`, tone: 'dim' });
72
+ for (const g of ['working', 'waiting', 'failing', 'idle']) {
73
+ const rs = p.groups[g];
74
+ if (!rs.length) continue;
75
+ out.push({ text: `${TITLES[g]} (${rs.length})`, tone: 'accent' });
76
+ for (const r of all ? rs : rs.slice(0, PER_GROUP)) out.push({ text: ` ${clip(r.name, 28).padEnd(28)} ${clip(r.doing, 44).padEnd(44)} ${r.state}, ${r.since}`, tone: TONES[g] });
77
+ if (!all && rs.length > PER_GROUP) out.push({ text: ` +${rs.length - PER_GROUP} more`, tone: 'dim' });
78
+ }
79
+ // 'unavailable' is a source the server declares it does not list yet (residents): an expected absence, not a failed read.
80
+ // Only a real fault (error, timeout) is "could not read". Residents that hold a slot already show up as rows above.
81
+ const failed = p.down.filter((s) => s.state !== 'unavailable');
82
+ const absent = p.down.filter((s) => s.state === 'unavailable');
83
+ if (failed.length) out.push({ text: `could not read: ${failed.map((s) => `${s.id} (${s.state}${s.note ? `: ${s.note}` : ''})`).join(' · ')}`, tone: 'warn' });
84
+ if (absent.length) out.push({ text: `not listed separately yet: ${absent.map((s) => s.id).join(' · ')} (shown above when they hold a slot)`, tone: 'dim' });
85
+ return out;
86
+ }
87
+
88
+ // ---- TUI /active: one clean row per session -------------------------------------------------------------
89
+ const GLYPH = { working: '◐', waiting: '⚠', failing: '✖', idle: '·' };
90
+ const RANK = { waiting: 0, failing: 1, working: 2, idle: 3 };
91
+ const TUI_TONES = { working: undefined, waiting: undefined, failing: undefined, idle: undefined }; // the row's own glyph carries status; a tone would prefix a second glyph on every wrapped line
92
+ const GROUP_TITLES = { waiting: 'Waiting on you', failing: 'Failing', working: 'Working', idle: 'Idle' };
93
+
94
+ /** "resident:hyp-01" -> { name: 'hyp-01', tag: 'resident' }: the machine prefix becomes a readable tag. */
95
+ function readableName(raw) {
96
+ const m = String(raw).match(/^(resident|agent|slot|tmux):(.+)$/i);
97
+ return m ? { name: m[2], tag: m[1].toLowerCase() } : { name: String(raw), tag: null };
98
+ }
99
+ const humanReason = (reason) => String(reason || '').replace(/\s+/g, ' ').trim().replace(/^unread slot messages?$/i, 'unread messages');
100
+
101
+ /** Word-wrap `rest` after `first`; continuation lines get a 4-space hanging indent. Never clips, never exceeds `width`. */
102
+ function wrapHanging(first, rest, width, sep = ' ') {
103
+ const out = [];
104
+ let cur = first, fresh = true; // fresh: only the label is on this line so far
105
+ for (const w of String(rest).split(/\s+/).filter(Boolean)) {
106
+ const joined = fresh ? `${cur}${sep}${w}` : `${cur} ${w}`;
107
+ if ([...joined].length <= width) { cur = joined; fresh = false; continue; }
108
+ out.push(cur); cur = ` ${w}`; fresh = false;
109
+ }
110
+ out.push(cur);
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * The window's /active: one row per session (deduped by readable name; the row that needs you wins), shaped
116
+ * ` <glyph> <name> <what it needs> · <age>`, word-wrapped under a hanging indent at `cols`, never clipped, grouped
117
+ * Waiting on you / Failing / Working / Idle. The `quox active` CLI keeps activeLines(); this is the TUI-only view.
118
+ */
119
+ export function activeRows(index, { now = Date.now(), cols = 80, filter = null, unread = null } = {}) {
120
+ const raw = (index?.rows || (index?.groups || []).flatMap((g) => g.rows) || [])
121
+ .filter((r) => r.kind !== 'conversation' || r.state === 'working' || r.state === 'waiting')
122
+ .filter((r) => !filter || rowKinds(r).has(filter));
123
+ const byName = new Map();
124
+ for (const r of raw) {
125
+ const base = r.kind === 'slot' ? (r.holder_session || r.name || '(unnamed slot)') : (r.name || '(unnamed)');
126
+ const { name, tag } = readableName(base);
127
+ const ts = t(r), ageS = Number.isFinite(ts) ? Math.max(0, (now - ts) / 1000) : null;
128
+ const group = r.state === 'working' ? 'working' : r.state === 'waiting' ? 'waiting' : r.state === 'failing' ? 'failing' : 'idle';
129
+ const needs = [];
130
+ let why = humanReason(r.state_reason);
131
+ const nUnread = unread && (unread.get?.(r.kind === 'slot' ? (r.name || '') : (r.slot || '')) ?? null); // optional per-slot count from the slots list
132
+ if (why === 'unread messages' && nUnread > 0) why = `${nUnread} unread message${nUnread === 1 ? '' : 's'}`;
133
+ if (why) needs.push(why);
134
+ else needs.push(group === 'idle' ? (ageS != null && ageS >= ASLEEP_AFTER_S ? 'asleep' : 'idle') : group);
135
+ if (r.kind === 'slot') needs.push('holding a slot'); else if (r.slot) needs.push(`holding slot ${r.slot}`);
136
+ if (r.agent) needs.push(`agent ${r.agent}`);
137
+ const cand = { name, tag: tag || (r.kind === 'resident' ? 'resident' : null), group, needs, ageS, ts };
138
+ const key = name.toLowerCase(), old = byName.get(key);
139
+ if (!old) { byName.set(key, cand); continue; }
140
+ const better = RANK[cand.group] < RANK[old.group] || (RANK[cand.group] === RANK[old.group] && cand.ts > old.ts);
141
+ const win = better ? cand : old, lose = better ? old : cand;
142
+ const hasUnread = win.needs.some((n) => /unread/.test(n));
143
+ win.needs = [...new Set([...win.needs, ...lose.needs.filter((n) => !/^(working|idle|asleep|failing)$/.test(n) && !(hasUnread && /unread/.test(n)) && !(/^holding a slot$/.test(n) && win.needs.some((x) => /^holding/.test(x))))])];
144
+ win.tag = win.tag || lose.tag;
145
+ byName.set(key, win);
146
+ }
147
+ const rows = [...byName.values()].sort((a, b) => RANK[a.group] - RANK[b.group] || b.ts - a.ts);
148
+ const n = (g) => rows.filter((r) => r.group === g).length;
149
+ const width = Math.max(30, cols - 4);
150
+ const hdr = `${rows.length} · ${n('waiting')} need you · ${n('working')} working · ${n('idle')} idle${n('failing') ? ` · ${n('failing')} failing` : ''}`;
151
+ const out = wrapHanging(`Active${filter ? ` ${filter}` : ''}:`, hdr, width, ' ').map((text) => ({ text, tone: 'accent' }));
152
+ if (!rows.length) out.push({ text: `No active ${filter || 'workers'} in this org.`, tone: undefined });
153
+ let last = null;
154
+ for (const r of rows) {
155
+ if (r.group !== last) { last = r.group; out.push({ text: `${GROUP_TITLES[r.group]} (${n(r.group)})`, tone: 'accent' }); }
156
+ const label = `${r.name}${r.tag ? ` (${r.tag})` : ''}`;
157
+ const what = `${r.needs.join(' · ')}${r.ageS == null ? '' : ` · ${formatAge(r.ageS)} ago`}`;
158
+ for (const text of wrapHanging(` ${GLYPH[r.group]} ${label}`, what, width)) out.push({ text, tone: TUI_TONES[r.group] });
159
+ }
160
+ // a source that could not be read is never an empty org: name it ('unavailable' is an expected absence, not a fault)
161
+ const failed = (index?.sources || []).filter((x) => x.state !== 'connected' && x.state !== 'unavailable');
162
+ if (failed.length) for (const text of wrapHanging('could not read:', failed.map((x) => `${x.id} (${x.state}${x.note ? `: ${x.note}` : ''})`).join(' · '), width, ' ')) out.push({ text, tone: 'warn' });
163
+ return out;
164
+ }
165
+
166
+ /** Bare `quox active [sessions|slots|agents] [--json] [--all]`. A failed fetch prints its status and exits 1 (never an empty list). */
167
+ export async function runActive({ filter = null, json = false, all = false, lines = false, cols = null } = {}, over = {}) {
168
+ const d = { write: (s) => process.stdout.write(s), now: () => Date.now(), ...over };
169
+ if (filter && !ACTIVE_KINDS.includes(filter)) { d.write(`quox active: unknown filter "${filter}" (sessions, slots or agents)\n`); return 1; }
170
+ try {
171
+ const idx = await d.fetchIndex();
172
+ if (json) {
173
+ const p = prepareActive(idx, { filter, now: d.now() });
174
+ d.write(JSON.stringify({ filter, counts: Object.fromEntries(Object.entries(p.groups).map(([k, v]) => [k, v.length])), workers: p.rows.map(({ ts, ...r }) => r), sources_down: p.down }, null, 2) + '\n');
175
+ } else if (lines) d.write(activeLines(idx, { filter, now: d.now(), all }).map((l) => l.text).join('\n') + '\n');
176
+ else {
177
+ let unread = null;
178
+ if (d.fetchSlots) { try { unread = new Map((await d.fetchSlots() || []).map((x) => [x.name, x.unread_count || 0])); } catch { unread = null; } } // the count is a nicety: a failed slots read never hides the list
179
+ d.write(activeRows(idx, { filter, now: d.now(), cols: cols || process.stdout.columns || 100, unread }).map((l) => l.text).join('\n') + '\n');
180
+ }
181
+ return 0;
182
+ } catch (e) {
183
+ d.write(`quox active: ${e && e.status ? `HTTP ${e.status}: ` : ''}${(e && e.message) || 'request failed'}\n`);
184
+ return 1;
185
+ }
186
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * ASIDE: a read-only side question. `quox aside "<question>"` and the TUI /aside share this.
3
+ *
4
+ * It goes to the collector's no-tools invoke (POST /api/internal/agents/invoke with no_tools: true): the model
5
+ * gets no tools at all, and no conversation or session id is sent, so the main conversation's history is never
6
+ * touched. The TUI passes the last few exchanges as plain read-only context so "what did it just say about X?"
7
+ * works; nothing is written back.
8
+ */
9
+ import { resolveEndpoint } from '../api/endpoints.js';
10
+
11
+ export const ASIDE_MAX_CONTEXT = 6000;
12
+
13
+ const ASIDE_NOTE = 'This is a read-only side question. You have no tools: answer from what you know and from the conversation excerpt below. Do not offer to run anything. Be brief.';
14
+
15
+ /** Recent [{ role: 'user'|'assistant', text }] -> one plain excerpt, newest kept when it is too long. */
16
+ export function asideExcerpt(turns, max = ASIDE_MAX_CONTEXT) {
17
+ const lines = (Array.isArray(turns) ? turns : [])
18
+ .filter(t => t && typeof t.text === 'string' && t.text.trim())
19
+ .map(t => `${t.role === 'assistant' ? 'Quox' : 'User'}: ${t.text.trim()}`);
20
+ let out = '';
21
+ for (let i = lines.length - 1; i >= 0; i--) {
22
+ const next = out ? `${lines[i]}\n\n${out}` : lines[i];
23
+ if (next.length > max) { if (!out) out = lines[i].slice(-max); break; }
24
+ out = next;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** The request body: never a session or conversation id, always no_tools. */
30
+ export function asideBody({ question, turns = [], agent = 'quox', maxTokens = 1024 }) {
31
+ const q = String(question ?? '').trim();
32
+ const excerpt = asideExcerpt(turns);
33
+ return {
34
+ agent_id: agent || 'quox',
35
+ task: q,
36
+ no_tools: true,
37
+ max_tokens: maxTokens,
38
+ context: excerpt ? `${ASIDE_NOTE}\n\nConversation so far (read only):\n${excerpt}` : ASIDE_NOTE,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * -> { text, model, provider, tools } . Throws the client error (with .status) on a non-2xx, and an error
44
+ * with code 'aside_used_tools' if the server reports any tool use (it must not happen; we say so if it does).
45
+ */
46
+ export async function askAside(client, { question, turns, agent, timeoutMs = 120000 }) {
47
+ if (!String(question ?? '').trim()) { const e = new Error('Ask a question: aside <question>'); e.code = 'usage'; throw e; }
48
+ const ep = resolveEndpoint('agents.invoke.notools');
49
+ const r = await client.post(ep.path, { service: ep.service, body: asideBody({ question, turns, agent }), retries: 0, timeout: timeoutMs });
50
+ if (r && r.success === false) { const e = new Error(r.error || 'the server could not answer'); e.status = 502; throw e; }
51
+ const tools = Array.isArray(r?.tools_used) ? r.tools_used : [];
52
+ if (tools.length) { const e = new Error(`the server reported tool use (${tools.join(', ')}) on a no-tools aside`); e.code = 'aside_used_tools'; throw e; }
53
+ return { text: String(r?.result ?? '').trim(), model: r?.usage?.model || null, provider: r?.provider || r?.usage?.provider || null, tools };
54
+ }