@moxxy/core 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/events/factory.d.ts +6 -0
  4. package/dist/events/factory.d.ts.map +1 -0
  5. package/dist/events/factory.js +14 -0
  6. package/dist/events/factory.js.map +1 -0
  7. package/dist/events/log.d.ts +92 -0
  8. package/dist/events/log.d.ts.map +1 -0
  9. package/dist/events/log.js +244 -0
  10. package/dist/events/log.js.map +1 -0
  11. package/dist/index.d.ts +40 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +41 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/logger.d.ts +16 -0
  16. package/dist/logger.d.ts.map +1 -0
  17. package/dist/logger.js +27 -0
  18. package/dist/logger.js.map +1 -0
  19. package/dist/permissions/engine.d.ts +94 -0
  20. package/dist/permissions/engine.d.ts.map +1 -0
  21. package/dist/permissions/engine.js +203 -0
  22. package/dist/permissions/engine.js.map +1 -0
  23. package/dist/permissions/resolvers.d.ts +8 -0
  24. package/dist/permissions/resolvers.d.ts.map +1 -0
  25. package/dist/permissions/resolvers.js +8 -0
  26. package/dist/permissions/resolvers.js.map +1 -0
  27. package/dist/plugins/discovery.d.ts +9 -0
  28. package/dist/plugins/discovery.d.ts.map +1 -0
  29. package/dist/plugins/discovery.js +112 -0
  30. package/dist/plugins/discovery.js.map +1 -0
  31. package/dist/plugins/host-options.d.ts +72 -0
  32. package/dist/plugins/host-options.d.ts.map +1 -0
  33. package/dist/plugins/host-options.js +2 -0
  34. package/dist/plugins/host-options.js.map +1 -0
  35. package/dist/plugins/host.d.ts +53 -0
  36. package/dist/plugins/host.d.ts.map +1 -0
  37. package/dist/plugins/host.js +269 -0
  38. package/dist/plugins/host.js.map +1 -0
  39. package/dist/plugins/lifecycle.d.ts +31 -0
  40. package/dist/plugins/lifecycle.d.ts.map +1 -0
  41. package/dist/plugins/lifecycle.js +100 -0
  42. package/dist/plugins/lifecycle.js.map +1 -0
  43. package/dist/plugins/loader.d.ts +7 -0
  44. package/dist/plugins/loader.d.ts.map +1 -0
  45. package/dist/plugins/loader.js +68 -0
  46. package/dist/plugins/loader.js.map +1 -0
  47. package/dist/plugins/package-requirements.d.ts +16 -0
  48. package/dist/plugins/package-requirements.d.ts.map +1 -0
  49. package/dist/plugins/package-requirements.js +52 -0
  50. package/dist/plugins/package-requirements.js.map +1 -0
  51. package/dist/plugins/registry-kinds.d.ts +58 -0
  52. package/dist/plugins/registry-kinds.d.ts.map +1 -0
  53. package/dist/plugins/registry-kinds.js +139 -0
  54. package/dist/plugins/registry-kinds.js.map +1 -0
  55. package/dist/plugins/toposort.d.ts +24 -0
  56. package/dist/plugins/toposort.d.ts.map +1 -0
  57. package/dist/plugins/toposort.js +67 -0
  58. package/dist/plugins/toposort.js.map +1 -0
  59. package/dist/preferences.d.ts +40 -0
  60. package/dist/preferences.d.ts.map +1 -0
  61. package/dist/preferences.js +58 -0
  62. package/dist/preferences.js.map +1 -0
  63. package/dist/registries/active-backend-registry.d.ts +65 -0
  64. package/dist/registries/active-backend-registry.d.ts.map +1 -0
  65. package/dist/registries/active-backend-registry.js +108 -0
  66. package/dist/registries/active-backend-registry.js.map +1 -0
  67. package/dist/registries/active-def-registry.d.ts +59 -0
  68. package/dist/registries/active-def-registry.d.ts.map +1 -0
  69. package/dist/registries/active-def-registry.js +63 -0
  70. package/dist/registries/active-def-registry.js.map +1 -0
  71. package/dist/registries/agents.d.ts +16 -0
  72. package/dist/registries/agents.d.ts.map +1 -0
  73. package/dist/registries/agents.js +17 -0
  74. package/dist/registries/agents.js.map +1 -0
  75. package/dist/registries/cache-strategies.d.ts +12 -0
  76. package/dist/registries/cache-strategies.d.ts.map +1 -0
  77. package/dist/registries/cache-strategies.js +13 -0
  78. package/dist/registries/cache-strategies.js.map +1 -0
  79. package/dist/registries/channels.d.ts +16 -0
  80. package/dist/registries/channels.d.ts.map +1 -0
  81. package/dist/registries/channels.js +35 -0
  82. package/dist/registries/channels.js.map +1 -0
  83. package/dist/registries/commands.d.ts +29 -0
  84. package/dist/registries/commands.d.ts.map +1 -0
  85. package/dist/registries/commands.js +72 -0
  86. package/dist/registries/commands.js.map +1 -0
  87. package/dist/registries/compactors.d.ts +12 -0
  88. package/dist/registries/compactors.d.ts.map +1 -0
  89. package/dist/registries/compactors.js +13 -0
  90. package/dist/registries/compactors.js.map +1 -0
  91. package/dist/registries/def-map-registry.d.ts +33 -0
  92. package/dist/registries/def-map-registry.d.ts.map +1 -0
  93. package/dist/registries/def-map-registry.js +36 -0
  94. package/dist/registries/def-map-registry.js.map +1 -0
  95. package/dist/registries/embedders.d.ts +16 -0
  96. package/dist/registries/embedders.d.ts.map +1 -0
  97. package/dist/registries/embedders.js +17 -0
  98. package/dist/registries/embedders.js.map +1 -0
  99. package/dist/registries/isolators.d.ts +21 -0
  100. package/dist/registries/isolators.d.ts.map +1 -0
  101. package/dist/registries/isolators.js +32 -0
  102. package/dist/registries/isolators.js.map +1 -0
  103. package/dist/registries/modes.d.ts +28 -0
  104. package/dist/registries/modes.d.ts.map +1 -0
  105. package/dist/registries/modes.js +81 -0
  106. package/dist/registries/modes.js.map +1 -0
  107. package/dist/registries/providers.d.ts +44 -0
  108. package/dist/registries/providers.d.ts.map +1 -0
  109. package/dist/registries/providers.js +95 -0
  110. package/dist/registries/providers.js.map +1 -0
  111. package/dist/registries/skills.d.ts +24 -0
  112. package/dist/registries/skills.d.ts.map +1 -0
  113. package/dist/registries/skills.js +81 -0
  114. package/dist/registries/skills.js.map +1 -0
  115. package/dist/registries/surfaces.d.ts +13 -0
  116. package/dist/registries/surfaces.d.ts.map +1 -0
  117. package/dist/registries/surfaces.js +14 -0
  118. package/dist/registries/surfaces.js.map +1 -0
  119. package/dist/registries/synthesizers.d.ts +26 -0
  120. package/dist/registries/synthesizers.d.ts.map +1 -0
  121. package/dist/registries/synthesizers.js +34 -0
  122. package/dist/registries/synthesizers.js.map +1 -0
  123. package/dist/registries/tools.d.ts +49 -0
  124. package/dist/registries/tools.d.ts.map +1 -0
  125. package/dist/registries/tools.js +98 -0
  126. package/dist/registries/tools.js.map +1 -0
  127. package/dist/registries/transcribers.d.ts +17 -0
  128. package/dist/registries/transcribers.d.ts.map +1 -0
  129. package/dist/registries/transcribers.js +18 -0
  130. package/dist/registries/transcribers.js.map +1 -0
  131. package/dist/registries/tunnel-providers.d.ts +12 -0
  132. package/dist/registries/tunnel-providers.d.ts.map +1 -0
  133. package/dist/registries/tunnel-providers.js +13 -0
  134. package/dist/registries/tunnel-providers.js.map +1 -0
  135. package/dist/registries/view-renderers.d.ts +13 -0
  136. package/dist/registries/view-renderers.d.ts.map +1 -0
  137. package/dist/registries/view-renderers.js +14 -0
  138. package/dist/registries/view-renderers.js.map +1 -0
  139. package/dist/registries/workflow-executors.d.ts +13 -0
  140. package/dist/registries/workflow-executors.d.ts.map +1 -0
  141. package/dist/registries/workflow-executors.js +14 -0
  142. package/dist/registries/workflow-executors.js.map +1 -0
  143. package/dist/requirements.d.ts +59 -0
  144. package/dist/requirements.d.ts.map +1 -0
  145. package/dist/requirements.js +149 -0
  146. package/dist/requirements.js.map +1 -0
  147. package/dist/run-turn.d.ts +6 -0
  148. package/dist/run-turn.d.ts.map +1 -0
  149. package/dist/run-turn.js +141 -0
  150. package/dist/run-turn.js.map +1 -0
  151. package/dist/session-runtime.d.ts +67 -0
  152. package/dist/session-runtime.d.ts.map +1 -0
  153. package/dist/session-runtime.js +2 -0
  154. package/dist/session-runtime.js.map +1 -0
  155. package/dist/session.d.ts +203 -0
  156. package/dist/session.d.ts.map +1 -0
  157. package/dist/session.js +399 -0
  158. package/dist/session.js.map +1 -0
  159. package/dist/sessions/persistence.d.ts +184 -0
  160. package/dist/sessions/persistence.d.ts.map +1 -0
  161. package/dist/sessions/persistence.js +442 -0
  162. package/dist/sessions/persistence.js.map +1 -0
  163. package/dist/setup-agent.d.ts +91 -0
  164. package/dist/setup-agent.d.ts.map +1 -0
  165. package/dist/setup-agent.js +138 -0
  166. package/dist/setup-agent.js.map +1 -0
  167. package/dist/skills/index.d.ts +4 -0
  168. package/dist/skills/index.d.ts.map +1 -0
  169. package/dist/skills/index.js +4 -0
  170. package/dist/skills/index.js.map +1 -0
  171. package/dist/skills/loader.d.ts +16 -0
  172. package/dist/skills/loader.d.ts.map +1 -0
  173. package/dist/skills/loader.js +99 -0
  174. package/dist/skills/loader.js.map +1 -0
  175. package/dist/skills/parse.d.ts +5 -0
  176. package/dist/skills/parse.d.ts.map +1 -0
  177. package/dist/skills/parse.js +8 -0
  178. package/dist/skills/parse.js.map +1 -0
  179. package/dist/skills/synthesize-draft.d.ts +8 -0
  180. package/dist/skills/synthesize-draft.d.ts.map +1 -0
  181. package/dist/skills/synthesize-draft.js +32 -0
  182. package/dist/skills/synthesize-draft.js.map +1 -0
  183. package/dist/skills/synthesize.d.ts +33 -0
  184. package/dist/skills/synthesize.d.ts.map +1 -0
  185. package/dist/skills/synthesize.js +241 -0
  186. package/dist/skills/synthesize.js.map +1 -0
  187. package/dist/subagents/events.d.ts +20 -0
  188. package/dist/subagents/events.d.ts.map +1 -0
  189. package/dist/subagents/events.js +146 -0
  190. package/dist/subagents/events.js.map +1 -0
  191. package/dist/subagents/registry.d.ts +40 -0
  192. package/dist/subagents/registry.d.ts.map +1 -0
  193. package/dist/subagents/registry.js +42 -0
  194. package/dist/subagents/registry.js.map +1 -0
  195. package/dist/subagents/run-child.d.ts +43 -0
  196. package/dist/subagents/run-child.d.ts.map +1 -0
  197. package/dist/subagents/run-child.js +276 -0
  198. package/dist/subagents/run-child.js.map +1 -0
  199. package/dist/subagents/spawn.d.ts +8 -0
  200. package/dist/subagents/spawn.d.ts.map +1 -0
  201. package/dist/subagents/spawn.js +8 -0
  202. package/dist/subagents/spawn.js.map +1 -0
  203. package/dist/subagents/tools.d.ts +4 -0
  204. package/dist/subagents/tools.d.ts.map +1 -0
  205. package/dist/subagents/tools.js +13 -0
  206. package/dist/subagents/tools.js.map +1 -0
  207. package/dist/subagents.d.ts +3 -0
  208. package/dist/subagents.d.ts.map +1 -0
  209. package/dist/subagents.js +3 -0
  210. package/dist/subagents.js.map +1 -0
  211. package/dist/surfaces/host.d.ts +53 -0
  212. package/dist/surfaces/host.d.ts.map +1 -0
  213. package/dist/surfaces/host.js +177 -0
  214. package/dist/surfaces/host.js.map +1 -0
  215. package/dist/tunnel/localhost.d.ts +7 -0
  216. package/dist/tunnel/localhost.d.ts.map +1 -0
  217. package/dist/tunnel/localhost.js +21 -0
  218. package/dist/tunnel/localhost.js.map +1 -0
  219. package/dist/usage-stats.d.ts +42 -0
  220. package/dist/usage-stats.d.ts.map +1 -0
  221. package/dist/usage-stats.js +83 -0
  222. package/dist/usage-stats.js.map +1 -0
  223. package/dist/view/default-renderer.d.ts +7 -0
  224. package/dist/view/default-renderer.d.ts.map +1 -0
  225. package/dist/view/default-renderer.js +14 -0
  226. package/dist/view/default-renderer.js.map +1 -0
  227. package/dist/view/parse.d.ts +7 -0
  228. package/dist/view/parse.d.ts.map +1 -0
  229. package/dist/view/parse.js +479 -0
  230. package/dist/view/parse.js.map +1 -0
  231. package/package.json +61 -0
  232. package/src/events/factory.ts +27 -0
  233. package/src/events/log.test.ts +470 -0
  234. package/src/events/log.ts +256 -0
  235. package/src/index.ts +102 -0
  236. package/src/logger.ts +46 -0
  237. package/src/permissions/engine.test.ts +206 -0
  238. package/src/permissions/engine.ts +247 -0
  239. package/src/permissions/resolvers.test.ts +40 -0
  240. package/src/permissions/resolvers.ts +17 -0
  241. package/src/plugins/discovery.test.ts +115 -0
  242. package/src/plugins/discovery.ts +124 -0
  243. package/src/plugins/host-options.ts +73 -0
  244. package/src/plugins/host-view-tunnel.test.ts +86 -0
  245. package/src/plugins/host.test.ts +434 -0
  246. package/src/plugins/host.ts +327 -0
  247. package/src/plugins/lifecycle.test.ts +187 -0
  248. package/src/plugins/lifecycle.ts +136 -0
  249. package/src/plugins/loader.ts +79 -0
  250. package/src/plugins/package-requirements.ts +50 -0
  251. package/src/plugins/registry-kinds.test.ts +162 -0
  252. package/src/plugins/registry-kinds.ts +210 -0
  253. package/src/plugins/toposort.test.ts +66 -0
  254. package/src/plugins/toposort.ts +69 -0
  255. package/src/preferences.test.ts +105 -0
  256. package/src/preferences.ts +84 -0
  257. package/src/registries/active-backend-registry.ts +142 -0
  258. package/src/registries/active-def-registry.test.ts +103 -0
  259. package/src/registries/active-def-registry.ts +97 -0
  260. package/src/registries/agents.ts +18 -0
  261. package/src/registries/cache-strategies.ts +14 -0
  262. package/src/registries/channels.ts +42 -0
  263. package/src/registries/commands.test.ts +71 -0
  264. package/src/registries/commands.ts +78 -0
  265. package/src/registries/compactors.ts +14 -0
  266. package/src/registries/def-map-registry.test.ts +67 -0
  267. package/src/registries/def-map-registry.ts +59 -0
  268. package/src/registries/embedders.test.ts +54 -0
  269. package/src/registries/embedders.ts +18 -0
  270. package/src/registries/isolators.test.ts +43 -0
  271. package/src/registries/isolators.ts +38 -0
  272. package/src/registries/modes.test.ts +66 -0
  273. package/src/registries/modes.ts +80 -0
  274. package/src/registries/providers.test.ts +50 -0
  275. package/src/registries/providers.ts +97 -0
  276. package/src/registries/semantics.test.ts +117 -0
  277. package/src/registries/skills.ts +87 -0
  278. package/src/registries/surfaces.ts +18 -0
  279. package/src/registries/synthesizers.test.ts +63 -0
  280. package/src/registries/synthesizers.ts +36 -0
  281. package/src/registries/tools.test.ts +142 -0
  282. package/src/registries/tools.ts +145 -0
  283. package/src/registries/transcribers.test.ts +70 -0
  284. package/src/registries/transcribers.ts +19 -0
  285. package/src/registries/tunnel-providers.test.ts +30 -0
  286. package/src/registries/tunnel-providers.ts +14 -0
  287. package/src/registries/view-renderers.test.ts +68 -0
  288. package/src/registries/view-renderers.ts +15 -0
  289. package/src/registries/workflow-executors.test.ts +53 -0
  290. package/src/registries/workflow-executors.ts +15 -0
  291. package/src/requirements.targetinfo.test.ts +70 -0
  292. package/src/requirements.test.ts +111 -0
  293. package/src/requirements.ts +262 -0
  294. package/src/run-turn.test.ts +178 -0
  295. package/src/run-turn.ts +156 -0
  296. package/src/session-runtime.ts +75 -0
  297. package/src/session.test.ts +216 -0
  298. package/src/session.ts +491 -0
  299. package/src/sessions/persistence.flush.test.ts +100 -0
  300. package/src/sessions/persistence.ready.test.ts +91 -0
  301. package/src/sessions/persistence.test.ts +274 -0
  302. package/src/sessions/persistence.ts +498 -0
  303. package/src/setup-agent.test.ts +72 -0
  304. package/src/setup-agent.ts +189 -0
  305. package/src/skills/index.ts +14 -0
  306. package/src/skills/loader.test.ts +102 -0
  307. package/src/skills/loader.ts +114 -0
  308. package/src/skills/parse.test.ts +59 -0
  309. package/src/skills/parse.ts +11 -0
  310. package/src/skills/synthesize-draft.test.ts +79 -0
  311. package/src/skills/synthesize-draft.ts +46 -0
  312. package/src/skills/synthesize.test.ts +170 -0
  313. package/src/skills/synthesize.ts +304 -0
  314. package/src/subagents/events.test.ts +56 -0
  315. package/src/subagents/events.ts +194 -0
  316. package/src/subagents/registry.ts +62 -0
  317. package/src/subagents/run-child.test.ts +185 -0
  318. package/src/subagents/run-child.ts +425 -0
  319. package/src/subagents/spawn.ts +12 -0
  320. package/src/subagents/tools.ts +19 -0
  321. package/src/subagents.ts +2 -0
  322. package/src/surfaces/host.test.ts +330 -0
  323. package/src/surfaces/host.ts +192 -0
  324. package/src/tunnel/localhost.test.ts +22 -0
  325. package/src/tunnel/localhost.ts +21 -0
  326. package/src/usage-stats.test.ts +77 -0
  327. package/src/usage-stats.ts +119 -0
  328. package/src/view/default-renderer.test.ts +29 -0
  329. package/src/view/default-renderer.ts +14 -0
  330. package/src/view/parse-extended.test.ts +255 -0
  331. package/src/view/parse.test.ts +232 -0
  332. package/src/view/parse.ts +520 -0
