kimaki 0.26.0 → 0.28.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/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -1,12 +1,22 @@
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 {
11
+ formatCandidateRef,
12
+ PROVIDER_ID as SUBROUTER_PROVIDER_ID,
13
+ resolveLiveModel,
14
+ } from '@subrouter/cli'
15
+ import { InvalidModelError, OpenCodeSdkError } from '../errors.js'
16
+ import {
17
+ initializeOpencodeForDirectory,
18
+ subscribeOpencodeServerLifecycle,
19
+ } from '../opencode.js'
10
20
  import { createLogger, LogPrefix } from '../logger.js'
11
21
  import type { ScheduledTaskScheduleKind } from '../database.js'
12
22
 
@@ -58,7 +68,7 @@ function getRecentModelsFromTuiState(): Array<{
58
68
  /**
59
69
  * Parse a model string in format "provider/model" into providerID and modelID.
60
70
  */
61
- function parseModelString(
71
+ export function parseModelId(
62
72
  model: string,
63
73
  ): { providerID: string; modelID: string } | undefined {
64
74
  const [providerID, ...modelParts] = model.split('/')
@@ -85,7 +95,7 @@ function getModelFromProjectConfig({
85
95
  if (!parsed.model) {
86
96
  return undefined
87
97
  }
88
- return parseModelString(parsed.model)
98
+ return parseModelId(parsed.model)
89
99
  })
90
100
  if (result instanceof Error) return undefined
91
101
  return result
@@ -107,6 +117,272 @@ function isModelValid(
107
117
  return isConnected && !!modelExists
108
118
  }
109
119
 
120
+ export type ListedModel = {
121
+ providerID: string
122
+ modelID: string
123
+ name: string
124
+ connected: boolean
125
+ }
126
+
127
+ type ProviderModelLookup = {
128
+ id: string
129
+ models?: Record<string, { name?: string } | undefined>
130
+ }
131
+
132
+ /** SDK display name from provider.list. */
133
+ export function getProviderModelName({
134
+ providers,
135
+ providerID,
136
+ modelID,
137
+ }: {
138
+ providers: ProviderModelLookup[]
139
+ providerID?: string
140
+ modelID?: string
141
+ }): string | undefined {
142
+ if (!providerID || !modelID) return undefined
143
+ const provider = providers.find((p) => p.id === providerID)
144
+ const model = provider?.models?.[modelID]
145
+ if (!model) return undefined
146
+ if (typeof model.name === 'string' && model.name.trim()) return model.name
147
+ return modelID
148
+ }
149
+
150
+ /** Use the SDK name only when it is the model id, or the model id plus `(live-model)`. */
151
+ export function displayedModelLabel({
152
+ modelID,
153
+ name,
154
+ }: {
155
+ modelID: string
156
+ name?: string
157
+ }): string {
158
+ if (name && (name === modelID || name.startsWith(`${modelID} (`))) return name
159
+ return modelID
160
+ }
161
+
162
+ export function formatDisplayedModelId({
163
+ providerID,
164
+ modelID,
165
+ name,
166
+ }: {
167
+ providerID?: string
168
+ modelID?: string
169
+ name?: string
170
+ }): string | undefined {
171
+ if (!providerID || !modelID) return undefined
172
+ return `${providerID}/${displayedModelLabel({ modelID, name })}`
173
+ }
174
+
175
+ /** Subrouter presets resolve to the live cooldown-aware candidate. */
176
+ export async function resolveDisplayedModelName({
177
+ providers,
178
+ providerID,
179
+ modelID,
180
+ sessionID,
181
+ }: {
182
+ providers: ProviderModelLookup[]
183
+ providerID?: string
184
+ modelID?: string
185
+ sessionID?: string
186
+ }): Promise<string | undefined> {
187
+ if (providerID === SUBROUTER_PROVIDER_ID && modelID) {
188
+ const candidate = await resolveLiveModel({ preset: modelID, sessionID }).catch((e) => {
189
+ sessionLogger.warn(
190
+ `[MODEL] Failed to resolve subrouter candidate for ${modelID}:`,
191
+ e instanceof Error ? e.message : e,
192
+ )
193
+ return null
194
+ })
195
+ if (candidate) return `${modelID} (${formatCandidateRef(candidate)})`
196
+ }
197
+ return getProviderModelName({ providers, providerID, modelID })
198
+ }
199
+
200
+ export async function resolveDisplayedModelId({
201
+ providers,
202
+ providerID,
203
+ modelID,
204
+ sessionID,
205
+ }: {
206
+ providers: ProviderModelLookup[]
207
+ providerID?: string
208
+ modelID?: string
209
+ sessionID?: string
210
+ }): Promise<string | undefined> {
211
+ return formatDisplayedModelId({
212
+ providerID,
213
+ modelID,
214
+ name: await resolveDisplayedModelName({ providers, providerID, modelID, sessionID }),
215
+ })
216
+ }
217
+
218
+ type ModelListCacheEntry =
219
+ | { status: 'pending'; promise: Promise<ListedModel[] | OpenCodeSdkError> }
220
+ | { status: 'ready'; value: ListedModel[] }
221
+
222
+ const modelListCache = new Map<string, ModelListCacheEntry>()
223
+
224
+ export function clearModelListCache() {
225
+ modelListCache.clear()
226
+ }
227
+
228
+ subscribeOpencodeServerLifecycle(() => {
229
+ clearModelListCache()
230
+ })
231
+
232
+ function flattenProviderModels({
233
+ providers,
234
+ connected,
235
+ }: {
236
+ providers: Provider[]
237
+ connected: string[]
238
+ }): ListedModel[] {
239
+ const connectedSet = new Set(connected)
240
+ const models: ListedModel[] = []
241
+ for (const provider of providers) {
242
+ const isConnected = connectedSet.has(provider.id)
243
+ for (const [modelID, model] of Object.entries(provider.models)) {
244
+ models.push({
245
+ providerID: provider.id,
246
+ modelID,
247
+ name: model.name || modelID,
248
+ connected: isConnected,
249
+ })
250
+ }
251
+ }
252
+ return models
253
+ }
254
+
255
+ export async function listModels({
256
+ getClient,
257
+ directory,
258
+ }: {
259
+ getClient: () => OpencodeClient
260
+ directory?: string
261
+ }): Promise<ListedModel[] | OpenCodeSdkError> {
262
+ const cacheKey = directory ?? ''
263
+ const cached = modelListCache.get(cacheKey)
264
+ if (cached?.status === 'ready') return cached.value
265
+ if (cached?.status === 'pending') return cached.promise
266
+
267
+ const promise = (async () => {
268
+ const providersResponse = await getClient()
269
+ .provider.list({ directory })
270
+ .catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }))
271
+ if (providersResponse instanceof Error) return providersResponse
272
+ if (!providersResponse.data) {
273
+ return new OpenCodeSdkError({ operation: 'provider.list' })
274
+ }
275
+ return flattenProviderModels({
276
+ providers: providersResponse.data.all,
277
+ connected: providersResponse.data.connected,
278
+ })
279
+ })()
280
+
281
+ modelListCache.set(cacheKey, { status: 'pending', promise })
282
+ const result = await promise
283
+ const current = modelListCache.get(cacheKey)
284
+ const ownsCacheEntry = current?.status === 'pending' && current.promise === promise
285
+ if (result instanceof Error) {
286
+ if (ownsCacheEntry) modelListCache.delete(cacheKey)
287
+ return result
288
+ }
289
+ if (ownsCacheEntry) modelListCache.set(cacheKey, { status: 'ready', value: result })
290
+ return result
291
+ }
292
+
293
+ export function validateModelIdAgainstList({
294
+ model,
295
+ models,
296
+ }: {
297
+ model: string
298
+ models: ListedModel[]
299
+ }): { providerID: string; modelID: string } | InvalidModelError {
300
+ const parsed = parseModelId(model)
301
+ if (!parsed) {
302
+ return new InvalidModelError({
303
+ model,
304
+ reason: 'expected provider/model, for example anthropic/claude-opus-4-6',
305
+ })
306
+ }
307
+
308
+ const match = models.find((candidate) => {
309
+ return (
310
+ candidate.providerID === parsed.providerID &&
311
+ candidate.modelID === parsed.modelID
312
+ )
313
+ })
314
+ if (!match) {
315
+ const siblings = models
316
+ .filter((candidate) => candidate.providerID === parsed.providerID)
317
+ .map((candidate) => `${candidate.providerID}/${candidate.modelID}`)
318
+ if (siblings.length > 0) {
319
+ return new InvalidModelError({
320
+ model,
321
+ reason: `unknown model. Similar: ${siblings.slice(0, 8).join(', ')}`,
322
+ })
323
+ }
324
+ const connectedProviders = [
325
+ ...new Set(
326
+ models.filter((candidate) => candidate.connected).map((candidate) => {
327
+ return candidate.providerID
328
+ }),
329
+ ),
330
+ ]
331
+ const connectedHint = connectedProviders.length
332
+ ? ` Connected providers: ${connectedProviders.join(', ')}`
333
+ : ''
334
+ return new InvalidModelError({
335
+ model,
336
+ reason: `unknown provider ${parsed.providerID}.${connectedHint}`,
337
+ })
338
+ }
339
+ if (!match.connected) {
340
+ return new InvalidModelError({
341
+ model,
342
+ reason: `provider ${parsed.providerID} is not connected`,
343
+ })
344
+ }
345
+ return parsed
346
+ }
347
+
348
+ export async function validateModelId({
349
+ model,
350
+ getClient,
351
+ directory,
352
+ }: {
353
+ model: string
354
+ getClient: () => OpencodeClient
355
+ directory?: string
356
+ }): Promise<
357
+ { providerID: string; modelID: string } | InvalidModelError | OpenCodeSdkError
358
+ > {
359
+ const models = await listModels({ getClient, directory })
360
+ if (models instanceof Error) return models
361
+ return validateModelIdAgainstList({ model, models })
362
+ }
363
+
364
+ export async function validateCliModelOption({
365
+ model,
366
+ directory,
367
+ }: {
368
+ model: string | undefined
369
+ directory?: string
370
+ }) {
371
+ if (!model) return
372
+ const parsed = parseModelId(model)
373
+ if (!parsed) {
374
+ return new InvalidModelError({
375
+ model,
376
+ reason: 'expected provider/model, for example anthropic/claude-opus-4-6',
377
+ })
378
+ }
379
+ if (!directory) return parsed
380
+
381
+ const getClient = await initializeOpencodeForDirectory(directory)
382
+ if (getClient instanceof Error) return getClient
383
+ return validateModelId({ model, getClient, directory })
384
+ }
385
+
110
386
  /**
111
387
  * Get the default model from OpenCode when no user preference is set.
112
388
  * Priority (matches OpenCode TUI behavior):
@@ -163,7 +439,7 @@ export async function getDefaultModel({
163
439
  const configResponse = await getClient().config.get({ directory })
164
440
  .catch((e) => new OpenCodeSdkError({ operation: 'config.get', cause: e }))
165
441
  if (!(configResponse instanceof Error) && configResponse.data?.model) {
166
- const configModel = parseModelString(configResponse.data.model)
442
+ const configModel = parseModelId(configResponse.data.model)
167
443
  if (configModel && isModelValid(configModel, connected, providers)) {
168
444
  sessionLogger.log(
169
445
  `[MODEL] Using config model: ${configModel.providerID}/${configModel.modelID}`,
@@ -16,6 +16,35 @@ export function isOpencodeSessionEventLogEnabled(): boolean {
16
16
  return process.env['KIMAKI_LOG_OPENCODE_SESSION_EVENTS'] === '1'
17
17
  }
18
18
 
19
+ function sessionIdFromEventProperties(properties: OpenCodeEvent['properties']): string | undefined {
20
+ if (!properties || typeof properties !== 'object') {
21
+ return undefined
22
+ }
23
+ const sessionID = Reflect.get(properties, 'sessionID')
24
+ if (typeof sessionID === 'string') {
25
+ return sessionID
26
+ }
27
+ const info = Reflect.get(properties, 'info')
28
+ if (info && typeof info === 'object') {
29
+ const id = Reflect.get(info, 'id')
30
+ if (typeof id === 'string') {
31
+ return id
32
+ }
33
+ const infoSessionId = Reflect.get(info, 'sessionID')
34
+ if (typeof infoSessionId === 'string') {
35
+ return infoSessionId
36
+ }
37
+ }
38
+ const part = Reflect.get(properties, 'part')
39
+ if (part && typeof part === 'object') {
40
+ const partSessionId = Reflect.get(part, 'sessionID')
41
+ if (typeof partSessionId === 'string') {
42
+ return partSessionId
43
+ }
44
+ }
45
+ return undefined
46
+ }
47
+
19
48
  export function getOpencodeEventSessionId(event: OpenCodeEvent): string | undefined {
20
49
  switch (event.type) {
21
50
  case 'message.updated':
@@ -40,7 +69,8 @@ export function getOpencodeEventSessionId(event: OpenCodeEvent): string | undefi
40
69
  case 'session.deleted':
41
70
  return event.properties.info.id
42
71
  default:
43
- return undefined
72
+ // session.next.* and later SDK events put sessionID on properties.
73
+ return sessionIdFromEventProperties(event.properties)
44
74
  }
45
75
  }
46
76
 
@@ -0,0 +1,54 @@
1
+ // Unit tests for the per-thread Discord arrival FIFO.
2
+ // /plan-agent and a follow-up message must keep Discord order.
3
+
4
+ import { describe, expect, test } from 'vitest'
5
+ import {
6
+ reserveThreadIngress,
7
+ runInThreadIngressSlot,
8
+ waitForCurrentThreadIngress,
9
+ } from './thread-session-runtime.js'
10
+
11
+ describe('thread ingress FIFO', () => {
12
+ test('later waiters stay behind an unreleased earlier slot', async () => {
13
+ const first = reserveThreadIngress('thread-fifo')
14
+ const second = reserveThreadIngress('thread-fifo')
15
+ const order: string[] = []
16
+
17
+ const firstWork = runInThreadIngressSlot(first, async () => {
18
+ await waitForCurrentThreadIngress()
19
+ order.push('first-write')
20
+ first.release()
21
+ await new Promise((resolve) => setTimeout(resolve, 20))
22
+ order.push('first-work')
23
+ })
24
+
25
+ const secondWork = runInThreadIngressSlot(second, async () => {
26
+ await waitForCurrentThreadIngress()
27
+ order.push('second')
28
+ second.release()
29
+ })
30
+
31
+ await Promise.all([firstWork, secondWork])
32
+ expect(order).toEqual(['first-write', 'second', 'first-work'])
33
+ })
34
+
35
+ test('runInThreadIngressSlot releases after a thrown callback', async () => {
36
+ const first = reserveThreadIngress('thread-fifo-throw')
37
+ const second = reserveThreadIngress('thread-fifo-throw')
38
+ const order: string[] = []
39
+
40
+ const firstWork = runInThreadIngressSlot(first, async () => {
41
+ await waitForCurrentThreadIngress()
42
+ order.push('first')
43
+ throw new Error('boom')
44
+ }).catch(() => {})
45
+
46
+ const secondWork = runInThreadIngressSlot(second, async () => {
47
+ await waitForCurrentThreadIngress()
48
+ order.push('second')
49
+ })
50
+
51
+ await Promise.all([firstWork, secondWork])
52
+ expect(order).toEqual(['first', 'second'])
53
+ })
54
+ })
@@ -43,6 +43,8 @@ export type QueuedMessage = {
43
43
  // Set by --agent/--model/--permission flags on kimaki send or slash commands.
44
44
  agent?: string
45
45
  model?: string
46
+ // Thinking-level variant from `/xxx-agent variant:`.
47
+ variant?: string
46
48
  // Raw permission rule strings ("tool:action" or "tool:pattern:action").
47
49
  // Parsed and merged into session permissions on creation.
48
50
  permissions?: string[]
@@ -183,6 +185,7 @@ export function setSessionUsername(threadId: string, username: string): void {
183
185
  }
184
186
 
185
187
  export function setSessionUserId(threadId: string, userId: string): void {
188
+ if (!userId) return
186
189
  updateThread(threadId, (t) => {
187
190
  if (t.sessionUserId) {
188
191
  return t
@@ -0,0 +1,169 @@
1
+ import type {
2
+ Event as OpenCodeEvent,
3
+ Message as OpenCodeMessage,
4
+ Part,
5
+ } from '@opencode-ai/sdk/v2'
6
+ import { describe, expect, test } from 'vitest'
7
+ import type { EventBufferEntry, EventBufferEvent } from './event-stream-state.js'
8
+ import { ThreadSessionRuntime } from './thread-session-runtime.js'
9
+
10
+ type RuntimeInternals = {
11
+ state?: { sessionId: string }
12
+ eventBuffer: EventBufferEntry[]
13
+ partBuffer: Map<string, Map<string, Part>>
14
+ compactEventForEventBuffer: (event: EventBufferEvent) => EventBufferEvent | undefined
15
+ flushBufferedParts: () => Promise<void>
16
+ handleMessageUpdated: (message: OpenCodeMessage) => Promise<void>
17
+ handlePartUpdated: (part: Part) => Promise<void>
18
+ handleNaturalAssistantCompletion: () => Promise<void>
19
+ handleMainPart: (part: Part) => Promise<void>
20
+ handleSubtaskPart: (part: Part) => Promise<void>
21
+ }
22
+
23
+ function summaryMessageEvent({
24
+ sessionId,
25
+ messageId,
26
+ }: {
27
+ sessionId: string
28
+ messageId: string
29
+ }): OpenCodeEvent {
30
+ return {
31
+ id: `evt_${messageId}`,
32
+ type: 'message.updated',
33
+ properties: {
34
+ sessionID: sessionId,
35
+ info: {
36
+ id: messageId,
37
+ sessionID: sessionId,
38
+ role: 'assistant',
39
+ parentID: 'msg_user',
40
+ time: { created: 2, completed: 3 },
41
+ modelID: 'compaction-model',
42
+ providerID: 'test-provider',
43
+ mode: 'compaction',
44
+ agent: 'compaction',
45
+ path: { cwd: '/test', root: '/test' },
46
+ cost: 1,
47
+ tokens: {
48
+ input: 10,
49
+ output: 1,
50
+ reasoning: 0,
51
+ cache: { read: 0, write: 0 },
52
+ },
53
+ finish: 'stop',
54
+ summary: true,
55
+ },
56
+ },
57
+ }
58
+ }
59
+
60
+ function summaryTextPart({
61
+ sessionId,
62
+ messageId,
63
+ }: {
64
+ sessionId: string
65
+ messageId: string
66
+ }): Part {
67
+ return {
68
+ id: `prt_${messageId}`,
69
+ sessionID: sessionId,
70
+ messageID: messageId,
71
+ type: 'text',
72
+ text: 'internal compaction summary must not reach Discord',
73
+ time: { start: 2, end: 3 },
74
+ }
75
+ }
76
+
77
+ describe('ThreadSessionRuntime compaction summary routing', () => {
78
+ test('drops summary message parts before fallback buffering or completion', async () => {
79
+ const sessionId = 'ses_main'
80
+ const messageId = 'msg_summary_embedded_parts'
81
+ const event = summaryMessageEvent({ sessionId, messageId })
82
+ if (event.type !== 'message.updated') {
83
+ throw new Error('Expected summary message event')
84
+ }
85
+ const message = {
86
+ ...event.properties.info,
87
+ parts: [summaryTextPart({ sessionId, messageId })],
88
+ } as unknown as OpenCodeMessage
89
+ const runtime = Object.create(ThreadSessionRuntime.prototype) as RuntimeInternals
90
+ Object.defineProperty(runtime, 'state', {
91
+ value: { sessionId },
92
+ configurable: true,
93
+ })
94
+ runtime.eventBuffer = [{ event, timestamp: 1 }]
95
+ runtime.partBuffer = new Map()
96
+ runtime.flushBufferedParts = async () => {
97
+ throw new Error('Summary message must not flush parts')
98
+ }
99
+ runtime.handleNaturalAssistantCompletion = async () => {
100
+ throw new Error('Summary message must not complete the Discord turn')
101
+ }
102
+
103
+ await runtime.handleMessageUpdated(message)
104
+
105
+ expect(runtime.partBuffer.size).toBe(0)
106
+ })
107
+
108
+ test.each([
109
+ ['main session', 'ses_main'],
110
+ ['subagent session', 'ses_child'],
111
+ ])('preserves summary identity and drops %s parts before buffering', async (_, sessionId) => {
112
+ const messageId = `msg_summary_${sessionId}`
113
+ const runtime = Object.create(ThreadSessionRuntime.prototype) as RuntimeInternals
114
+ const compacted = runtime.compactEventForEventBuffer(
115
+ summaryMessageEvent({ sessionId, messageId }),
116
+ )
117
+
118
+ expect(compacted?.type).toBe('message.updated')
119
+ if (!compacted || compacted.type !== 'message.updated') {
120
+ throw new Error('Expected compacted summary message event')
121
+ }
122
+ expect(compacted.properties.info.summary).toBe(true)
123
+
124
+ runtime.eventBuffer = [{ event: compacted, timestamp: 1 }]
125
+ runtime.partBuffer = new Map()
126
+
127
+ await runtime.handlePartUpdated(summaryTextPart({ sessionId, messageId }))
128
+
129
+ expect(runtime.partBuffer.size).toBe(0)
130
+ })
131
+
132
+ test.each([
133
+ ['main session', 'ses_main'],
134
+ ['subagent session', 'ses_child'],
135
+ ])('holds %s parts until summary identity arrives, then drops them', async (_, sessionId) => {
136
+ const messageId = `msg_summary_unknown_${sessionId}`
137
+ const runtime = Object.create(ThreadSessionRuntime.prototype) as RuntimeInternals
138
+ Object.defineProperty(runtime, 'state', {
139
+ value: { sessionId: 'ses_main' },
140
+ configurable: true,
141
+ })
142
+ runtime.eventBuffer = []
143
+ runtime.partBuffer = new Map()
144
+ runtime.handleMainPart = async () => {
145
+ throw new Error('Unknown summary parts must not render')
146
+ }
147
+ runtime.handleSubtaskPart = async () => {
148
+ throw new Error('Unknown summary parts must not render')
149
+ }
150
+ runtime.flushBufferedParts = async () => {
151
+ throw new Error('Summary message must not flush parts')
152
+ }
153
+ runtime.handleNaturalAssistantCompletion = async () => {
154
+ throw new Error('Summary message must not complete the Discord turn')
155
+ }
156
+
157
+ await runtime.handlePartUpdated(summaryTextPart({ sessionId, messageId }))
158
+
159
+ expect(runtime.partBuffer.get(messageId)?.size).toBe(1)
160
+
161
+ const event = summaryMessageEvent({ sessionId, messageId })
162
+ if (event.type !== 'message.updated') {
163
+ throw new Error('Expected summary message event')
164
+ }
165
+ await runtime.handleMessageUpdated(event.properties.info)
166
+
167
+ expect(runtime.partBuffer.size).toBe(0)
168
+ })
169
+ })