kimaki 0.26.0 → 0.27.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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -0,0 +1,415 @@
1
+ // Tests for model ID parsing, cached provider.list wrapping, and validation.
2
+
3
+ import { afterEach, describe, expect, test } from 'vitest'
4
+ import { InvalidModelError } from '../errors.js'
5
+ import {
6
+ clearModelListCache,
7
+ formatDisplayedModelId,
8
+ getProviderModelName,
9
+ listModels,
10
+ parseModelId,
11
+ resolveDisplayedModelId,
12
+ validateCliModelOption,
13
+ validateModelId,
14
+ validateModelIdAgainstList,
15
+ type ListedModel,
16
+ } from './model-utils.js'
17
+
18
+ const listed: ListedModel[] = [
19
+ {
20
+ providerID: 'anthropic',
21
+ modelID: 'claude-opus-4-6',
22
+ name: 'Claude Opus 4.6',
23
+ connected: true,
24
+ },
25
+ {
26
+ providerID: 'anthropic',
27
+ modelID: 'claude-sonnet-4-6',
28
+ name: 'Claude Sonnet 4.6',
29
+ connected: true,
30
+ },
31
+ {
32
+ providerID: 'openai',
33
+ modelID: 'gpt-5.4',
34
+ name: 'GPT-5.4',
35
+ connected: false,
36
+ },
37
+ ]
38
+
39
+ afterEach(() => {
40
+ clearModelListCache()
41
+ })
42
+
43
+ describe('getProviderModelName', () => {
44
+ const providers = [
45
+ {
46
+ id: 'subrouter',
47
+ models: {
48
+ build: { name: 'build (claude-opus-4-6)' },
49
+ default: { name: 'default' },
50
+ },
51
+ },
52
+ {
53
+ id: 'deterministic-provider',
54
+ models: {
55
+ 'deterministic-v2': { name: 'deterministic-v2' },
56
+ },
57
+ },
58
+ ]
59
+
60
+ test('uses the sdk display name when subrouter adds the live model', () => {
61
+ expect(
62
+ getProviderModelName({
63
+ providers,
64
+ providerID: 'subrouter',
65
+ modelID: 'build',
66
+ }),
67
+ ).toMatchInlineSnapshot(`"build (claude-opus-4-6)"`)
68
+ })
69
+
70
+ test('falls back to the model id when name is missing', () => {
71
+ expect(
72
+ getProviderModelName({
73
+ providers: [{ id: 'subrouter', models: { build: {} } }],
74
+ providerID: 'subrouter',
75
+ modelID: 'build',
76
+ }),
77
+ ).toMatchInlineSnapshot(`"build"`)
78
+ })
79
+
80
+ test('returns undefined when provider or model is missing', () => {
81
+ expect(
82
+ getProviderModelName({
83
+ providers,
84
+ providerID: 'subrouter',
85
+ modelID: 'missing',
86
+ }),
87
+ ).toBeUndefined()
88
+ expect(
89
+ getProviderModelName({
90
+ providers,
91
+ providerID: 'openai',
92
+ modelID: 'gpt-5.4',
93
+ }),
94
+ ).toBeUndefined()
95
+ expect(
96
+ getProviderModelName({
97
+ providers,
98
+ }),
99
+ ).toBeUndefined()
100
+ })
101
+ })
102
+
103
+ describe('formatDisplayedModelId', () => {
104
+ test('keeps provider/id when the sdk name matches the model id', () => {
105
+ expect(
106
+ formatDisplayedModelId({
107
+ providerID: 'deterministic-provider',
108
+ modelID: 'deterministic-v2',
109
+ name: 'deterministic-v2',
110
+ }),
111
+ ).toMatchInlineSnapshot(`"deterministic-provider/deterministic-v2"`)
112
+ })
113
+
114
+ test('uses the sdk name so subrouter live model shows in parentheses', () => {
115
+ expect(
116
+ formatDisplayedModelId({
117
+ providerID: 'subrouter',
118
+ modelID: 'build',
119
+ name: 'build (claude-opus-4-6)',
120
+ }),
121
+ ).toMatchInlineSnapshot(`"subrouter/build (claude-opus-4-6)"`)
122
+ })
123
+
124
+ test('falls back to provider/id when name is missing', () => {
125
+ expect(
126
+ formatDisplayedModelId({
127
+ providerID: 'subrouter',
128
+ modelID: 'build',
129
+ }),
130
+ ).toMatchInlineSnapshot(`"subrouter/build"`)
131
+ })
132
+
133
+ test('keeps provider/id when sdk name is a pretty label', () => {
134
+ expect(
135
+ formatDisplayedModelId({
136
+ providerID: 'anthropic',
137
+ modelID: 'claude-opus-4-6',
138
+ name: 'Claude Opus 4.6',
139
+ }),
140
+ ).toMatchInlineSnapshot(`"anthropic/claude-opus-4-6"`)
141
+ })
142
+ })
143
+
144
+ describe('resolveDisplayedModelId', () => {
145
+ test('uses subrouter sdk name with parentheses', () => {
146
+ expect(
147
+ resolveDisplayedModelId({
148
+ providers: [
149
+ {
150
+ id: 'subrouter',
151
+ models: { build: { name: 'build (claude-opus-4-6)' } },
152
+ },
153
+ ],
154
+ providerID: 'subrouter',
155
+ modelID: 'build',
156
+ }),
157
+ ).toMatchInlineSnapshot(`"subrouter/build (claude-opus-4-6)"`)
158
+ })
159
+ })
160
+
161
+ describe('parseModelId', () => {
162
+ test('splits provider/model', () => {
163
+ expect(parseModelId('anthropic/claude-opus-4-6')).toMatchInlineSnapshot(`
164
+ {
165
+ "modelID": "claude-opus-4-6",
166
+ "providerID": "anthropic",
167
+ }
168
+ `)
169
+ })
170
+
171
+ test('keeps slashes inside the model id', () => {
172
+ expect(parseModelId('opencode/glm-5/free')).toMatchInlineSnapshot(`
173
+ {
174
+ "modelID": "glm-5/free",
175
+ "providerID": "opencode",
176
+ }
177
+ `)
178
+ })
179
+
180
+ test('rejects missing slash, empty provider, and empty model', () => {
181
+ expect(parseModelId('claude-opus-4-6')).toBeUndefined()
182
+ expect(parseModelId('/claude-opus-4-6')).toBeUndefined()
183
+ expect(parseModelId('anthropic/')).toBeUndefined()
184
+ expect(parseModelId('')).toBeUndefined()
185
+ })
186
+ })
187
+
188
+ describe('validateModelIdAgainstList', () => {
189
+ test('accepts a connected listed model', () => {
190
+ expect(
191
+ validateModelIdAgainstList({
192
+ model: 'anthropic/claude-opus-4-6',
193
+ models: listed,
194
+ }),
195
+ ).toMatchInlineSnapshot(`
196
+ {
197
+ "modelID": "claude-opus-4-6",
198
+ "providerID": "anthropic",
199
+ }
200
+ `)
201
+ })
202
+
203
+ test('rejects bad format', () => {
204
+ const result = validateModelIdAgainstList({
205
+ model: 'claude-opus-4-6',
206
+ models: listed,
207
+ })
208
+ expect(result).toBeInstanceOf(InvalidModelError)
209
+ if (result instanceof InvalidModelError) {
210
+ expect(result.model).toBe('claude-opus-4-6')
211
+ expect(result.reason).toContain('provider/model')
212
+ }
213
+ })
214
+
215
+ test('rejects unknown model and lists siblings', () => {
216
+ const result = validateModelIdAgainstList({
217
+ model: 'anthropic/does-not-exist',
218
+ models: listed,
219
+ })
220
+ expect(result).toBeInstanceOf(InvalidModelError)
221
+ if (result instanceof InvalidModelError) {
222
+ expect(result.message).toContain('anthropic/does-not-exist')
223
+ expect(result.message).toContain('anthropic/claude-opus-4-6')
224
+ expect(result.message).toContain('anthropic/claude-sonnet-4-6')
225
+ }
226
+ })
227
+
228
+ test('rejects disconnected provider', () => {
229
+ const result = validateModelIdAgainstList({
230
+ model: 'openai/gpt-5.4',
231
+ models: listed,
232
+ })
233
+ expect(result).toBeInstanceOf(InvalidModelError)
234
+ if (result instanceof InvalidModelError) {
235
+ expect(result.message).toContain('not connected')
236
+ expect(result.message).toContain('openai')
237
+ }
238
+ })
239
+ })
240
+
241
+ function fakeGetClient(options?: {
242
+ calls?: { count: number }
243
+ failOnce?: boolean
244
+ }) {
245
+ let failed = false
246
+ return () => ({
247
+ provider: {
248
+ list: async () => {
249
+ if (options?.calls) options.calls.count += 1
250
+ if (options?.failOnce && !failed) {
251
+ failed = true
252
+ throw new Error('provider.list failed')
253
+ }
254
+ return {
255
+ data: {
256
+ all: [
257
+ {
258
+ id: 'anthropic',
259
+ models: {
260
+ 'claude-opus-4-6': { name: 'Claude Opus 4.6' },
261
+ },
262
+ },
263
+ ],
264
+ connected: ['anthropic'],
265
+ default: {},
266
+ },
267
+ }
268
+ },
269
+ },
270
+ })
271
+ }
272
+
273
+ describe('listModels', () => {
274
+ test('returns listed models from provider.list', async () => {
275
+ const models = await listModels({
276
+ getClient: fakeGetClient() as never,
277
+ directory: '/tmp/project-a',
278
+ })
279
+ expect(models).not.toBeInstanceOf(Error)
280
+ if (models instanceof Error) throw models
281
+ expect(models).toMatchInlineSnapshot(`
282
+ [
283
+ {
284
+ "connected": true,
285
+ "modelID": "claude-opus-4-6",
286
+ "name": "Claude Opus 4.6",
287
+ "providerID": "anthropic",
288
+ },
289
+ ]
290
+ `)
291
+ })
292
+
293
+ test('memoizes provider.list per directory', async () => {
294
+ const calls = { count: 0 }
295
+ const getClient = fakeGetClient({ calls }) as never
296
+ const first = await listModels({ getClient, directory: '/tmp/project-a' })
297
+ const second = await listModels({ getClient, directory: '/tmp/project-a' })
298
+ expect(first).not.toBeInstanceOf(Error)
299
+ expect(second).not.toBeInstanceOf(Error)
300
+ expect(calls.count).toBe(1)
301
+ })
302
+
303
+ test('does not share cache across directories', async () => {
304
+ const calls = { count: 0 }
305
+ const getClient = fakeGetClient({ calls }) as never
306
+ const first = await listModels({ getClient, directory: '/tmp/project-a' })
307
+ const second = await listModels({ getClient, directory: '/tmp/project-b' })
308
+ expect(first).not.toBeInstanceOf(Error)
309
+ expect(second).not.toBeInstanceOf(Error)
310
+ expect(calls.count).toBe(2)
311
+ })
312
+
313
+ test('does not cache failed fetches', async () => {
314
+ const calls = { count: 0 }
315
+ const getClient = fakeGetClient({ calls, failOnce: true }) as never
316
+ const first = await listModels({ getClient, directory: '/tmp/project-a' })
317
+ expect(first).toBeInstanceOf(Error)
318
+ const second = await listModels({ getClient, directory: '/tmp/project-a' })
319
+ expect(second).not.toBeInstanceOf(Error)
320
+ expect(calls.count).toBe(2)
321
+ })
322
+
323
+ test('does not restore a pending result after the cache is cleared', async () => {
324
+ const calls = { count: 0 }
325
+ const firstResponse = Promise.withResolvers<{
326
+ data: {
327
+ all: Array<{ id: string; models: Record<string, { name: string }> }>
328
+ connected: string[]
329
+ default: Record<string, string>
330
+ }
331
+ }>()
332
+ const getClient = (() => ({
333
+ provider: {
334
+ list: () => {
335
+ calls.count += 1
336
+ if (calls.count === 1) return firstResponse.promise
337
+ return Promise.resolve({
338
+ data: {
339
+ all: [{ id: 'openai', models: { 'gpt-5.5': { name: 'GPT-5.5' } } }],
340
+ connected: ['openai'],
341
+ default: {},
342
+ },
343
+ })
344
+ },
345
+ },
346
+ })) as never
347
+
348
+ const pending = listModels({ getClient, directory: '/tmp/project-a' })
349
+ clearModelListCache()
350
+ firstResponse.resolve({
351
+ data: {
352
+ all: [
353
+ {
354
+ id: 'anthropic',
355
+ models: { 'claude-opus-4-6': { name: 'Claude Opus 4.6' } },
356
+ },
357
+ ],
358
+ connected: ['anthropic'],
359
+ default: {},
360
+ },
361
+ })
362
+ await pending
363
+
364
+ const refreshed = await listModels({ getClient, directory: '/tmp/project-a' })
365
+ expect({ calls: calls.count, refreshed }).toMatchInlineSnapshot(`
366
+ {
367
+ "calls": 2,
368
+ "refreshed": [
369
+ {
370
+ "connected": true,
371
+ "modelID": "gpt-5.5",
372
+ "name": "GPT-5.5",
373
+ "providerID": "openai",
374
+ },
375
+ ],
376
+ }
377
+ `)
378
+ })
379
+ })
380
+
381
+ describe('validateCliModelOption', () => {
382
+ test('skips empty model', async () => {
383
+ expect(await validateCliModelOption({ model: undefined })).toBeUndefined()
384
+ expect(await validateCliModelOption({ model: '' })).toBeUndefined()
385
+ })
386
+
387
+ test('rejects bad format without starting opencode', async () => {
388
+ const result = await validateCliModelOption({ model: 'claude-opus-4-6' })
389
+ expect(result).toBeInstanceOf(InvalidModelError)
390
+ })
391
+ })
392
+
393
+ describe('validateModelId', () => {
394
+ test('validates against the cached list', async () => {
395
+ const getClient = fakeGetClient() as never
396
+ const ok = await validateModelId({
397
+ model: 'anthropic/claude-opus-4-6',
398
+ getClient,
399
+ directory: '/tmp/project-a',
400
+ })
401
+ expect(ok).toMatchInlineSnapshot(`
402
+ {
403
+ "modelID": "claude-opus-4-6",
404
+ "providerID": "anthropic",
405
+ }
406
+ `)
407
+
408
+ const bad = await validateModelId({
409
+ model: 'anthropic/missing',
410
+ getClient,
411
+ directory: '/tmp/project-a',
412
+ })
413
+ expect(bad).toBeInstanceOf(InvalidModelError)
414
+ })
415
+ })
@@ -1,12 +1,17 @@
1
1
  // Model resolution utilities.
