opencode-dux 1.0.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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
@@ -0,0 +1,795 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { PluginConfig } from '../config';
6
+ import {
7
+ getActiveRuntimePreset,
8
+ setActiveRuntimePreset,
9
+ } from '../config/runtime-preset';
10
+ import { createPresetManager } from './preset-manager';
11
+
12
+ function createMockContext() {
13
+ const configUpdate = mock(async () => ({}));
14
+ return {
15
+ client: {
16
+ config: {
17
+ update: configUpdate,
18
+ },
19
+ },
20
+ directory: '/tmp/test',
21
+ } as any;
22
+ }
23
+
24
+ function createOutput() {
25
+ return { parts: [] as Array<{ type: string; text?: string }> };
26
+ }
27
+
28
+ function getOutputText(output: ReturnType<typeof createOutput>): string {
29
+ return output.parts
30
+ .filter((p) => p.type === 'text')
31
+ .map((p) => p.text ?? '')
32
+ .join('\n');
33
+ }
34
+
35
+ let previousXdgDataHome: string | undefined;
36
+ let tempDir: string;
37
+
38
+ beforeEach(() => {
39
+ previousXdgDataHome = process.env.XDG_DATA_HOME;
40
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-preset-manager-'));
41
+ process.env.XDG_DATA_HOME = tempDir;
42
+ setActiveRuntimePreset(null);
43
+ });
44
+
45
+ afterEach(() => {
46
+ if (previousXdgDataHome === undefined) {
47
+ delete process.env.XDG_DATA_HOME;
48
+ } else {
49
+ process.env.XDG_DATA_HOME = previousXdgDataHome;
50
+ }
51
+
52
+ fs.rmSync(tempDir, { recursive: true, force: true });
53
+ setActiveRuntimePreset(null);
54
+ });
55
+
56
+ describe('createPresetManager', () => {
57
+ describe('handleCommandExecuteBefore', () => {
58
+ test('ignores non-preset commands', async () => {
59
+ const ctx = createMockContext();
60
+ const config: PluginConfig = {};
61
+ const manager = createPresetManager(ctx, config);
62
+ const output = createOutput();
63
+
64
+ await manager.handleCommandExecuteBefore(
65
+ { command: 'auto-continue', sessionID: 's1', arguments: 'on' },
66
+ output,
67
+ );
68
+
69
+ expect(output.parts).toHaveLength(0);
70
+ expect(ctx.client.config.update).not.toHaveBeenCalled();
71
+ });
72
+
73
+ test('lists available presets when no argument given', async () => {
74
+ const ctx = createMockContext();
75
+ const config: PluginConfig = {
76
+ presets: {
77
+ cheap: {
78
+ orchestrator: { model: 'anthropic/claude-3.5-haiku' },
79
+ },
80
+ powerful: {
81
+ orchestrator: { model: 'openai/gpt-5.5' },
82
+ },
83
+ },
84
+ };
85
+ const manager = createPresetManager(ctx, config);
86
+ const output = createOutput();
87
+
88
+ await manager.handleCommandExecuteBefore(
89
+ { command: 'preset', sessionID: 's1', arguments: '' },
90
+ output,
91
+ );
92
+
93
+ const text = getOutputText(output);
94
+ expect(text).toContain('cheap');
95
+ expect(text).toContain('powerful');
96
+ expect(ctx.client.config.update).not.toHaveBeenCalled();
97
+ });
98
+
99
+ test('lists presets with active marker when preset is set', async () => {
100
+ const ctx = createMockContext();
101
+ const config: PluginConfig = {
102
+ preset: 'cheap',
103
+ presets: {
104
+ cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
105
+ powerful: { orchestrator: { model: 'openai/gpt-5.5' } },
106
+ },
107
+ };
108
+ const manager = createPresetManager(ctx, config);
109
+ const output = createOutput();
110
+
111
+ await manager.handleCommandExecuteBefore(
112
+ { command: 'preset', sessionID: 's1', arguments: '' },
113
+ output,
114
+ );
115
+
116
+ const text = getOutputText(output);
117
+ expect(text).toContain('← active');
118
+ });
119
+
120
+ test('shows no-presets message when none configured', async () => {
121
+ const ctx = createMockContext();
122
+ const config: PluginConfig = {};
123
+ const manager = createPresetManager(ctx, config);
124
+ const output = createOutput();
125
+
126
+ await manager.handleCommandExecuteBefore(
127
+ { command: 'preset', sessionID: 's1', arguments: '' },
128
+ output,
129
+ );
130
+
131
+ const text = getOutputText(output);
132
+ expect(text).toContain('No presets configured');
133
+ });
134
+
135
+ test('switches preset and calls config.update', async () => {
136
+ const ctx = createMockContext();
137
+ const config: PluginConfig = {
138
+ presets: {
139
+ cheap: {
140
+ orchestrator: { model: 'anthropic/claude-3.5-haiku' },
141
+ explorer: { model: 'openai/gpt-5.4-mini' },
142
+ },
143
+ },
144
+ };
145
+ const manager = createPresetManager(ctx, config);
146
+ const output = createOutput();
147
+
148
+ await manager.handleCommandExecuteBefore(
149
+ { command: 'preset', sessionID: 's1', arguments: 'cheap' },
150
+ output,
151
+ );
152
+
153
+ const text = getOutputText(output);
154
+ expect(text).toContain('Switched to preset "cheap"');
155
+ expect(text).toContain('orchestrator');
156
+ expect(text).toContain('anthropic/claude-3.5-haiku');
157
+ expect(text).toContain('explorer');
158
+ expect(ctx.client.config.update).toHaveBeenCalledTimes(1);
159
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
160
+ body: {
161
+ agent: {
162
+ orchestrator: { model: 'anthropic/claude-3.5-haiku' },
163
+ explorer: { model: 'openai/gpt-5.4-mini' },
164
+ },
165
+ },
166
+ });
167
+ });
168
+
169
+ test('passes temperature in config update', async () => {
170
+ const ctx = createMockContext();
171
+ const config: PluginConfig = {
172
+ presets: {
173
+ precise: {
174
+ orchestrator: { model: 'openai/o3', temperature: 0.1 },
175
+ },
176
+ },
177
+ };
178
+ const manager = createPresetManager(ctx, config);
179
+ const output = createOutput();
180
+
181
+ await manager.handleCommandExecuteBefore(
182
+ { command: 'preset', sessionID: 's1', arguments: 'precise' },
183
+ output,
184
+ );
185
+
186
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
187
+ body: {
188
+ agent: {
189
+ orchestrator: { model: 'openai/o3', temperature: 0.1 },
190
+ },
191
+ },
192
+ });
193
+ });
194
+
195
+ test('passes variant in config update', async () => {
196
+ const ctx = createMockContext();
197
+ const config: PluginConfig = {
198
+ presets: {
199
+ thinker: {
200
+ oracle: {
201
+ model: 'anthropic/claude-sonnet-4-6',
202
+ variant: 'thinking',
203
+ },
204
+ },
205
+ },
206
+ };
207
+ const manager = createPresetManager(ctx, config);
208
+ const output = createOutput();
209
+
210
+ await manager.handleCommandExecuteBefore(
211
+ { command: 'preset', sessionID: 's1', arguments: 'thinker' },
212
+ output,
213
+ );
214
+
215
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
216
+ body: {
217
+ agent: {
218
+ oracle: {
219
+ model: 'anthropic/claude-sonnet-4-6',
220
+ variant: 'thinking',
221
+ },
222
+ },
223
+ },
224
+ });
225
+ });
226
+
227
+ test('shows error for unknown preset name', async () => {
228
+ const ctx = createMockContext();
229
+ const config: PluginConfig = {
230
+ presets: {
231
+ cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
232
+ },
233
+ };
234
+ const manager = createPresetManager(ctx, config);
235
+ const output = createOutput();
236
+
237
+ await manager.handleCommandExecuteBefore(
238
+ { command: 'preset', sessionID: 's1', arguments: 'nonexistent' },
239
+ output,
240
+ );
241
+
242
+ const text = getOutputText(output);
243
+ expect(text).toContain('not found');
244
+ expect(text).toContain('cheap');
245
+ expect(ctx.client.config.update).not.toHaveBeenCalled();
246
+ });
247
+
248
+ test('shows error when no presets configured but argument given', async () => {
249
+ const ctx = createMockContext();
250
+ const config: PluginConfig = {};
251
+ const manager = createPresetManager(ctx, config);
252
+ const output = createOutput();
253
+
254
+ await manager.handleCommandExecuteBefore(
255
+ { command: 'preset', sessionID: 's1', arguments: 'cheap' },
256
+ output,
257
+ );
258
+
259
+ const text = getOutputText(output);
260
+ expect(text).toContain('not found');
261
+ expect(text).toContain('No presets configured');
262
+ });
263
+
264
+ test('handles config.update error gracefully', async () => {
265
+ const ctx = createMockContext();
266
+ ctx.client.config.update = mock(async () => {
267
+ throw new Error('Server unavailable');
268
+ });
269
+ const config: PluginConfig = {
270
+ presets: {
271
+ cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
272
+ },
273
+ };
274
+ const manager = createPresetManager(ctx, config);
275
+ const output = createOutput();
276
+
277
+ await manager.handleCommandExecuteBefore(
278
+ { command: 'preset', sessionID: 's1', arguments: 'cheap' },
279
+ output,
280
+ );
281
+
282
+ const text = getOutputText(output);
283
+ expect(text).toContain('Failed to switch preset');
284
+ expect(text).toContain('Server unavailable');
285
+ });
286
+
287
+ test('shows empty preset message when preset has no valid overrides', async () => {
288
+ const ctx = createMockContext();
289
+ const config: PluginConfig = {
290
+ presets: {
291
+ empty: {
292
+ orchestrator: {},
293
+ },
294
+ },
295
+ };
296
+ const manager = createPresetManager(ctx, config);
297
+ const output = createOutput();
298
+
299
+ await manager.handleCommandExecuteBefore(
300
+ { command: 'preset', sessionID: 's1', arguments: 'empty' },
301
+ output,
302
+ );
303
+
304
+ const text = getOutputText(output);
305
+ expect(text).toContain('empty');
306
+ expect(ctx.client.config.update).not.toHaveBeenCalled();
307
+ });
308
+
309
+ test('forwards options field in config update', async () => {
310
+ const ctx = createMockContext();
311
+ const config: PluginConfig = {
312
+ presets: {
313
+ thinker: {
314
+ oracle: {
315
+ model: 'anthropic/claude-sonnet-4-6',
316
+ options: {
317
+ thinking: { type: 'enabled', budgetTokens: 10000 },
318
+ },
319
+ },
320
+ },
321
+ },
322
+ };
323
+ const manager = createPresetManager(ctx, config);
324
+ const output = createOutput();
325
+
326
+ await manager.handleCommandExecuteBefore(
327
+ { command: 'preset', sessionID: 's1', arguments: 'thinker' },
328
+ output,
329
+ );
330
+
331
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
332
+ body: {
333
+ agent: {
334
+ oracle: {
335
+ model: 'anthropic/claude-sonnet-4-6',
336
+ options: {
337
+ thinking: { type: 'enabled', budgetTokens: 10000 },
338
+ },
339
+ },
340
+ },
341
+ },
342
+ });
343
+ });
344
+
345
+ test('trims whitespace from preset name argument', async () => {
346
+ const ctx = createMockContext();
347
+ const config: PluginConfig = {
348
+ presets: {
349
+ cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
350
+ },
351
+ };
352
+ const manager = createPresetManager(ctx, config);
353
+ const output = createOutput();
354
+
355
+ await manager.handleCommandExecuteBefore(
356
+ { command: 'preset', sessionID: 's1', arguments: ' cheap ' },
357
+ output,
358
+ );
359
+
360
+ const text = getOutputText(output);
361
+ expect(text).toContain('Switched to preset "cheap"');
362
+ expect(ctx.client.config.update).toHaveBeenCalledTimes(1);
363
+ });
364
+
365
+ test('shows suggestion for multi-word arguments', async () => {
366
+ const ctx = createMockContext();
367
+ const config: PluginConfig = {
368
+ presets: {
369
+ cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
370
+ },
371
+ };
372
+ const manager = createPresetManager(ctx, config);
373
+ const output = createOutput();
374
+
375
+ await manager.handleCommandExecuteBefore(
376
+ { command: 'preset', sessionID: 's1', arguments: 'cheap powerful' },
377
+ output,
378
+ );
379
+
380
+ const text = getOutputText(output);
381
+ expect(text).toContain('cannot contain spaces');
382
+ expect(text).toContain('/preset cheap');
383
+ expect(ctx.client.config.update).not.toHaveBeenCalled();
384
+ });
385
+
386
+ test('catches tab-separated arguments', async () => {
387
+ const ctx = createMockContext();
388
+ const config: PluginConfig = {
389
+ presets: {
390
+ cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
391
+ },
392
+ };
393
+ const manager = createPresetManager(ctx, config);
394
+ const output = createOutput();
395
+
396
+ await manager.handleCommandExecuteBefore(
397
+ { command: 'preset', sessionID: 's1', arguments: 'cheap\tpowerful' },
398
+ output,
399
+ );
400
+
401
+ const text = getOutputText(output);
402
+ expect(text).toContain('cannot contain spaces');
403
+ expect(ctx.client.config.update).not.toHaveBeenCalled();
404
+ });
405
+
406
+ test('skips agents with empty overrides in mixed preset', async () => {
407
+ const ctx = createMockContext();
408
+ const config: PluginConfig = {
409
+ presets: {
410
+ mixed: {
411
+ orchestrator: { model: 'anthropic/claude-3.5-haiku' },
412
+ explorer: {},
413
+ oracle: { temperature: 0.3 },
414
+ },
415
+ },
416
+ };
417
+ const manager = createPresetManager(ctx, config);
418
+ const output = createOutput();
419
+
420
+ await manager.handleCommandExecuteBefore(
421
+ { command: 'preset', sessionID: 's1', arguments: 'mixed' },
422
+ output,
423
+ );
424
+
425
+ const text = getOutputText(output);
426
+ expect(text).toContain('Switched to preset "mixed"');
427
+ // Only orchestrator and oracle should be forwarded
428
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
429
+ body: {
430
+ agent: {
431
+ orchestrator: { model: 'anthropic/claude-3.5-haiku' },
432
+ oracle: { temperature: 0.3 },
433
+ },
434
+ },
435
+ });
436
+ });
437
+
438
+ test('resolves array-form model to first entry', async () => {
439
+ const ctx = createMockContext();
440
+ const config: PluginConfig = {
441
+ presets: {
442
+ fallback: {
443
+ orchestrator: {
444
+ model: ['anthropic/claude-3.5-haiku', 'openai/gpt-5.5'],
445
+ },
446
+ },
447
+ },
448
+ };
449
+ const manager = createPresetManager(ctx, config);
450
+ const output = createOutput();
451
+
452
+ await manager.handleCommandExecuteBefore(
453
+ { command: 'preset', sessionID: 's1', arguments: 'fallback' },
454
+ output,
455
+ );
456
+
457
+ const text = getOutputText(output);
458
+ expect(text).toContain('Switched to preset "fallback"');
459
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
460
+ body: {
461
+ agent: {
462
+ orchestrator: { model: 'anthropic/claude-3.5-haiku' },
463
+ },
464
+ },
465
+ });
466
+ });
467
+
468
+ test('resolves array-form model with object entries', async () => {
469
+ const ctx = createMockContext();
470
+ const config: PluginConfig = {
471
+ presets: {
472
+ thinker: {
473
+ oracle: {
474
+ model: [
475
+ { id: 'anthropic/claude-sonnet-4-6', variant: 'thinking' },
476
+ { id: 'openai/o3' },
477
+ ],
478
+ },
479
+ },
480
+ },
481
+ };
482
+ const manager = createPresetManager(ctx, config);
483
+ const output = createOutput();
484
+
485
+ await manager.handleCommandExecuteBefore(
486
+ { command: 'preset', sessionID: 's1', arguments: 'thinker' },
487
+ output,
488
+ );
489
+
490
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
491
+ body: {
492
+ agent: {
493
+ oracle: {
494
+ model: 'anthropic/claude-sonnet-4-6',
495
+ variant: 'thinking',
496
+ },
497
+ },
498
+ },
499
+ });
500
+ });
501
+
502
+ test('shows variant and options in switch summary', async () => {
503
+ const ctx = createMockContext();
504
+ const config: PluginConfig = {
505
+ presets: {
506
+ thinker: {
507
+ oracle: {
508
+ model: 'anthropic/claude-sonnet-4-6',
509
+ variant: 'thinking',
510
+ options: { thinking: { type: 'enabled', budgetTokens: 10000 } },
511
+ },
512
+ },
513
+ },
514
+ };
515
+ const manager = createPresetManager(ctx, config);
516
+ const output = createOutput();
517
+
518
+ await manager.handleCommandExecuteBefore(
519
+ { command: 'preset', sessionID: 's1', arguments: 'thinker' },
520
+ output,
521
+ );
522
+
523
+ const text = getOutputText(output);
524
+ expect(text).toContain('variant: thinking');
525
+ expect(text).toContain('options: yes');
526
+ });
527
+
528
+ test('tracks active preset after switch', async () => {
529
+ const ctx = createMockContext();
530
+ const config: PluginConfig = {
531
+ presets: {
532
+ cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
533
+ powerful: { orchestrator: { model: 'openai/gpt-5.5' } },
534
+ },
535
+ };
536
+ const manager = createPresetManager(ctx, config);
537
+
538
+ // Switch to cheap
539
+ const output1 = createOutput();
540
+ await manager.handleCommandExecuteBefore(
541
+ { command: 'preset', sessionID: 's1', arguments: 'cheap' },
542
+ output1,
543
+ );
544
+ expect(getOutputText(output1)).toContain('Switched');
545
+
546
+ // List presets should now show cheap as active
547
+ const output2 = createOutput();
548
+ await manager.handleCommandExecuteBefore(
549
+ { command: 'preset', sessionID: 's1', arguments: '' },
550
+ output2,
551
+ );
552
+ expect(getOutputText(output2)).toContain('cheap ← active');
553
+
554
+ // Switch to powerful
555
+ const output3 = createOutput();
556
+ await manager.handleCommandExecuteBefore(
557
+ { command: 'preset', sessionID: 's1', arguments: 'powerful' },
558
+ output3,
559
+ );
560
+ expect(getOutputText(output3)).toContain('Switched to preset "powerful"');
561
+
562
+ // List should now show powerful as active
563
+ const output4 = createOutput();
564
+ await manager.handleCommandExecuteBefore(
565
+ { command: 'preset', sessionID: 's1', arguments: '' },
566
+ output4,
567
+ );
568
+ expect(getOutputText(output4)).toContain('powerful ← active');
569
+
570
+ // Cleanup module state
571
+ setActiveRuntimePreset(null);
572
+ });
573
+ });
574
+
575
+ describe('registerCommand', () => {
576
+ test('registers preset command when not present', () => {
577
+ const ctx = createMockContext();
578
+ const config: PluginConfig = {};
579
+ const manager = createPresetManager(ctx, config);
580
+ const opencodeConfig: Record<string, unknown> = {};
581
+
582
+ manager.registerCommand(opencodeConfig);
583
+
584
+ const command = (opencodeConfig.command as Record<string, unknown>)
585
+ .preset as { template: string; description: string };
586
+ expect(command).toBeDefined();
587
+ expect(command.template).toContain('presets');
588
+ expect(command.description).toContain('/preset');
589
+ });
590
+
591
+ test('does not overwrite existing preset command', () => {
592
+ const ctx = createMockContext();
593
+ const config: PluginConfig = {};
594
+ const manager = createPresetManager(ctx, config);
595
+ const existing = { template: 'custom', description: 'custom' };
596
+ const opencodeConfig: Record<string, unknown> = {
597
+ command: { preset: existing },
598
+ };
599
+
600
+ manager.registerCommand(opencodeConfig);
601
+
602
+ expect((opencodeConfig.command as Record<string, unknown>).preset).toBe(
603
+ existing,
604
+ );
605
+ });
606
+ });
607
+
608
+ describe('preset switching stale state', () => {
609
+ test('reset updates for agents removed when switching presets', async () => {
610
+ const ctx = createMockContext();
611
+ const config: PluginConfig = {
612
+ presets: {
613
+ cheap: {
614
+ oracle: { model: 'cheap-model', temperature: 0.3 },
615
+ },
616
+ powerful: {
617
+ orchestrator: { model: 'powerful-model' },
618
+ },
619
+ },
620
+ agents: {
621
+ oracle: { model: 'baseline-model' },
622
+ },
623
+ };
624
+ const manager = createPresetManager(ctx, config);
625
+ const output1 = createOutput();
626
+
627
+ // Switch to cheap first
628
+ await manager.handleCommandExecuteBefore(
629
+ { command: 'preset', sessionID: 's1', arguments: 'cheap' },
630
+ output1,
631
+ );
632
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
633
+ body: {
634
+ agent: {
635
+ oracle: { model: 'cheap-model', temperature: 0.3 },
636
+ },
637
+ },
638
+ });
639
+
640
+ // Reset mock for next call
641
+ ctx.client.config.update.mockClear();
642
+
643
+ const output2 = createOutput();
644
+ await manager.handleCommandExecuteBefore(
645
+ { command: 'preset', sessionID: 's1', arguments: 'powerful' },
646
+ output2,
647
+ );
648
+
649
+ // Second update should reset oracle to baseline and set orchestrator
650
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
651
+ body: {
652
+ agent: {
653
+ oracle: { model: 'baseline-model' },
654
+ orchestrator: { model: 'powerful-model' },
655
+ },
656
+ },
657
+ });
658
+
659
+ // Cleanup
660
+ setActiveRuntimePreset(null);
661
+ });
662
+
663
+ test('no reset updates when new preset covers same agents', async () => {
664
+ const ctx = createMockContext();
665
+ const config: PluginConfig = {
666
+ presets: {
667
+ cheap: {
668
+ oracle: { model: 'a' },
669
+ },
670
+ cheaper: {
671
+ oracle: { model: 'b' },
672
+ },
673
+ },
674
+ };
675
+ const manager = createPresetManager(ctx, config);
676
+ const output1 = createOutput();
677
+
678
+ // Switch to cheap first
679
+ await manager.handleCommandExecuteBefore(
680
+ { command: 'preset', sessionID: 's1', arguments: 'cheap' },
681
+ output1,
682
+ );
683
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
684
+ body: {
685
+ agent: {
686
+ oracle: { model: 'a' },
687
+ },
688
+ },
689
+ });
690
+
691
+ // Reset mock for next call
692
+ ctx.client.config.update.mockClear();
693
+
694
+ const output2 = createOutput();
695
+ await manager.handleCommandExecuteBefore(
696
+ { command: 'preset', sessionID: 's1', arguments: 'cheaper' },
697
+ output2,
698
+ );
699
+
700
+ // Second update should only have oracle, no reset updates
701
+ expect(ctx.client.config.update).toHaveBeenCalledWith({
702
+ body: {
703
+ agent: {
704
+ oracle: { model: 'b' },
705
+ },
706
+ },
707
+ });
708
+
709
+ // Cleanup
710
+ setActiveRuntimePreset(null);
711
+ });
712
+
713
+ test('preset state rolled back on config.update error', async () => {
714
+ const ctx = createMockContext();
715
+ ctx.client.config.update = mock(async () => {
716
+ throw new Error('Server unavailable');
717
+ });
718
+ const config: PluginConfig = {
719
+ presets: {
720
+ cheap: {
721
+ oracle: { model: 'a' },
722
+ },
723
+ expensive: {
724
+ oracle: { model: 'b' },
725
+ },
726
+ },
727
+ };
728
+ const manager = createPresetManager(ctx, config);
729
+
730
+ // Reset mock for successful switch
731
+ ctx.client.config.update = mock(async () => ({}));
732
+
733
+ // Switch to cheap successfully
734
+ const output1 = createOutput();
735
+ await manager.handleCommandExecuteBefore(
736
+ { command: 'preset', sessionID: 's1', arguments: 'cheap' },
737
+ output1,
738
+ );
739
+ expect(getActiveRuntimePreset()).toBe('cheap');
740
+
741
+ // Reset mock to throw error
742
+ ctx.client.config.update = mock(async () => {
743
+ throw new Error('Server unavailable');
744
+ });
745
+
746
+ // Try to switch to expensive but it fails
747
+ const output2 = createOutput();
748
+ await manager.handleCommandExecuteBefore(
749
+ { command: 'preset', sessionID: 's1', arguments: 'expensive' },
750
+ output2,
751
+ );
752
+
753
+ // Active preset should still be "cheap" after error
754
+ expect(getActiveRuntimePreset()).toBe('cheap');
755
+ expect(getOutputText(output2)).toContain('Failed to switch preset');
756
+
757
+ // Cleanup
758
+ setActiveRuntimePreset(null);
759
+ });
760
+
761
+ test('activePreset syncs from runtime-preset state on factory creation', async () => {
762
+ // Set runtime preset before creating manager
763
+ setActiveRuntimePreset('cheap');
764
+
765
+ const ctx = createMockContext();
766
+ const config: PluginConfig = {
767
+ presets: {
768
+ cheap: {
769
+ oracle: { model: 'a' },
770
+ },
771
+ powerful: {
772
+ oracle: { model: 'b' },
773
+ },
774
+ },
775
+ };
776
+
777
+ // Create manager - should sync from module-level state
778
+ const manager = createPresetManager(ctx, config);
779
+
780
+ // List presets should show cheap as active
781
+ const output = createOutput();
782
+ await manager.handleCommandExecuteBefore(
783
+ { command: 'preset', sessionID: 's1', arguments: '' },
784
+ output,
785
+ );
786
+
787
+ const text = getOutputText(output);
788
+ expect(text).toContain('cheap ← active');
789
+ expect(text).toContain('powerful');
790
+
791
+ // Cleanup
792
+ setActiveRuntimePreset(null);
793
+ });
794
+ });
795
+ });