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
@@ -8,6 +8,8 @@ import { homedir } from 'node:os';
8
8
  import { join, dirname } from 'node:path';
9
9
 
10
10
  export const INVITE = 'Ⓠ /rename this session · /slot <name> for an address · /attach <agent>';
11
+ /** The new-session invite; the /slot segment only where slots are licensed (Z2H-R5 C). */
12
+ export const inviteText = ({ slots = true } = {}) => (slots ? INVITE : 'Ⓠ /rename this session · /attach <agent>');
11
13
  export const MAX_NAME = 40;
12
14
 
13
15
  export const sessionsPath = () => join(homedir(), '.config', 'quox', 'sessions.json');
@@ -26,10 +28,10 @@ export function loadNames(path = sessionsPath()) {
26
28
  }
27
29
 
28
30
  /** -> { ok:true } | { ok:false, error }. Atomic write; never throws. */
29
- export function saveName(id, name, path = sessionsPath()) {
31
+ export function saveName(id, name, path = sessionsPath(), meta = {}) {
30
32
  try {
31
33
  const all = loadNames(path);
32
- all[id] = { name, updated_at: new Date().toISOString() };
34
+ all[id] = { name, ...(meta.profile ? { profile: meta.profile } : {}), ...(meta.org ? { org: meta.org } : {}), updated_at: new Date().toISOString() };
33
35
  mkdirSync(dirname(path), { recursive: true });
34
36
  const tmp = `${path}.${process.pid}.tmp`;
35
37
  writeFileSync(tmp, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
@@ -38,6 +40,22 @@ export function saveName(id, name, path = sessionsPath()) {
38
40
  } catch (e) { return { ok: false, error: e?.message || 'failed' }; }
39
41
  }
40
42
 
43
+ /**
44
+ * Resolve a saved name to a conversation. Entries saved under this profile (or legacy entries with no profile)
45
+ * win, newest updated_at first. Only other-profile entries left: one profile -> newest; several -> ambiguous.
46
+ * -> { id, entry } | { ambiguous: [ids] } | null
47
+ */
48
+ export function pickNamed(names, want, profile) {
49
+ const w = String(want).trim().toLowerCase();
50
+ const all = Object.entries(names || {}).filter(([, v]) => v?.name && String(v.name).toLowerCase() === w);
51
+ if (!all.length) return null;
52
+ const newest = (xs) => xs.slice().sort((a, b) => String(b[1].updated_at || '').localeCompare(String(a[1].updated_at || '')))[0];
53
+ const mine = all.filter(([, v]) => !v.profile || v.profile === profile);
54
+ if (mine.length) { const [id, entry] = newest(mine); return { id, entry }; }
55
+ if (new Set(all.map(([, v]) => v.profile)).size > 1) return { ambiguous: all.map(([id]) => id) };
56
+ const [id, entry] = newest(all); return { id, entry };
57
+ }
58
+
41
59
  const lev = (a, b) => {
42
60
  const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
43
61
  for (let j = 1; j <= b.length; j++) dp[0][j] = j;
@@ -47,6 +65,82 @@ const lev = (a, b) => {
47
65
  return dp[a.length][b.length];
48
66
  };
49
67
 
68
+ /** Names compare ignoring case, hyphens, underscores and spaces: "triageq" is "triage-q". */
69
+ export const squash = (s) => String(s ?? '').toLowerCase().replace(/[-_\s]+/g, '');
70
+
71
+ /** The ONE name in `names` that equals `input` once squashed (exact match wins first); null when none or several. */
72
+ export function uniqueSquash(input, names) {
73
+ const q = squash(input);
74
+ if (!q) return null;
75
+ const list = (names || []).map(n => String(n));
76
+ const exact = list.find(n => n.toLowerCase() === String(input).trim().toLowerCase());
77
+ if (exact) return exact;
78
+ const hits = [...new Set(list.filter(n => squash(n) === q))];
79
+ return hits.length === 1 ? hits[0] : null;
80
+ }
81
+
82
+ /**
83
+ * QUOXON-SAY-BY-SESSION: resolve what the user typed after /say to a slot. A slot name wins (exact, then a unique
84
+ * squash match); otherwise the slot HELD by a session of that name ("/say SITE-CLI" reaches slot cli-site).
85
+ * -> slot name | null.
86
+ */
87
+ export function slotForName(input, rows, senders = null) {
88
+ return resolveSayTarget(input, rows, senders)?.slot || null;
89
+ }
90
+
91
+ /**
92
+ * SLOTS-REPLY: the full resolution behind slotForName. Order: a slot name, then the session HOLDING a slot, then a
93
+ * name that recently SPOKE with a verified from_slot (`senders`: slot messages, oldest first). The slot is the
94
+ * address; `name` is the session/sender name it was reached by (null for a plain slot name).
95
+ * -> { slot, via: 'slot'|'session'|'sender', name } | null.
96
+ */
97
+ export function resolveSayTarget(input, rows, senders = null) {
98
+ const list = Array.isArray(rows) ? rows : [];
99
+ const byName = uniqueSquash(input, list.map(r => r?.name).filter(Boolean));
100
+ if (byName) return { slot: byName, via: 'slot', name: null };
101
+ const held = list.filter(r => r?.name && r?.holder_session && r?.state !== 'free');
102
+ const who = uniqueSquash(input, held.map(r => r.holder_session));
103
+ if (who) {
104
+ const slots = [...new Set(held.filter(r => r.holder_session === who).map(r => r.name))];
105
+ return slots.length === 1 ? { slot: slots[0], via: 'session', name: who } : null;
106
+ }
107
+ // A sender is known by its full label ("quox1 (Adam)") and by its head ("quox1"); the newest message wins.
108
+ const spoke = new Map();
109
+ for (const m of Array.isArray(senders) ? senders : []) {
110
+ const f = String(m?.from || '').trim();
111
+ if (!f || !m?.from_slot) continue;
112
+ spoke.set(f, m.from_slot);
113
+ const head = f.replace(/\s*\(.*\)\s*$/, '');
114
+ if (head && head !== f) spoke.set(head, m.from_slot);
115
+ }
116
+ const by = uniqueSquash(input, [...spoke.keys()]);
117
+ return by ? { slot: spoke.get(by), via: 'sender', name: by } : null;
118
+ }
119
+
120
+ /** SLOTS-REPLY: how an address is shown everywhere: the slot first, the session/display name in brackets. */
121
+ export function addressLabel(slot, name) {
122
+ const s = String(slot || ''); const n = String(name || '').trim();
123
+ if (!n || n === s || n.startsWith(`${s} (`)) return n.startsWith(`${s} (`) ? n : s;
124
+ return `${s} (${n})`;
125
+ }
126
+
127
+ /**
128
+ * QUOXON-SAY-MISS: /say to a name that is neither a slot nor a session holding one. Say so plainly, suggest a close
129
+ * match, list who IS reachable (slot and the session holding it), and how the other session becomes reachable.
130
+ */
131
+ export function sayMissText(input, rows, cap = 8) {
132
+ const list = (Array.isArray(rows) ? rows : []).filter(r => r?.name);
133
+ const held = list.filter(r => r.holder_session && r.state !== 'free');
134
+ const near = nearest(input, [...new Set([...list.map(r => r.name), ...held.map(r => r.holder_session)])], 2);
135
+ const reach = held.slice(0, cap).map(r => `${r.name} (${r.holder_session})`).join(', ');
136
+ return [
137
+ `Nothing called "${input}" can get a /say: it is not a slot, and no session by that name holds one.`,
138
+ near.length ? `Did you mean ${near.join(' or ')}?` : null,
139
+ `If ${input} is a running session, it needs a slot first; ask it to run: quox slot checkout <name> --create --session ${input}`,
140
+ reach ? `Reachable now: ${reach}${held.length > cap ? `, +${held.length - cap} more (/slots)` : ''}` : null,
141
+ ].filter(Boolean).join('\n');
142
+ }
143
+
50
144
  const idOf = (a) => String(a?.agent_id ?? a?.id ?? a?.name ?? '');
51
145
  const nameOf = (a) => String(a?.name ?? a?.agent_id ?? a?.id ?? '');
52
146
 
@@ -59,6 +153,9 @@ export function matchAgent(input, agents) {
59
153
  const list = Array.isArray(agents) ? agents : [];
60
154
  const hit = list.find(a => idOf(a).toLowerCase() === q || nameOf(a).toLowerCase() === q);
61
155
  if (hit) return { agent: { id: idOf(hit), name: nameOf(hit) } };
156
+ const sq = squash(q);
157
+ const loose = sq ? list.filter(a => squash(idOf(a)) === sq || squash(nameOf(a)) === sq) : [];
158
+ if (new Set(loose.map(idOf)).size === 1) return { agent: { id: idOf(loose[0]), name: nameOf(loose[0]) } };
62
159
  const scored = list.map(a => {
63
160
  const ks = [idOf(a).toLowerCase(), nameOf(a).toLowerCase()];
64
161
  const d = Math.min(...ks.map(k => (k.includes(q) || (k && q.includes(k)) ? 0 : lev(q, k))));
@@ -66,3 +163,76 @@ export function matchAgent(input, agents) {
66
163
  }).filter(x => x.d <= Math.max(2, Math.floor(q.length / 3))).sort((x, y) => x.d - y.d || x.id.localeCompare(y.id));
67
164
  return { matches: scored.slice(0, 3).map(x => x.id) };
68
165
  }
166
+
167
+ /**
168
+ * "/attach" argument -> { kind: 'slot'|'agent'|null, name, rest }. The FIRST token is the name; anything after it
169
+ * is a message for the target. `slot:x` / `agent:x` force the kind.
170
+ */
171
+ export function parseTarget(input) {
172
+ const m = String(input || '').trim().match(/^(\S+)\s*([\s\S]*)$/);
173
+ if (!m) return { kind: null, name: '', rest: '' };
174
+ const p = m[1].match(/^(slot|agent):(.*)$/i);
175
+ return p ? { kind: p[1].toLowerCase(), name: p[2], rest: m[2].trim() } : { kind: null, name: m[1], rest: m[2].trim() };
176
+ }
177
+
178
+ /** Closest names to `input` among plain strings: substring either way, else edit distance. Best first, max 3. */
179
+ export function nearest(input, names, max = 3) {
180
+ const q = String(input || '').trim().toLowerCase();
181
+ if (!q) return [];
182
+ return (names || []).map(n => String(n)).map(n => { const k = n.toLowerCase(); return { n, d: k.includes(q) || (k && q.includes(k)) ? 0 : lev(q, k) }; })
183
+ .filter(x => x.d <= Math.max(2, Math.floor(q.length / 3))).sort((a, b) => a.d - b.d || a.n.localeCompare(b.n)).slice(0, max).map(x => x.n);
184
+ }
185
+
186
+ /** Why a list call failed, in words a person can act on. 401/403 is "signed out", never a missing item. */
187
+ export function listFailure(e, profile = null) {
188
+ const st = e?.status;
189
+ if (st === 401 || st === 403) return `signed out (HTTP ${st}). Run quox login -p ${profile || '<profile>'}.`;
190
+ if (st) return `HTTP ${st}`;
191
+ return /timed out/i.test(e?.message || '') ? 'timed out' : (e?.message || 'failed');
192
+ }
193
+
194
+ /** More free slots than this fold into one "+N free" line in /slots (TUI-UX #8). */
195
+ export const FREE_COLLAPSE_OVER = 5;
196
+
197
+ /**
198
+ * /slots as aligned columns, held first then newest use first (owner 2026-10-16: "messy output. I want columns. And last used
199
+ * date/time"). Without `cols` every slot prints in the full-width layout. With `cols` (TUI-UX #8) the table fits the terminal:
200
+ * the full layout when it fits, else a compact one (no HOST / absolute time, names clipped with an ellipsis), and free slots
201
+ * collapse into one "+N free" line unless `all`. Unread is never shown on a free slot.
202
+ */
203
+ export function slotsTable(rows, { mine = null, now = Date.now(), cols = null, all = false } = {}) {
204
+ const t = (r) => Math.max(...['last_heartbeat_at', 'last_read_at', 'checked_out_at', 'released_at', 'created_at'].map(k => Date.parse(r[k] || '') || 0));
205
+ const ago = (ms) => { if (!ms) return '-'; const m = Math.max(0, Math.floor((now - ms) / 60000)); return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : m < 2880 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`; };
206
+ const when = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 16) : '-');
207
+ const isHeld = (r) => Boolean(r.holder_session && r.state !== 'free');
208
+ const sorted = [...rows].filter(r => r?.name).sort((a, b) => (isHeld(b) - isHeld(a)) || (t(b) - t(a)));
209
+ const limited = Number.isFinite(cols) && cols > 0;
210
+ const freeCount = sorted.filter(r => !isHeld(r)).length;
211
+ const collapse = limited && !all && freeCount > FREE_COLLAPSE_OVER; // a handful of free slots stay listed: folding one row into a summary saves nothing
212
+ const shown = collapse ? sorted.filter(isHeld) : sorted;
213
+ const unread = (r) => (r.unread_count && isHeld(r) ? String(r.unread_count) : '');
214
+ const nameOf = (r) => r.name + (r.name === mine ? ' *' : '');
215
+ const layout = (heads, cellsOf) => {
216
+ const head = heads, body = shown.map(cellsOf);
217
+ return { head, body, w: head.map((h, i) => Math.max(h.length, ...body.map(b => b[i].length))) };
218
+ };
219
+ let L = layout(['', 'SLOT', 'HOLDER', 'HOST', 'LAST USED (UTC)', 'AGO', 'UNREAD'],
220
+ r => [isHeld(r) ? '●' : '○', nameOf(r), isHeld(r) ? r.holder_session : 'free', r.host_id || '-', when(t(r)), ago(t(r)), unread(r)]);
221
+ const width = (w) => w.reduce((a, b) => a + b, 0) + 2 * (w.length - 1);
222
+ let compact = false;
223
+ if (limited && width(L.w) > cols) {
224
+ compact = true;
225
+ L = layout(['', 'SLOT', 'HOLDER', 'AGO', 'UNREAD'], r => [isHeld(r) ? '●' : '○', nameOf(r), isHeld(r) ? r.holder_session : 'free', ago(t(r)), unread(r)]);
226
+ // still too wide: shrink the two name columns (widest first) with a trailing ellipsis, never below 6
227
+ while (width(L.w) > cols) {
228
+ const k = L.w[1] >= L.w[2] ? 1 : 2;
229
+ if (L.w[k] <= 6) break;
230
+ L.w[k] -= 1;
231
+ }
232
+ }
233
+ const fit = (cell, n) => (cell.length > n ? cell.slice(0, Math.max(1, n - 1)) + '…' : cell);
234
+ const fmt = (cells) => cells.map((c, i) => (compact && (i === 1 || i === 2) ? fit(c, L.w[i]) : c).padEnd(L.w[i])).join(' ').trimEnd();
235
+ const out = [fmt(L.head), ...L.body.map(fmt)];
236
+ if (collapse) out.push(`+${freeCount} free (/slots all)`);
237
+ return out;
238
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Is this error a dead session (sign in again) rather than a transient failure? Pure, so the
3
+ * headless client (api.js, `quox --print`) can ask without loading signin.js, which pulls
4
+ * React + Ink (~2 s cold) (QUOXON-LATENCY). signin.js re-exports it for the interactive client.
5
+ */
6
+ export function isSessionDead(e) {
7
+ if (!e || e.transient) return false;
8
+ if (e.code === 'session_expired') return true;
9
+ return /session expired|No refresh token|please login again|no authentication token|valid jwt or service key|not signed in/i.test(e.message || '');
10
+ }
11
+
12
+ /** One line for "this window's sign-in is stale" (a command the palette tagged "(sign in)"). */
13
+ export const SESSION_EXPIRED_LINE = "This window's sign-in expired. /login signs in again without leaving.";
14
+ /** What to say when a palette row is chosen but cannot run: the sign-in line, or "<name> is <reason>." */
15
+ export function signedOutNotice(name, reason) {
16
+ return reason === '(sign in)' ? SESSION_EXPIRED_LINE : `${name} is ${reason}.`;
17
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Signed-out handling for the TUI (QUOXON-Z2H): one clear card instead of scattered errors, and an in-TUI
3
+ * /login that uses the same login path as `quox login` (createAuth().login + verifyMfa). The password lives
4
+ * only in a secret box (closure): never dispatched, logged, put in history or saved. Only the email is kept
5
+ * (profile.last_email) so the next sign-in can prefill it.
6
+ */
7
+ import React from 'react';
8
+ import { Box, Text } from 'ink';
9
+ import { createSecretBox } from './connect.js';
10
+ import { c, clip } from './theme.js';
11
+ import { Rule } from './render.js';
12
+
13
+ const h = React.createElement;
14
+
15
+ /** The one-line answer to anything that needs a session while signed out. */
16
+ export const SIGNED_OUT_LINE = 'Signed out. /login to sign in again';
17
+
18
+ /** A definitive "this session is over" failure (not a transient hiccup, not a 401 from some other route). */
19
+ export { isSessionDead, SESSION_EXPIRED_LINE, signedOutNotice } from './sessionDead.js'; // QUOXON-LATENCY: pure, kept off the Ink import path
20
+
21
+ const WHY = {
22
+ idle_timeout: 'you were idle for longer than the idle limit',
23
+ max_session: 'it reached its maximum length',
24
+ session_revoked: 'the server revoked it (a session policy limit, an admin, or a sign-out elsewhere)'
25
+ };
26
+
27
+ /** The card text. reason = idle_timeout | max_session from the server's refresh response, else neutral. */
28
+ export function signedOutCard(profile, reason) {
29
+ const why = WHY[reason];
30
+ return [
31
+ // SIGNOUT-WIPE: never quote a length here. An org policy can cap CLI sessions far below the
32
+ // 30-day default (24h on 2026-10-15), and a wrong number made a policy expiry look like data loss.
33
+ `You're signed out of ${profile} (how long a sign-in lasts is set by your Quox server's session policy).`,
34
+ ...(why ? [`This session ended because ${why}.`] : []),
35
+ 'Sign in again: /login'
36
+ ].join(' ');
37
+ }
38
+
39
+ /** Controller for the /login card. onSignedIn() runs after a successful sign-in so the app can refresh. */
40
+ export function createLoginController({ api, note, refresh, onSignedIn }) {
41
+ const pw = createSecretBox();
42
+ let card = null; let busy = false; let tokenForMfa = null;
43
+ const close = () => { pw.clear(); tokenForMfa = null; card = null; refresh(); };
44
+ async function submitCreds() {
45
+ busy = true; card.phase = 'working'; card.error = null; refresh();
46
+ let r;
47
+ try { r = await api.signIn(card.email.trim(), pw.take()); }
48
+ catch (e) { busy = false; if (!card) return; card.phase = 'password'; card.error = (e?.message || 'Sign in failed').replace(/\s+/g, ' '); refresh(); return; }
49
+ busy = false;
50
+ if (!card) return;
51
+ if (r?.mfa_required) { tokenForMfa = r.access_token; card.phase = 'mfa'; card.mfa = ''; refresh(); return; }
52
+ done(r);
53
+ }
54
+ async function submitMfa() {
55
+ busy = true; const code = card.mfa.trim(); card.phase = 'working'; refresh();
56
+ try { await api.verifyMfa(tokenForMfa, code); }
57
+ catch (e) { busy = false; if (!card) return; card.phase = 'mfa'; card.mfa = ''; card.error = (e?.message || 'MFA failed').replace(/\s+/g, ' '); refresh(); return; }
58
+ busy = false;
59
+ done({});
60
+ }
61
+ function done(r) {
62
+ const who = r?.user?.email || card.email.trim();
63
+ close();
64
+ note(`Signed in as ${who}.`, 'ok');
65
+ onSignedIn?.();
66
+ }
67
+ return {
68
+ isOpen: () => Boolean(card),
69
+ view: () => (card ? { card: { ...card }, mask: pw.mask() } : null),
70
+ open() {
71
+ if (!api.signIn) { note('Signing in is not available in this mode. Run: quox login', 'warn'); return false; }
72
+ pw.clear(); tokenForMfa = null;
73
+ const email = (api.lastEmail?.() || '').trim();
74
+ card = { phase: email ? 'password' : 'email', email, mfa: '', error: null };
75
+ refresh();
76
+ return true;
77
+ },
78
+ onEvent(e) {
79
+ if (!card) return false;
80
+ if (card.phase === 'working' || busy) return true;
81
+ const ph = card.phase;
82
+ if (e.type === 'paste' || e.type === 'text') {
83
+ const t = String(e.text || '').replace(/[\x00-\x1f\x7f]+/g, '');
84
+ if (ph === 'password') e.type === 'paste' ? pw.paste(t) : pw.type(t);
85
+ else if (ph === 'email') card.email += t.replace(/\s+/g, '');
86
+ else if (ph === 'mfa') card.mfa += t.replace(/\s+/g, '');
87
+ card.error = null; refresh(); return true;
88
+ }
89
+ if (e.type !== 'key') return true;
90
+ if (e.name === 'escape' || e.name === 'ctrl-c') { close(); note('Cancelled. Nothing was saved.'); return true; }
91
+ if (e.name === 'enter') {
92
+ if (ph === 'email') { if (!card.email.includes('@')) { card.error = 'Enter your email address.'; } else { card.phase = 'password'; card.error = null; } refresh(); }
93
+ else if (ph === 'password') { if (!pw.length()) { card.error = 'Enter your password, or Esc to cancel.'; refresh(); } else submitCreds(); }
94
+ else if (ph === 'mfa') { if (!card.mfa.trim()) { card.error = 'Enter the code from your authenticator app.'; refresh(); } else submitMfa(); }
95
+ return true;
96
+ }
97
+ if (e.name === 'backspace') {
98
+ if (ph === 'password') pw.backspace(); else if (ph === 'email') card.email = card.email.slice(0, -1); else card.mfa = card.mfa.slice(0, -1);
99
+ refresh(); return true;
100
+ }
101
+ if (e.name === 'kill-line' || e.name === 'delete-word') {
102
+ if (ph === 'password') pw.clear(); else if (ph === 'email') card.email = ''; else card.mfa = '';
103
+ refresh(); return true;
104
+ }
105
+ if (e.name === 'tab' && ph === 'password') { card.phase = 'email'; refresh(); return true; }
106
+ return true;
107
+ }
108
+ };
109
+ }
110
+
111
+ /** card: { phase: 'email'|'password'|'mfa'|'working', email, mfa, error }, mask */
112
+ export function LoginCard({ card, mask, cols, profile }) {
113
+ const cur = (p) => (card.phase === p ? '▏' : '');
114
+ return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
115
+ h(Rule, { cols, color: 'sky' }),
116
+ h(Text, { color: c('sky'), bold: true }, clip(`Ⓠ Sign in to ${profile} · your password goes to the server and is never stored`, cols)),
117
+ h(Text, { color: c('text') }, clip(` email ${card.email}${cur('email')}`, cols)),
118
+ card.phase !== 'email' ? h(Text, { color: c('text') }, clip(` password ${mask || ''}${cur('password')}`, cols)) : null,
119
+ card.phase === 'mfa' || (card.phase === 'working' && card.mfa) ? h(Text, { color: c('text') }, clip(` code ${card.mfa}${cur('mfa')}`, cols)) : null,
120
+ card.error ? h(Text, { color: c('warn') }, clip(` ${card.error}`, cols)) : null,
121
+ h(Text, { color: c('dim') }, card.phase === 'working'
122
+ ? clip(' ◐ signing in…', cols)
123
+ : clip(` ${card.phase === 'mfa' ? 'enter your authenticator code' : card.phase === 'email' ? 'type your email' : 'type your password'} · Enter continue · Esc cancel`, cols)),
124
+ h(Rule, { cols, color: 'sky' }));
125
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Pure helpers for /agents (list picker, card, tools), /which, and the loose-command fallback.
3
+ * No I/O: app.js feeds real data in and prints what comes out.
4
+ */
5
+ import { findEntry, suggestCommands } from '../commands.js';
6
+
7
+ /** Words that are verbs of /agents, never agent names. */
8
+ export const AGENT_RESERVED = new Set(['list', 'tools', 'show']);
9
+
10
+ /**
11
+ * /agents argument -> { mode: 'list', filter } | { mode: 'card', name } | { mode: 'tools', name } | { mode: 'usage', text }.
12
+ * `list [filter]`, `show <name>`, `<name> [tools [list]]`, `tools [list] <name>` (name optional only for list).
13
+ */
14
+ export function parseAgentsArgs(arg) {
15
+ const t = String(arg || '').trim().split(/\s+/).filter(Boolean);
16
+ const lc = t.map(x => x.toLowerCase());
17
+ if (!t.length) return { mode: 'list', filter: '' };
18
+ if (lc[0] === 'list') return { mode: 'list', filter: t.slice(1).join(' ') };
19
+ if (lc[0] === 'show') return t.length > 1 ? { mode: 'card', name: t.slice(1).join(' ') } : { mode: 'usage', text: 'Usage: /agents show <name>' };
20
+ if (lc[0] === 'tools') {
21
+ const rest = lc[1] === 'list' ? t.slice(2) : t.slice(1);
22
+ return rest.length ? { mode: 'tools', name: rest.join(' ') } : { mode: 'usage', text: 'Usage: /agents <name> tools' };
23
+ }
24
+ const last = lc[lc.length - 1];
25
+ if (last === 'tools' && t.length > 1) return { mode: 'tools', name: t.slice(0, -1).join(' ') };
26
+ if (last === 'list' && lc[lc.length - 2] === 'tools' && t.length > 2) return { mode: 'tools', name: t.slice(0, -2).join(' ') };
27
+ return { mode: 'card', name: t.join(' ') };
28
+ }
29
+
30
+ /** Agents matching the filter text (id, name or role), filter case-insensitive. */
31
+ export function filterAgents(list, filter) {
32
+ const q = String(filter || '').trim().toLowerCase();
33
+ const rows = Array.isArray(list) ? list : [];
34
+ if (!q) return rows;
35
+ return rows.filter(a => [a.agent_id, a.name, a.role].some(v => String(v || '').toLowerCase().includes(q)));
36
+ }
37
+
38
+ const clipTo = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
39
+ /** One picker row: name, short role, status. */
40
+ export const agentPickerRow = (a) => ({
41
+ key: String(a.name || a.agent_id),
42
+ text: `${a.role ? clipTo(String(a.role), 48) : 'no role recorded'} · ${a.status || 'status not reported'}`
43
+ });
44
+
45
+ /** Tool names grouped by their prefix (github_list_repos -> github) when the prefix is shared by 2+; the rest under "other". */
46
+ export function toolLines(data) {
47
+ const nm = data?.name || data?.id || 'agent';
48
+ if (!Array.isArray(data?.tools)) return [[`${nm}: could not read tools${data?.toolsErr ? ` (${data.toolsErr})` : ''}`, 'warn']];
49
+ if (!data.tools.length) return [[`${nm} has no tools assigned.`, 'plain']];
50
+ const names = data.tools.map(x => (typeof x === 'string' ? x : x?.name || x?.tool_name || x?.tool_id || String(x)));
51
+ const pref = (n) => (n.includes('_') ? n.split('_')[0] : null);
52
+ const count = new Map();
53
+ for (const n of names) { const p = pref(n); if (p) count.set(p, (count.get(p) || 0) + 1); }
54
+ const groups = new Map();
55
+ for (const n of [...names].sort()) {
56
+ const p = pref(n); const g = p && count.get(p) > 1 ? p : 'other';
57
+ if (!groups.has(g)) groups.set(g, []);
58
+ groups.get(g).push(n);
59
+ }
60
+ const out = [[`${nm}: ${names.length} tool${names.length === 1 ? '' : 's'}`, 'plain']];
61
+ const order = [...groups.keys()].filter(g => g !== 'other').sort().concat(groups.has('other') ? ['other'] : []);
62
+ for (const g of order) out.push([` ${g} (${groups.get(g).length}): ${groups.get(g).join(', ')}`, 'dim']);
63
+ return out;
64
+ }
65
+
66
+ // ---- /which ------------------------------------------------------------------------------------------------
67
+ export const WHICH_KEYS = ['profile', 'org', 'agent', 'hat', 'model', 'slot', 'conversation'];
68
+ const WHICH_ALIAS = { provider: 'model', session: 'conversation', chat: 'conversation', login: 'profile', user: 'profile' };
69
+
70
+ /** d: { profile, agentLogin, org, agent, hat, model (text), slot, slotLive, conversationId, sessionName } -> [[key, text]] */
71
+ export function whichRows(d = {}) {
72
+ return [
73
+ ['profile', `${d.profile || '?'} (${d.agentLogin ? 'agent login' : 'human login'})`],
74
+ ['org', d.org || '? not in this login'],
75
+ ['agent', d.agent || 'CommanderQ (quox)'],
76
+ ['hat', d.hat || 'none reported yet (shows after your first message; quox hat list for the org\'s hats)'],
77
+ ['model', d.model || '?'],
78
+ ['slot', d.slot ? `${d.slot} (${d.slotLive ? 'live stream connected' : 'live stream not connected'})` : 'none (/slot <name>)'],
79
+ ['conversation', `${d.conversationId || 'new (starts with your next message)'}${d.sessionName ? ` · ${d.sessionName}` : ''}`]
80
+ ];
81
+ }
82
+
83
+ /** arg -> { rows: [[key,text]] } | { error } */
84
+ export function whichReply(arg, d) {
85
+ const q = String(arg || '').trim().toLowerCase();
86
+ const rows = whichRows(d);
87
+ if (!q) return { rows };
88
+ const key = WHICH_ALIAS[q] || q;
89
+ const hit = rows.find(r => r[0] === key);
90
+ return hit ? { rows: [hit] } : { error: `/which ${q}: not one I know. Try /which ${WHICH_KEYS.join(' | ')}` };
91
+ }
92
+
93
+ // ---- loose slash commands ----------------------------------------------------------------------------------
94
+ /** Commands that only read: safe to run for the user from a loosely typed line. Everything else is suggested. */
95
+ export const LOOSE_SAFE = new Set(['help', 'status', 'context', 'cost', 'agents', 'skills', 'memory', 'sitrep', 'away', 'workflow', 'crystal', 'proof', 'reconcile', 'tips', 'whatsnew', 'which', 'doctor', 'hat']);
96
+
97
+ /**
98
+ * Unknown `/cmd words...`. Looks for a later word that is a real active command or alias.
99
+ * -> { run: '/x args' } | { suggest: '/x' } | { ask: 'words' } | null (fall back to the near-miss / Try /help line)
100
+ */
101
+ export function looseCommand(cmd, restArg) {
102
+ const words = String(restArg || '').trim().split(/\s+/).filter(Boolean);
103
+ for (let i = 0; i < words.length; i++) {
104
+ const e = findEntry(words[i].replace(/^\//, ''));
105
+ if (!e || e.kind !== 'command' || e.status !== 'active') continue;
106
+ const rest = words.slice(i + 1).join(' ');
107
+ if (LOOSE_SAFE.has(e.id)) return { run: `/${e.id}${rest ? ` ${rest}` : ''}`, name: `/${e.id}` };
108
+ return { suggest: `/${e.id}${rest ? ` ${rest}` : ''}` };
109
+ }
110
+ if (words.length && !suggestCommands(cmd).length) return { ask: `${String(cmd).replace(/^\//, '')} ${words.join(' ')}` };
111
+ return null;
112
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * QUOXON-FW6: the async sources behind registry `args` levels (slash/args.js). Each source reads
3
+ * the same api the command itself uses and returns [{ value, desc? }]. A source may throw; the
4
+ * palette caches that as empty and shows nothing (suggestions fail quietly, never an error row).
5
+ */
6
+ import { CHOICES, connectedChoices, providerId } from '../model.js';
7
+ import { liveModels } from '../modelPrefs.js';
8
+ import { pathItems } from '../slotChat.js';
9
+
10
+ /** Provider ids /connect understands (one per provider; the subscription has no key). */
11
+ export const connectIds = () => [
12
+ ...new Map(
13
+ CHOICES.filter((c) => c.connect).map((c) => [c.connect, { value: c.connect, desc: c.name }]),
14
+ ).values(),
15
+ ];
16
+
17
+ export function createArgSources({ api, getPrefs = () => ({ favourites: [], default: null }), getRows = () => [] }) {
18
+ return async function source(src, param) {
19
+ switch (src) {
20
+ case 'providers':
21
+ return connectIds();
22
+ case 'model.connected': {
23
+ if (!api.connections) return [];
24
+ const [res, caps] = await Promise.all([
25
+ api.connections(),
26
+ api.capabilities ? api.capabilities() : null,
27
+ ]);
28
+ return connectedChoices(res, caps).map((c) => ({ value: c.id, desc: c.name }));
29
+ }
30
+ case 'model.models': {
31
+ const id = providerId(param);
32
+ if (!id) return [];
33
+ const l = await liveModels(api, id);
34
+ return l.ids.map((v) => ({ value: v, desc: v === l.def ? 'newest' : '' }));
35
+ }
36
+ case 'model.favs':
37
+ return getPrefs().favourites.map((f, i) => ({
38
+ value: String(i + 1),
39
+ desc: `${f.provider} ${f.model}`,
40
+ }));
41
+ case 'rows':
42
+ return (getRows?.() || []).map((r) => ({ value: r.id, desc: r.source.type }));
43
+ case 'orgs':
44
+ return (await api.orgs()).map((o) => ({ value: o.slug || o.id, desc: o.name || '' }));
45
+ case 'agents':
46
+ return (await api.agents()).map((a) => ({
47
+ value: a.agent_id,
48
+ desc: a.name && a.name !== a.agent_id ? a.name : '',
49
+ }));
50
+ case 'fleetHosts':
51
+ return (await api.objects?.fleetNames?.()) || [];
52
+ case 'conversations':
53
+ return ((await api.conversations?.(15)) || []).map((c) => ({ value: String(c.id), desc: c.title || '' }));
54
+ case 'files': // QUOXON-MESH: /send <slot> <path>; param = the directory part typed so far
55
+ return pathItems(param || '', { cwd: process.cwd() });
56
+ case 'slots':
57
+ return (await api.slots()).map((s) => ({
58
+ value: String(s.name),
59
+ desc: s.holder_session ? `held by ${s.holder_session}` : s.assigned_to ? `for ${s.assigned_to}` : '',
60
+ }));
61
+ default:
62
+ return [];
63
+ }
64
+ };
65
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * QUOXON-FW6: argument suggestions. After `/command ` the composer suggests the next word, driven
3
+ * by the registry: an entry may declare `args`, a list of levels. A level is
4
+ * { after: [words...], choices?: [string | { value, desc }], src?: 'name', of?: <word index> }
5
+ * and applies when the words already typed match `after` ('*' matches any word). `src` names an
6
+ * async source the app resolves (slash/argSources.js) with the word at index `of` as its
7
+ * parameter. Levels are checked in order and the first match wins, so put specific levels before
8
+ * wildcard ones. The registry stays plain data and never holds a model list.
9
+ */
10
+ import { findEntry } from '../commands.js';
11
+
12
+ /** The draft as an argument context, or null unless it is `/command <something>` on one line. */
13
+ export function argContext(draft) {
14
+ const t = String(draft ?? '');
15
+ if (!t.startsWith('/') || t.startsWith('//') || t.includes('\n')) return null;
16
+ const m = t.match(/^\/(\S+)\s([\s\S]*)$/);
17
+ if (!m) return null;
18
+ const entry = findEntry(m[1]);
19
+ if (!entry || entry.kind !== 'command' || entry.status !== 'active' || !Array.isArray(entry.args))
20
+ return null;
21
+ const rest = m[2];
22
+ const tokens = rest.split(/\s+/);
23
+ const partial = tokens.pop(); // '' when the draft ends in a space
24
+ const words = tokens.filter(Boolean);
25
+ return { entry, words, partial, start: t.length - partial.length };
26
+ }
27
+
28
+ const matches = (after, words) =>
29
+ after.length === words.length &&
30
+ after.every((a, i) => a === '*' || String(words[i]).toLowerCase() === String(a).toLowerCase());
31
+
32
+ /** The level that suggests the next word after `words`, or null. */
33
+ export function levelFor(entry, words) {
34
+ for (const lv of entry.args || []) if (matches(lv.after || [], words)) return lv;
35
+ return null;
36
+ }
37
+
38
+ const asItem = (x) =>
39
+ typeof x === 'string' ? { value: x } : { value: String(x.value), desc: x.desc || '' };
40
+
41
+ /** Static choices of a level as items. */
42
+ export const staticItems = (lv) => (lv?.choices || []).map(asItem);
43
+
44
+ /** Prefix matches first, then substring; the exact sole match is dropped (nothing to add). */
45
+ export function rankArgs(items, partial) {
46
+ const q = String(partial || '').toLowerCase();
47
+ const seen = new Set();
48
+ const uniq = items.map(asItem).filter((i) => i.value && !seen.has(i.value) && seen.add(i.value));
49
+ if (!q) return uniq;
50
+ const pre = uniq.filter((i) => i.value.toLowerCase().startsWith(q));
51
+ const sub = uniq.filter(
52
+ (i) => !i.value.toLowerCase().startsWith(q) && i.value.toLowerCase().includes(q),
53
+ );
54
+ const out = [...pre, ...sub];
55
+ return out.length === 1 && out[0].value.toLowerCase() === q ? [] : out;
56
+ }
57
+
58
+ /**
59
+ * What Tab does: replace the partial word; a trailing space only when another argument can
60
+ * follow.
61
+ */
62
+ export function applyArg(draft, ctx, value) {
63
+ const more = levelFor(ctx.entry, [...ctx.words, value]) != null;
64
+ return draft.slice(0, ctx.start) + value + (more ? ' ' : '');
65
+ }