2
2
  // getDefaultModel resolves the default model from OpenCode when no user preference is set.
3
+ // listModels wraps provider.list() with a per-directory memoized cache.
3
4
 
4
5
  import fs from 'node:fs'
5
6
  import path from 'node:path'
6
7
  import { xdgState } from 'xdg-basedir'
7
8
  import * as errore from 'errore'
8
- import { OpenCodeSdkError } from '../errors.js'
9
- import { type initializeOpencodeForDirectory } from '../opencode.js'
9
+ import type { OpencodeClient, Provider } from '@opencode-ai/sdk/v2'
10
+ import { InvalidModelError, OpenCodeSdkError } from '../errors.js'
11
+ import {
12
+ initializeOpencodeForDirectory,
13
+ subscribeOpencodeServerLifecycle,
14
+ } from '../opencode.js'
10
15
  import { createLogger, LogPrefix } from '../logger.js'
11
16
  import type { ScheduledTaskScheduleKind } from '../database.js'
12
17
 
@@ -58,7 +63,7 @@ function getRecentModelsFromTuiState(): Array<{
58
63
  /**
59
64
  * Parse a model string in format "provider/model" into providerID and modelID.
60
65
  */
61
- function parseModelString(
66
+ export function parseModelId(
62
67
  model: string,
63
68
  ): { providerID: string; modelID: string } | undefined {
64
69
  const [providerID, ...modelParts] = model.split('/')
@@ -85,7 +90,7 @@ function getModelFromProjectConfig({
85
90
  if (!parsed.model) {
86
91
  return undefined
87
92
  }
88
- return parseModelString(parsed.model)
93
+ return parseModelId(parsed.model)
89
94
  })
90
95
  if (result instanceof Error) return undefined
91
96
  return result
@@ -107,6 +112,245 @@ function isModelValid(
107
112
  return isConnected && !!modelExists
108
113
  }
109
114
 
115
+ export type ListedModel = {
116
+ providerID: string
117
+ modelID: string
118
+ name: string
119
+ connected: boolean
120
+ }
121
+
122
+ type ProviderModelLookup = {
123
+ id: string
124
+ models?: Record<string, { name?: string } | undefined>
125
+ }
126
+
127
+ /** SDK display name from provider.list. Subrouter sets this to `build (claude-opus-4-6)`. */
128
+ export function getProviderModelName({
129
+ providers,
130
+ providerID,
131
+ modelID,
132
+ }: {
133
+ providers: ProviderModelLookup[]
134
+ providerID?: string
135
+ modelID?: string
136
+ }): string | undefined {
137
+ if (!providerID || !modelID) return undefined
138
+ const provider = providers.find((p) => p.id === providerID)
139
+ const model = provider?.models?.[modelID]
140
+ if (!model) return undefined
141
+ if (typeof model.name === 'string' && model.name.trim()) return model.name
142
+ return modelID
143
+ }
144
+
145
+ /** Use the SDK name only when it is the model id, or the model id plus `(live-model)`. */
146
+ export function displayedModelLabel({
147
+ modelID,
148
+ name,
149
+ }: {
150
+ modelID: string
151
+ name?: string
152
+ }): string {
153
+ if (name && (name === modelID || name.startsWith(`${modelID} (`))) return name
154
+ return modelID
155
+ }
156
+
157
+ export function formatDisplayedModelId({
158
+ providerID,
159
+ modelID,
160
+ name,
161
+ }: {
162
+ providerID?: string
163
+ modelID?: string
164
+ name?: string
165
+ }): string | undefined {
166
+ if (!providerID || !modelID) return undefined
167
+ return `${providerID}/${displayedModelLabel({ modelID, name })}`
168
+ }
169
+
170
+ export function resolveDisplayedModelId({
171
+ providers,
172
+ providerID,
173
+ modelID,
174
+ }: {
175
+ providers: ProviderModelLookup[]
176
+ providerID?: string
177
+ modelID?: string
178
+ }): string | undefined {
179
+ return formatDisplayedModelId({
180
+ providerID,
181
+ modelID,
182
+ name: getProviderModelName({ providers, providerID, modelID }),
183
+ })
184
+ }
185
+
186
+ type ModelListCacheEntry =
187
+ | { status: 'pending'; promise: Promise<ListedModel[] | OpenCodeSdkError> }
188
+ | { status: 'ready'; value: ListedModel[] }
189
+
190
+ const modelListCache = new Map<string, ModelListCacheEntry>()
191
+
192
+ export function clearModelListCache() {
193
+ modelListCache.clear()
194
+ }
195
+
196
+ subscribeOpencodeServerLifecycle(() => {
197
+ clearModelListCache()
198
+ })
199
+
200
+ function flattenProviderModels({
201
+ providers,
202
+ connected,
203
+ }: {
204
+ providers: Provider[]
205
+ connected: string[]
206
+ }): ListedModel[] {
207
+ const connectedSet = new Set(connected)
208
+ const models: ListedModel[] = []
209
+ for (const provider of providers) {
210
+ const isConnected = connectedSet.has(provider.id)
211
+ for (const [modelID, model] of Object.entries(provider.models)) {
212
+ models.push({
213
+ providerID: provider.id,
214
+ modelID,
215
+ name: model.name || modelID,
216
+ connected: isConnected,
217
+ })
218
+ }
219
+ }
220
+ return models
221
+ }
222
+
223
+ export async function listModels({
224
+ getClient,
225
+ directory,
226
+ }: {
227
+ getClient: () => OpencodeClient
228
+ directory?: string
229
+ }): Promise<ListedModel[] | OpenCodeSdkError> {
230
+ const cacheKey = directory ?? ''
231
+ const cached = modelListCache.get(cacheKey)
232
+ if (cached?.status === 'ready') return cached.value
233
+ if (cached?.status === 'pending') return cached.promise
234
+
235
+ const promise = (async () => {
236
+ const providersResponse = await getClient()
237
+ .provider.list({ directory })
238
+ .catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }))
239
+ if (providersResponse instanceof Error) return providersResponse
240
+ if (!providersResponse.data) {
241
+ return new OpenCodeSdkError({ operation: 'provider.list' })
242
+ }
243
+ return flattenProviderModels({
244
+ providers: providersResponse.data.all,
245
+ connected: providersResponse.data.connected,
246
+ })
247
+ })()
248
+
249
+ modelListCache.set(cacheKey, { status: 'pending', promise })
250
+ const result = await promise
251
+ const current = modelListCache.get(cacheKey)
252
+ const ownsCacheEntry = current?.status === 'pending' && current.promise === promise
253
+ if (result instanceof Error) {
254
+ if (ownsCacheEntry) modelListCache.delete(cacheKey)
255
+ return result
256
+ }
257
+ if (ownsCacheEntry) modelListCache.set(cacheKey, { status: 'ready', value: result })
258
+ return result
259
+ }
260
+
261
+ export function validateModelIdAgainstList({
262
+ model,
263
+ models,
264
+ }: {
265
+ model: string
266
+ models: ListedModel[]
267
+ }): { providerID: string; modelID: string } | InvalidModelError {
268
+ const parsed = parseModelId(model)
269
+ if (!parsed) {
270
+ return new InvalidModelError({
271
+ model,
272
+ reason: 'expected provider/model, for example anthropic/claude-opus-4-6',
273
+ })
274
+ }
275
+
276
+ const match = models.find((candidate) => {
277
+ return (
278
+ candidate.providerID === parsed.providerID &&
279
+ candidate.modelID === parsed.modelID
280
+ )
281
+ })
282
+ if (!match) {
283
+ const siblings = models
284
+ .filter((candidate) => candidate.providerID === parsed.providerID)
285
+ .map((candidate) => `${candidate.providerID}/${candidate.modelID}`)
286
+ if (siblings.length > 0) {
287
+ return new InvalidModelError({
288
+ model,
289
+ reason: `unknown model. Similar: ${siblings.slice(0, 8).join(', ')}`,
290
+ })
291
+ }
292
+ const connectedProviders = [
293
+ ...new Set(
294
+ models.filter((candidate) => candidate.connected).map((candidate) => {
295
+ return candidate.providerID
296
+ }),
297
+ ),
298
+ ]
299
+ const connectedHint = connectedProviders.length
300
+ ? ` Connected providers: ${connectedProviders.join(', ')}`
301
+ : ''
302
+ return new InvalidModelError({
303
+ model,
304
+ reason: `unknown provider ${parsed.providerID}.${connectedHint}`,
305
+ })
306
+ }
307
+ if (!match.connected) {
308
+ return new InvalidModelError({
309
+ model,
310
+ reason: `provider ${parsed.providerID} is not connected`,
311
+ })
312
+ }
313
+ return parsed
314
+ }
315
+
316
+ export async function validateModelId({
317
+ model,
318
+ getClient,
319
+ directory,
320
+ }: {
321
+ model: string
322
+ getClient: () => OpencodeClient
323
+ directory?: string
324
+ }): Promise<
325
+ { providerID: string; modelID: string } | InvalidModelError | OpenCodeSdkError
326
+ > {
327
+ const models = await listModels({ getClient, directory })
328
+ if (models instanceof Error) return models
329
+ return validateModelIdAgainstList({ model, models })
330
+ }
331
+
332
+ export async function validateCliModelOption({
333
+ model,
334
+ directory,
335
+ }: {
336
+ model: string | undefined
337
+ directory?: string
338
+ }) {
339
+ if (!model) return
340
+ const parsed = parseModelId(model)
341
+ if (!parsed) {
342
+ return new InvalidModelError({
343
+ model,
344
+ reason: 'expected provider/model, for example anthropic/claude-opus-4-6',
345
+ })
346
+ }
347
+ if (!directory) return parsed
348
+
349
+ const getClient = await initializeOpencodeForDirectory(directory)
350
+ if (getClient instanceof Error) return getClient
351
+ return validateModelId({ model, getClient, directory })
352
+ }
353
+
110
354
  /**
111
355
  * Get the default model from OpenCode when no user preference is set.
112
356
  * Priority (matches OpenCode TUI behavior):
@@ -163,7 +407,7 @@ export async function getDefaultModel({
163
407
  const configResponse = await getClient().config.get({ directory })
164
408
  .catch((e) => new OpenCodeSdkError({ operation: 'config.get', cause: e }))
165
409
  if (!(configResponse instanceof Error) && configResponse.data?.model) {
166
- const configModel = parseModelString(configResponse.data.model)
410
+ const configModel = parseModelId(configResponse.data.model)
167
411
  if (configModel && isModelValid(configModel, connected, providers)) {
168
412
  sessionLogger.log(
169
413
  `[MODEL] Using config model: ${configModel.providerID}/${configModel.modelID}`,