@@ -0,0 +1,105 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { loadPreferences, preferencesPath, savePreferences } from './preferences.js';
6
+
7
+ // preferencesPath() resolves ~/.moxxy/preferences.json via os.homedir(), which
8
+ // derives from HOME (POSIX) / USERPROFILE (Windows). Point both at a tmpdir per
9
+ // test so the suite never touches the developer's real preferences file.
10
+ let tmpHome: string;
11
+ let savedHome: string | undefined;
12
+ let savedUserProfile: string | undefined;
13
+
14
+ beforeEach(async () => {
15
+ tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-'));
16
+ savedHome = process.env.HOME;
17
+ savedUserProfile = process.env.USERPROFILE;
18
+ process.env.HOME = tmpHome;
19
+ process.env.USERPROFILE = tmpHome;
20
+ await fs.mkdir(path.join(tmpHome, '.moxxy'), { recursive: true });
21
+ });
22
+
23
+ afterEach(async () => {
24
+ if (savedHome === undefined) delete process.env.HOME;
25
+ else process.env.HOME = savedHome;
26
+ if (savedUserProfile === undefined) delete process.env.USERPROFILE;
27
+ else process.env.USERPROFILE = savedUserProfile;
28
+ await fs.rm(tmpHome, { recursive: true, force: true });
29
+ });
30
+
31
+ const readRaw = async (): Promise<unknown> =>
32
+ JSON.parse(await fs.readFile(preferencesPath(), 'utf8'));
33
+
34
+ describe('preferences store', () => {
35
+ it('resolves the path under the (overridden) home dir', () => {
36
+ expect(preferencesPath()).toBe(path.join(tmpHome, '.moxxy', 'preferences.json'));
37
+ });
38
+
39
+ it('returns an empty object when the file is missing', async () => {
40
+ expect(await loadPreferences()).toEqual({});
41
+ });
42
+
43
+ it('returns an empty object when the file is corrupt', async () => {
44
+ await fs.writeFile(preferencesPath(), '{ not json', 'utf8');
45
+ expect(await loadPreferences()).toEqual({});
46
+ });
47
+
48
+ it('round-trips a patch through disk', async () => {
49
+ await savePreferences({ model: 'gpt-5.4-mini', providerName: 'openai' });
50
+ const loaded = await loadPreferences();
51
+ expect(loaded.model).toBe('gpt-5.4-mini');
52
+ expect(loaded.providerName).toBe('openai');
53
+ });
54
+
55
+ it('merges a second patch without clobbering unrelated fields', async () => {
56
+ await savePreferences({ providerName: 'openai' });
57
+ await savePreferences({ model: 'gpt-5.4-mini' });
58
+ const loaded = await loadPreferences();
59
+ expect(loaded.providerName).toBe('openai');
60
+ expect(loaded.model).toBe('gpt-5.4-mini');
61
+ });
62
+
63
+ it('migrates legacy mode names on load', async () => {
64
+ // "tool-use" is the legacy id that migrateModeName maps to "default".
65
+ await fs.writeFile(
66
+ preferencesPath(),
67
+ JSON.stringify({ mode: 'tool-use' }) + '\n',
68
+ 'utf8',
69
+ );
70
+ const loaded = await loadPreferences();
71
+ expect(loaded.mode).toBe('default');
72
+ });
73
+
74
+ it('writes a trailing newline atomically (no partial file)', async () => {
75
+ await savePreferences({ model: 'm1' });
76
+ const raw = await fs.readFile(preferencesPath(), 'utf8');
77
+ expect(raw.endsWith('\n')).toBe(true);
78
+ expect(JSON.parse(raw)).toEqual({ model: 'm1' });
79
+ });
80
+
81
+ it('serializes overlapping saves so no update is lost (invariant #5)', async () => {
82
+ // Two patches firing concurrently each read-merge-write the same file.
83
+ // Without the mutex the later writer clobbers the earlier writer's field.
84
+ await Promise.all([
85
+ savePreferences({ model: 'a' }),
86
+ savePreferences({ mode: 'goal' }),
87
+ ]);
88
+ const loaded = await loadPreferences();
89
+ expect(loaded.model).toBe('a');
90
+ expect(loaded.mode).toBe('goal');
91
+ });
92
+
93
+ it('keeps ALL distinct keys present under many overlapping writers', async () => {
94
+ const patches: Array<Partial<Record<`k${number}`, string>>> = [];
95
+ for (let i = 0; i < 25; i++) patches.push({ [`k${i}`]: `v${i}` });
96
+ // Fire all saves at once; each merges a distinct key.
97
+ await Promise.all(
98
+ patches.map((p) => savePreferences(p as Record<string, string>)),
99
+ );
100
+ const loaded = (await readRaw()) as Record<string, string>;
101
+ for (let i = 0; i < 25; i++) {
102
+ expect(loaded[`k${i}`]).toBe(`v${i}`);
103
+ }
104
+ });
105
+ });
@@ -0,0 +1,84 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { createMutex, migrateModeName } from '@moxxy/sdk';
5
+ import { writeFileAtomic } from '@moxxy/sdk/server';
6
+
7
+ /**
8
+ * User-level runtime preferences persisted at ~/.moxxy/preferences.json.
9
+ * Distinct from `~/.moxxy/config.yaml` (user-edited, source of truth for
10
+ * provider/plugin wiring) — this file is mutated by the TUI as the user
11
+ * picks a model / loop strategy via slash commands, so it survives across
12
+ * CLI invocations. A missing file or unreadable contents is a no-op:
13
+ * preferences are best-effort, never load-blocking.
14
+ */
15
+ export interface MoxxyPreferences {
16
+ /** Active provider name (e.g. "openai", "anthropic"). */
17
+ readonly providerName?: string;
18
+ /** Active model id under that provider (e.g. "gpt-5.4-mini"). */
19
+ readonly model?: string;
20
+ /** Active loop strategy name (e.g. "default", "research", "goal"). */
21
+ readonly mode?: string;
22
+ /**
23
+ * Providers the user disabled (desktop Settings → Providers toggle).
24
+ * Seeded into the ProviderRegistry at boot; a listed provider stays
25
+ * registered but can't be activated until re-enabled.
26
+ */
27
+ readonly disabledProviders?: ReadonlyArray<string>;
28
+ }
29
+
30
+ export function preferencesPath(): string {
31
+ return path.join(os.homedir(), '.moxxy', 'preferences.json');
32
+ }
33
+
34
+ /**
35
+ * Read preferences from disk. Returns an empty object when the file
36
+ * doesn't exist or fails to parse — preferences are an optional layer,
37
+ * never a hard dependency of session bootstrap.
38
+ */
39
+ export async function loadPreferences(): Promise<MoxxyPreferences> {
40
+ try {
41
+ const raw = await fs.readFile(preferencesPath(), 'utf8');
42
+ const parsed = JSON.parse(raw) as unknown;
43
+ if (parsed && typeof parsed === 'object') {
44
+ const prefs = parsed as MoxxyPreferences;
45
+ return prefs.mode ? { ...prefs, mode: migrateModeName(prefs.mode) } : prefs;
46
+ }
47
+ return {};
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ // Serializes the read-modify-write in `savePreferences` so two concurrent
54
+ // saves (e.g. a /model pick and a provider-disable toggle firing close
55
+ // together) can't both read the same snapshot and have the second write
56
+ // clobber the first's field. This is the single write path for the file —
57
+ // other packages (CLI, runner, channels) route their fire-and-forget saves
58
+ // through it, and any load-modify-save must go through it too.
59
+ const writeMutex = createMutex();
60
+
61
+ /**
62
+ * Merge-and-write preferences. Reads the current file (if any), merges
63
+ * the patch on top so we don't blow away unrelated fields, and writes
64
+ * the result back atomically. The whole read-merge-write runs inside a
65
+ * module-level mutex so overlapping saves serialize (invariant #5) and
66
+ * never lose an update. Best-effort: a write failure logs to stderr but
67
+ * does not throw — the user's pick still takes effect in this session,
68
+ * just won't persist across invocations.
69
+ */
70
+ export async function savePreferences(patch: Partial<MoxxyPreferences>): Promise<void> {
71
+ return writeMutex.run(async () => {
72
+ const current = await loadPreferences();
73
+ const next: MoxxyPreferences = { ...current, ...patch };
74
+ const target = preferencesPath();
75
+ try {
76
+ await writeFileAtomic(target, JSON.stringify(next, null, 2) + '\n');
77
+ } catch (err) {
78
+ process.stderr.write(
79
+ `moxxy: failed to persist preferences to ${target}: ` +
80
+ `${err instanceof Error ? err.message : String(err)}\n`,
81
+ );
82
+ }
83
+ });
84
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Shared base for the "single active backend" registries (speech-to-text,
3
+ * text-to-speech, embeddings). Each one is a Map of named defs with at most one
4
+ * *active* at a time:
5
+ * - plugins call `register(def)` at load time
6
+ * - the host / CLI / agent calls `setActive(name, config)` to pick one
7
+ * - call sites read `getActive()` / `tryGetActive()` to use it (degrading
8
+ * gracefully when none is active)
9
+ *
10
+ * The three concrete registries differ only along a few axes, expressed via
11
+ * {@link BackendRegistryOptions}:
12
+ * - how a def becomes an instance (`build`) — `createClient(config)` for
13
+ * STT/embeddings, `create({ config, getSecret })` for TTS
14
+ * - whether the first registered def auto-becomes active (`autoAdoptFirst`,
15
+ * TTS only — STT/embeddings require an explicit `setActive`)
16
+ * - whether the active instance is (re)built lazily on read (`buildOnRead`,
17
+ * TTS only — needed because `autoAdoptFirst` can set `active` without ever
18
+ * building an instance; it also makes `setActive` rebuild when handed a
19
+ * fresh config)
20
+ *
21
+ * `ProviderRegistry` deliberately stays separate: it is the core LLM path and
22
+ * carries its own ready-state semantics.
23
+ */
24
+ export interface BackendRegistryOptions<TDef extends { name: string }, TInstance> {
25
+ /** Capitalised singular noun for error messages, e.g. `'Synthesizer'`. */
26
+ readonly noun: string;
27
+ /** Turn a def + (possibly empty) config into a runtime instance. */
28
+ readonly build: (def: TDef, config: Record<string, unknown>) => TInstance;
29
+ /** Adopt the first registered def as active when nothing is active yet. */
30
+ readonly autoAdoptFirst?: boolean;
31
+ /** (Re)build the active instance lazily on read rather than requiring it to
32
+ * have been built at `setActive` time. Also makes `setActive` rebuild when a
33
+ * config is supplied. Pair with {@link autoAdoptFirst}. */
34
+ readonly buildOnRead?: boolean;
35
+ }
36
+
37
+ export class ActiveBackendRegistry<TDef extends { name: string }, TInstance> {
38
+ private readonly defs = new Map<string, TDef>();
39
+ private readonly instances = new Map<string, TInstance>();
40
+ private active: string | null = null;
41
+
42
+ constructor(private readonly opts: BackendRegistryOptions<TDef, TInstance>) {}
43
+
44
+ register(def: TDef, instance?: TInstance): void {
45
+ if (this.defs.has(def.name)) {
46
+ throw new Error(`${this.opts.noun} already registered: ${def.name}`);
47
+ }
48
+ this.defs.set(def.name, def);
49
+ if (instance) this.instances.set(def.name, instance);
50
+ if (this.opts.autoAdoptFirst && this.active === null) this.active = def.name;
51
+ }
52
+
53
+ /** Overwrite an existing def, dropping the cached instance so the next build
54
+ * uses the new def. */
55
+ replace(def: TDef, instance?: TInstance): void {
56
+ this.defs.set(def.name, def);
57
+ this.instances.delete(def.name);
58
+ if (instance) this.instances.set(def.name, instance);
59
+ }
60
+
61
+ unregister(name: string): void {
62
+ this.defs.delete(name);
63
+ this.instances.delete(name);
64
+ if (this.active === name) this.active = null;
65
+ }
66
+
67
+ list(): ReadonlyArray<TDef> {
68
+ return [...this.defs.values()];
69
+ }
70
+
71
+ has(name: string): boolean {
72
+ return this.defs.has(name);
73
+ }
74
+
75
+ setActive(name: string, config?: Record<string, unknown>): TInstance {
76
+ if (!this.defs.has(name)) throw new Error(`${this.opts.noun} not registered: ${name}`);
77
+ if (this.opts.buildOnRead) {
78
+ // Supplying a config (re)builds with it; otherwise reuse / lazily build.
79
+ if (config) this.instances.set(name, this.buildInstance(name, config));
80
+ this.active = name;
81
+ return this.instantiate(name);
82
+ }
83
+ // Build once, on first activation; later activations reuse the cache and
84
+ // ignore `config`.
85
+ let instance = this.instances.get(name);
86
+ if (!instance) {
87
+ instance = this.buildInstance(name, config);
88
+ this.instances.set(name, instance);
89
+ }
90
+ this.active = name;
91
+ return instance;
92
+ }
93
+
94
+ /** Deactivate the current backend (callers fall back to their default). */
95
+ clearActive(): void {
96
+ this.active = null;
97
+ }
98
+
99
+ getActive(): TInstance {
100
+ if (!this.active) {
101
+ throw new Error(`No active ${this.opts.noun.toLowerCase()}. Call setActive(name) first.`);
102
+ }
103
+ if (this.opts.buildOnRead) return this.instantiate(this.active);
104
+ const inst = this.instances.get(this.active);
105
+ if (!inst) throw new Error(`Active ${this.opts.noun.toLowerCase()} has no instance: ${this.active}`);
106
+ return inst;
107
+ }
108
+
109
+ /** Active backend, or null when none is configured. Lets call sites degrade
110
+ * gracefully (keyword recall, the OS voice, "no transcriber configured"). */
111
+ tryGetActive(): TInstance | null {
112
+ if (!this.active) return null;
113
+ if (this.opts.buildOnRead) {
114
+ try {
115
+ return this.instantiate(this.active);
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ return this.instances.get(this.active) ?? null;
121
+ }
122
+
123
+ getActiveName(): string | null {
124
+ return this.active;
125
+ }
126
+
127
+ /** Cached instance for `name`, building it from its def on first use. */
128
+ private instantiate(name: string): TInstance {
129
+ let inst = this.instances.get(name);
130
+ if (!inst) {
131
+ inst = this.buildInstance(name);
132
+ this.instances.set(name, inst);
133
+ }
134
+ return inst;
135
+ }
136
+
137
+ private buildInstance(name: string, config?: Record<string, unknown>): TInstance {
138
+ const def = this.defs.get(name);
139
+ if (!def) throw new Error(`${this.opts.noun} not registered: ${name}`);
140
+ return this.opts.build(def, config ?? {});
141
+ }
142
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ActiveDefRegistry } from './active-def-registry.js';
3
+
4
+ /**
5
+ * Contract test for the shared base behind compactors, cache-strategies,
6
+ * view-renderers, tunnel-providers and workflow-executors. The five concrete
7
+ * subclasses keep thin sanity tests; the invariants live here once.
8
+ */
9
+
10
+ interface Thing {
11
+ name: string;
12
+ v?: number;
13
+ }
14
+ const mk = (name: string, v?: number): Thing => ({ name, v });
15
+
16
+ describe('ActiveDefRegistry (shared base)', () => {
17
+ it('getActive() is null when empty', () => {
18
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
19
+ expect(r.getActive()).toBeNull();
20
+ expect(r.getActiveName()).toBeNull();
21
+ expect(r.list()).toEqual([]);
22
+ });
23
+
24
+ it('auto-activates the first registered def and keeps it on later registers', () => {
25
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
26
+ r.register(mk('a'));
27
+ expect(r.getActive()?.name).toBe('a');
28
+ expect(r.getActiveName()).toBe('a');
29
+ r.register(mk('b'));
30
+ expect(r.getActive()?.name).toBe('a'); // still the first
31
+ });
32
+
33
+ it('throws the exact noun-prefixed message on duplicate register', () => {
34
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
35
+ r.register(mk('a'));
36
+ expect(() => r.register(mk('a'))).toThrow('Thing already registered: a');
37
+ });
38
+
39
+ it('replace overwrites without throwing and keeps active', () => {
40
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
41
+ r.register(mk('a', 1));
42
+ const replacement = mk('a', 2);
43
+ r.replace(replacement);
44
+ expect(r.getActive()).toBe(replacement);
45
+ expect(r.getActive()?.v).toBe(2);
46
+ expect(r.list()).toHaveLength(1);
47
+ });
48
+
49
+ it('replace on empty registry auto-activates', () => {
50
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
51
+ r.replace(mk('a'));
52
+ expect(r.getActive()?.name).toBe('a');
53
+ });
54
+
55
+ it('setActive switches; throws the exact noun-prefixed message for unknown', () => {
56
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
57
+ r.register(mk('a'));
58
+ r.register(mk('b'));
59
+ r.setActive('b');
60
+ expect(r.getActive()?.name).toBe('b');
61
+ expect(() => r.setActive('nope')).toThrow('Thing not registered: nope');
62
+ });
63
+
64
+ it('unregister clears the active slot rather than picking a successor', () => {
65
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
66
+ r.register(mk('a'));
67
+ r.register(mk('b'));
68
+ r.unregister('a');
69
+ expect(r.getActive()).toBeNull();
70
+ expect(r.getActiveName()).toBeNull();
71
+ expect(r.list().map((x) => x.name)).toEqual(['b']);
72
+ });
73
+
74
+ it('unregistering a non-active def leaves active intact', () => {
75
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
76
+ r.register(mk('a'));
77
+ r.register(mk('b'));
78
+ r.unregister('b');
79
+ expect(r.getActive()?.name).toBe('a');
80
+ });
81
+
82
+ it('has() reflects membership; clearActive() deactivates without removing', () => {
83
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
84
+ r.register(mk('a'));
85
+ expect(r.has('a')).toBe(true);
86
+ expect(r.has('z')).toBe(false);
87
+ r.clearActive();
88
+ expect(r.getActive()).toBeNull();
89
+ expect(r.getActiveName()).toBeNull();
90
+ expect(r.has('a')).toBe(true); // still registered, just not active
91
+ r.setActive('a');
92
+ expect(r.getActive()?.name).toBe('a');
93
+ });
94
+
95
+ it('autoAdoptFirst:false leaves nothing active until setActive', () => {
96
+ const r = new ActiveDefRegistry<Thing>({ noun: 'Thing', autoAdoptFirst: false });
97
+ r.register(mk('a'));
98
+ r.replace(mk('b'));
99
+ expect(r.getActive()).toBeNull();
100
+ r.setActive('a');
101
+ expect(r.getActive()?.name).toBe('a');
102
+ });
103
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared base for the "single active def, no built instance" registries
3
+ * (compactors, cache strategies, view renderers, tunnel providers, workflow
4
+ * executors). Each is a `Map<name, def>` with at most one *active* def at a
5
+ * time:
6
+ * - plugins call `register(def)` at load time (throws on duplicate)
7
+ * - the first registration auto-activates (when `autoAdoptFirst`, the
8
+ * default), so a session always has a default once a plugin loads one
9
+ * - the host / agent calls `setActive(name)` to switch
10
+ * - call sites read `getActive()` (returns null when nothing is active)
11
+ * - `unregister` clears the active slot rather than silently picking an
12
+ * arbitrary successor — callers must `setActive()` again
13
+ *
14
+ * This is the def-only sibling of {@link ActiveBackendRegistry} (which also
15
+ * builds runtime *instances* for STT/TTS/embeddings). The two families share
16
+ * the same surface (register/replace/unregister/list/has/setActive/getActive/
17
+ * getActiveName/clearActive) so they stay consistent.
18
+ *
19
+ * `ModeRegistry` (change-listeners + legacy-name migration) and
20
+ * `ProviderRegistry` (disabled-set + built instances) deliberately stay
21
+ * bespoke rather than extend this base.
22
+ */
23
+ export interface ActiveDefRegistryOptions {
24
+ /** Capitalised singular noun for error messages, e.g. `'Compactor'`. */
25
+ readonly noun: string;
26
+ /** Adopt the first registered def as active when nothing is active yet.
27
+ * Defaults to `true`. */
28
+ readonly autoAdoptFirst?: boolean;
29
+ }
30
+
31
+ export class ActiveDefRegistry<TDef extends { name: string }> {
32
+ private readonly defs = new Map<string, TDef>();
33
+ private active: string | null = null;
34
+ private readonly noun: string;
35
+ private readonly autoAdoptFirst: boolean;
36
+
37
+ constructor(opts: ActiveDefRegistryOptions) {
38
+ this.noun = opts.noun;
39
+ this.autoAdoptFirst = opts.autoAdoptFirst ?? true;
40
+ }
41
+
42
+ /**
43
+ * Register a def. Throws on duplicate — use `replace()` to overwrite.
44
+ * Auto-activates the first registration (when `autoAdoptFirst`).
45
+ */
46
+ register(def: TDef): void {
47
+ if (this.defs.has(def.name)) {
48
+ throw new Error(`${this.noun} already registered: ${def.name}`);
49
+ }
50
+ this.defs.set(def.name, def);
51
+ if (this.autoAdoptFirst && !this.active) this.active = def.name;
52
+ }
53
+
54
+ /** Overwrite an existing def (or add a new one) without throwing.
55
+ * Auto-activates when nothing is active yet (mirrors `register`). */
56
+ replace(def: TDef): void {
57
+ this.defs.set(def.name, def);
58
+ if (this.autoAdoptFirst && !this.active) this.active = def.name;
59
+ }
60
+
61
+ /**
62
+ * Remove a def. If it was active, the active slot is cleared (callers must
63
+ * `setActive()` rather than getting an arbitrary "next").
64
+ */
65
+ unregister(name: string): void {
66
+ this.defs.delete(name);
67
+ if (this.active === name) this.active = null;
68
+ }
69
+
70
+ list(): ReadonlyArray<TDef> {
71
+ return [...this.defs.values()];
72
+ }
73
+
74
+ has(name: string): boolean {
75
+ return this.defs.has(name);
76
+ }
77
+
78
+ setActive(name: string): void {
79
+ const def = this.defs.get(name);
80
+ if (!def) throw new Error(`${this.noun} not registered: ${name}`);
81
+ this.active = def.name;
82
+ }
83
+
84
+ /** Deactivate the current def (callers fall back to their default). */
85
+ clearActive(): void {
86
+ this.active = null;
87
+ }
88
+
89
+ getActive(): TDef | null {
90
+ if (!this.active) return null;
91
+ return this.defs.get(this.active) ?? null;
92
+ }
93
+
94
+ getActiveName(): string | null {
95
+ return this.active;
96
+ }
97
+ }
@@ -0,0 +1,18 @@
1
+ import type { AgentDef } from '@moxxy/sdk';
2
+ import { DefMapRegistry } from './def-map-registry.js';
3
+
4
+ /**
5
+ * Registry of named subagent kinds contributed by plugins. The
6
+ * `dispatch_agent` tool looks definitions up here by `agentType` and
7
+ * uses them as templates (systemPrompt, allowedTools, mode, …)
8
+ * for the spawned child.
9
+ *
10
+ * A flat name→def map ({@link DefMapRegistry}): register throws on
11
+ * duplicate so two plugins can't silently shadow each other — use
12
+ * `replace()` when you really want to override (e.g. user-config overrides).
13
+ */
14
+ export class AgentRegistry extends DefMapRegistry<AgentDef> {
15
+ constructor() {
16
+ super({ noun: 'Agent', keyOf: (def) => def.name });
17
+ }
18
+ }
@@ -0,0 +1,14 @@
1
+ import type { CacheStrategyDef } from '@moxxy/sdk';
2
+ import { ActiveDefRegistry } from './active-def-registry.js';
3
+
4
+ /**
5
+ * One active prompt-caching strategy per session. Register throws on
6
+ * duplicate, auto-activates the first, and `unregister` clears the active
7
+ * slot rather than picking an arbitrary successor. See
8
+ * {@link ActiveDefRegistry}.
9
+ */
10
+ export class CacheStrategyRegistry extends ActiveDefRegistry<CacheStrategyDef> {
11
+ constructor() {
12
+ super({ noun: 'Cache strategy' });
13
+ }
14
+ }
@@ -0,0 +1,42 @@
1
+ import type {
2
+ ChannelAvailability,
3
+ ChannelDef,
4
+ ChannelFactoryDeps,
5
+ ChannelRegistry,
6
+ } from '@moxxy/sdk';
7
+ import { DefMapRegistry } from './def-map-registry.js';
8
+
9
+ /**
10
+ * Flat name→def registry of channels ({@link DefMapRegistry}) plus
11
+ * `listWithAvailability`, which pairs each channel with its current
12
+ * availability (treating channels without an `isAvailable` hook as
13
+ * `{ ok: true }` and mapping a thrown probe to `{ ok: false }`).
14
+ */
15
+ export class ChannelRegistryImpl extends DefMapRegistry<ChannelDef> implements ChannelRegistry {
16
+ constructor() {
17
+ super({ noun: 'Channel', keyOf: (def) => def.name });
18
+ }
19
+
20
+ async listWithAvailability(
21
+ deps: ChannelFactoryDeps,
22
+ ): Promise<ReadonlyArray<{ def: ChannelDef; availability: ChannelAvailability }>> {
23
+ const out: Array<{ def: ChannelDef; availability: ChannelAvailability }> = [];
24
+ for (const def of this.defs.values()) {
25
+ let availability: ChannelAvailability;
26
+ if (def.isAvailable) {
27
+ try {
28
+ availability = await def.isAvailable(deps);
29
+ } catch (err) {
30
+ availability = {
31
+ ok: false,
32
+ reason: err instanceof Error ? err.message : String(err),
33
+ };
34
+ }
35
+ } else {
36
+ availability = { ok: true };
37
+ }
38
+ out.push({ def, availability });
39
+ }
40
+ return out;
41
+ }
42
+ }
@@ -0,0 +1,71 @@
1
+ import type { CommandDef } from '@moxxy/sdk';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { CommandRegistry } from './commands.js';
4
+
5
+ function fakeCommand(name: string, extra: Partial<CommandDef> = {}): CommandDef {
6
+ return {
7
+ name,
8
+ description: `cmd ${name}`,
9
+ handler: () => ({ kind: 'text', text: name }),
10
+ ...extra,
11
+ } as CommandDef;
12
+ }
13
+
14
+ describe('CommandRegistry', () => {
15
+ it('registers, looks up by name and alias, lists, and unregisters', () => {
16
+ const reg = new CommandRegistry();
17
+ reg.register(fakeCommand('help', { aliases: ['h', '?'] }));
18
+ expect(reg.has('help')).toBe(true);
19
+ expect(reg.get('help')?.name).toBe('help');
20
+ expect(reg.get('h')?.name).toBe('help');
21
+ expect(reg.get('?')?.name).toBe('help');
22
+ expect(reg.list().map((c) => c.name)).toEqual(['help']);
23
+ reg.unregister('help');
24
+ expect(reg.has('help')).toBe(false);
25
+ expect(reg.get('h')).toBeUndefined();
26
+ });
27
+
28
+ it('throws on a duplicate primary name', () => {
29
+ const reg = new CommandRegistry();
30
+ reg.register(fakeCommand('new'));
31
+ expect(() => reg.register(fakeCommand('new'))).toThrow(/already registered/);
32
+ });
33
+
34
+ it('throws on an alias already claimed by another command (as alias or name)', () => {
35
+ const reg = new CommandRegistry();
36
+ reg.register(fakeCommand('compact', { aliases: ['c'] }));
37
+ expect(() => reg.register(fakeCommand('clear', { aliases: ['c'] }))).toThrow(
38
+ /alias already in use/,
39
+ );
40
+ expect(() => reg.register(fakeCommand('compact'))).toThrow(/already registered/);
41
+ });
42
+
43
+ it('throws when a new primary name collides with an existing alias', () => {
44
+ const reg = new CommandRegistry();
45
+ reg.register(fakeCommand('compact', { aliases: ['c'] }));
46
+ // 'c' is already an alias of /compact; registering a command literally
47
+ // named 'c' must NOT silently shadow it via direct-lookup precedence.
48
+ expect(() => reg.register(fakeCommand('c'))).toThrow(/already in use as an alias/);
49
+ });
50
+
51
+ it('replace() upserts and cleans up the prior definition aliases', () => {
52
+ const reg = new CommandRegistry();
53
+ reg.register(fakeCommand('mode', { aliases: ['m'] }));
54
+ reg.replace(fakeCommand('mode', { aliases: ['md'], description: 'updated' }));
55
+ expect(reg.get('mode')?.description).toBe('updated');
56
+ expect(reg.get('md')?.name).toBe('mode');
57
+ // Old alias is cleaned up.
58
+ expect(reg.get('m')).toBeUndefined();
59
+ });
60
+
61
+ it('listForChannel filters channel-scoped commands', () => {
62
+ const reg = new CommandRegistry();
63
+ reg.register(fakeCommand('everywhere'));
64
+ reg.register(fakeCommand('tuionly', { channels: ['tui'] }));
65
+ expect(reg.listForChannel('tui').map((c) => c.name).sort()).toEqual([
66
+ 'everywhere',
67
+ 'tuionly',
68
+ ]);
69
+ expect(reg.listForChannel('telegram').map((c) => c.name)).toEqual(['everywhere']);
70
+ });
71
+ });