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
package/dist/voice.js CHANGED
@@ -181,16 +181,22 @@ function createWavHeader({ dataLength, sampleRate, numChannels, bitsPerSample, }
181
181
  }
182
182
  // Build the transcription tool schema dynamically so the agent field can
183
183
  // use an enum constrained to the actual available agent names.
184
- function buildTranscriptionTool({ agentNames, }) {
184
+ export function buildTranscriptionTool({ agentNames, canForkSession = false, }) {
185
185
  const properties = {
186
186
  transcription: {
187
187
  type: 'string',
188
- description: 'The final transcription of the audio. MUST be non-empty. If audio is unclear, transcribe your best interpretation. If silent, too short to understand, or completely incomprehensible, use "[inaudible audio]".',
188
+ description: 'The final transcription of the audio. If only a session routing instruction was spoken with no request, return an empty string. If audio is unclear, transcribe your best interpretation. If silent, too short to understand, or completely incomprehensible, use "[inaudible audio]".',
189
189
  },
190
190
  queueMessage: {
191
191
  type: 'boolean',
192
192
  description: 'Set to true ONLY if the user explicitly says "queue this message", "queue this", or similar phrasing indicating they want this message queued instead of sent immediately. If not mentioned, omit or set to false.',
193
193
  },
194
+ sessionAction: {
195
+ type: 'string',
196
+ enum: canForkSession ? ['btw', 'new-session'] : ['new-session'],
197
+ description: 'Use "new-session" only when explicitly asked to create a new chat, session, or thread with no conversation history. Remove routing instructions from transcription. Omit for normal messages. Never combine with queueMessage.' +
198
+ (canForkSession ? ' Use "btw" only when explicitly asked to create a side chat or fork with current context.' : ''),
199
+ },
194
200
  };
195
201
  if (agentNames && agentNames.length > 0) {
196
202
  properties['agent'] = {
@@ -213,7 +219,7 @@ function buildTranscriptionTool({ agentNames, }) {
213
219
  /**
214
220
  * Extract transcription result from doGenerate content array.
215
221
  * Looks for a tool-call named 'transcriptionResult', falls back to text content.
216
- * Returns structured result with transcription text and queueMessage flag.
222
+ * Returns transcription text, queue/session routing, and optional agent selection.
217
223
  */
218
224
  export function extractTranscription(content) {
219
225
  const toolCall = content.find((c) => c.type === 'tool-call' && c.toolName === 'transcriptionResult');
@@ -226,13 +232,16 @@ export function extractTranscription(content) {
226
232
  return {};
227
233
  })();
228
234
  const transcription = (typeof args.transcription === 'string' ? args.transcription : '').trim();
229
- const queueMessage = args.queueMessage === true;
235
+ const sessionAction = args.sessionAction === 'btw' || args.sessionAction === 'new-session'
236
+ ? args.sessionAction
237
+ : undefined;
238
+ const queueMessage = !sessionAction && args.queueMessage === true;
230
239
  const agent = typeof args.agent === 'string' ? args.agent : undefined;
231
240
  voiceLogger.log(`Transcription result received: "${transcription.slice(0, 100)}..."${queueMessage ? ' [QUEUE]' : ''}${agent ? ` [AGENT:${agent}]` : ''}`);
232
241
  if (!transcription) {
233
242
  return new EmptyTranscriptionError();
234
243
  }
235
- return { transcription, queueMessage, agent };
244
+ return { transcription, queueMessage, agent, sessionAction };
236
245
  }
237
246
  // Fall back to text content if no tool call
238
247
  const textPart = content.find((c) => c.type === 'text');
@@ -247,8 +256,8 @@ export function extractTranscription(content) {
247
256
  reason: 'Model did not produce a transcription',
248
257
  });
249
258
  }
250
- async function runTranscriptionOnce({ model, prompt, audioBase64, mediaType, temperature, agentNames, provider, }) {
251
- const tool = buildTranscriptionTool({ agentNames });
259
+ async function runTranscriptionOnce({ model, prompt, audioBase64, mediaType, temperature, agentNames, canForkSession, provider, }) {
260
+ const tool = buildTranscriptionTool({ agentNames, canForkSession });
252
261
  const options = {
253
262
  prompt: [
254
263
  {
@@ -311,7 +320,7 @@ export function createTranscriptionModel({ apiKey, provider, }) {
311
320
  const google = createGoogleGenerativeAI({ apiKey });
312
321
  return google('gemini-2.5-flash');
313
322
  }
314
- export async function transcribeAudio({ audio, prompt, language, temperature, apiKey: apiKeyParam, model, provider, mediaType: mediaTypeParam, currentSessionContext, lastSessionContext, agents, }) {
323
+ export async function transcribeAudio({ audio, prompt, language, temperature, apiKey: apiKeyParam, model, provider, mediaType: mediaTypeParam, currentSessionContext, lastSessionContext, agents, canForkSession = false, }) {
315
324
  const apiKey = apiKeyParam || process.env.OPENAI_API_KEY || process.env.GEMINI_API_KEY;
316
325
  if (!model && !apiKey) {
317
326
  return Promise.resolve(new ApiKeyMissingError({ service: 'OpenAI or Gemini' }));
@@ -412,6 +421,16 @@ This is a software development environment. The speaker is giving instructions t
412
421
  - Example: "Queue this message. Fix the login bug in auth.ts" → transcription: "Fix the login bug in auth.ts", queueMessage: true
413
422
  - If removing the queue phrase would leave empty content (user only said "queue this" with nothing else), keep the full spoken text as the transcription — never return an empty transcription.
414
423
  - If no queue intent is detected, omit queueMessage or set it to false.
424
+
425
+ SESSION ROUTING:
426
+ - Only route when the user explicitly asks to create a new chat, session, or thread for the request. Set sessionAction to "new-session": a separate thread with NO conversation history.
427
+ ${canForkSession ? '- If the user explicitly asks to create a side chat or fork with current context, set sessionAction to "btw" instead.' : '- Context-preserving side chats and forks are unavailable here. Preserve requests for them as spoken text without setting sessionAction.'}
428
+ - Remove these routing words from the transcription. Include only the actual request, not instructions about where to send it.
429
+ - Example: "Fix the login bug. Create this as a new chat session" -> transcription: "Fix the login bug", sessionAction: "new-session".
430
+ - Do not infer routing from conversational phrases, task content, or session context.
431
+ - If both routing and queueing are requested, sessionAction takes priority; set queueMessage to false. An explicit fresh-session request takes priority over a contextual fork.
432
+ - If there is no actual request after removing routing words, return an empty transcription and omit sessionAction. Never invent placeholder content.
433
+ - Otherwise omit sessionAction. Agent selection can be combined with either route.
415
434
  ${agents && agents.length > 0 ? `
416
435
  AGENT SELECTION:
417
436
  - Only set the agent field when the user explicitly says phrases like "use the X agent", "switch to X agent", "with the X agent", or similar phrasing that clearly names a specific agent to switch to.
@@ -449,6 +468,7 @@ Note: "critique" is a CLI tool for showing diffs in the browser.`;
449
468
  mediaType,
450
469
  temperature: temperature ?? 0.3,
451
470
  agentNames: agentNames && agentNames.length > 0 ? agentNames : undefined,
471
+ canForkSession,
452
472
  provider: resolvedProvider,
453
473
  });
454
474
  }
@@ -3,7 +3,7 @@
3
3
  import { describe, test, expect } from 'vitest';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { transcribeAudio, convertOggToWav, extractTranscription, normalizeAudioMediaType, getOpenAIAudioConversionStrategy, } from './voice.js';
6
+ import { transcribeAudio, convertOggToWav, extractTranscription, normalizeAudioMediaType, getOpenAIAudioConversionStrategy, buildTranscriptionTool, } from './voice.js';
7
7
  import { getVoiceAttachmentMatchReason, isVoiceAttachment, } from './voice-attachment.js';
8
8
  describe('audio media type routing', () => {
9
9
  test('normalizes m4a aliases to audio/mp4', () => {
@@ -50,8 +50,86 @@ describe('voice attachment detection', () => {
50
50
  ]
51
51
  `);
52
52
  });
53
+ test('does not treat iOS and Discord videos as voice', () => {
54
+ expect([
55
+ getVoiceAttachmentMatchReason({
56
+ name: 'ScreenRecording_08-26-2026_11-10-29_1.mov',
57
+ contentType: 'video/quicktime',
58
+ duration: 12.4,
59
+ width: 1170,
60
+ height: 2532,
61
+ }),
62
+ getVoiceAttachmentMatchReason({
63
+ name: 'IMG_1234.MOV',
64
+ contentType: null,
65
+ duration: 8,
66
+ width: 1920,
67
+ height: 1080,
68
+ }),
69
+ getVoiceAttachmentMatchReason({
70
+ name: 'clip.mp4',
71
+ contentType: 'video/mp4',
72
+ duration: 4,
73
+ }),
74
+ getVoiceAttachmentMatchReason({
75
+ name: 'clip.mp4',
76
+ contentType: null,
77
+ }),
78
+ isVoiceAttachment({
79
+ name: 'memo.m4a',
80
+ contentType: null,
81
+ duration: 3.2,
82
+ }),
83
+ ]).toMatchInlineSnapshot(`
84
+ [
85
+ null,
86
+ null,
87
+ null,
88
+ null,
89
+ true,
90
+ ]
91
+ `);
92
+ });
53
93
  });
54
94
  describe('extractTranscription', () => {
95
+ test('only offers contextual routing when a source session exists', () => {
96
+ expect(buildTranscriptionTool({}).inputSchema).toMatchObject({
97
+ properties: { sessionAction: { enum: ['new-session'] } },
98
+ });
99
+ expect(buildTranscriptionTool({ canForkSession: true }).inputSchema).toMatchObject({
100
+ properties: { sessionAction: { enum: ['btw', 'new-session'] } },
101
+ });
102
+ expect(JSON.stringify(buildTranscriptionTool({ canForkSession: true })))
103
+ .not.toContain('by the way');
104
+ });
105
+ test.each(['btw', 'new-session'])('extracts %s routing without queueing', (sessionAction) => {
106
+ const result = extractTranscription([{
107
+ type: 'tool-call',
108
+ toolCallId: 'routing',
109
+ toolName: 'transcriptionResult',
110
+ input: JSON.stringify({
111
+ transcription: 'Explain the authentication flow',
112
+ sessionAction,
113
+ queueMessage: true,
114
+ agent: 'plan',
115
+ }),
116
+ }]);
117
+ expect(result).toEqual({
118
+ transcription: 'Explain the authentication flow',
119
+ sessionAction,
120
+ queueMessage: false,
121
+ agent: 'plan',
122
+ });
123
+ });
124
+ test('ignores unknown session routing', () => {
125
+ const result = extractTranscription([{
126
+ type: 'tool-call',
127
+ toolCallId: 'routing',
128
+ toolName: 'transcriptionResult',
129
+ input: JSON.stringify({ transcription: 'Keep working', sessionAction: 'invalid' }),
130
+ }]);
131
+ expect(result).toEqual({ transcription: 'Keep working', queueMessage: false, agent: undefined });
132
+ });
55
133
  test('extracts transcription from tool call', () => {
56
134
  const result = extractTranscription([
57
135
  {
@@ -65,6 +143,7 @@ describe('extractTranscription', () => {
65
143
  {
66
144
  "agent": undefined,
67
145
  "queueMessage": false,
146
+ "sessionAction": undefined,
68
147
  "transcription": "hello world",
69
148
  }
70
149
  `);
@@ -85,6 +164,7 @@ describe('extractTranscription', () => {
85
164
  {
86
165
  "agent": undefined,
87
166
  "queueMessage": true,
167
+ "sessionAction": undefined,
88
168
  "transcription": "Fix the login bug in auth.ts",
89
169
  }
90
170
  `);
@@ -143,18 +223,17 @@ describe('extractTranscription', () => {
143
223
  expect(result.message).toMatchInlineSnapshot(`"Transcription failed: Model did not produce a transcription"`);
144
224
  });
145
225
  });
146
- describe('transcribeAudio with real API', () => {
226
+ const liveVoiceTest = describe.skipIf(process.env['KIMAKI_LIVE_VOICE_TESTS'] !== '1');
227
+ liveVoiceTest('transcribeAudio with real API', () => {
147
228
  const audioPath = path.join(import.meta.dirname, '..', 'scripts', 'example-audio.mp3');
148
- test('transcribes with Gemini', { timeout: 30_000 }, async () => {
229
+ const oggPath = path.join(import.meta.dirname, '..', 'scripts', 'example-audio.ogg');
230
+ const geminiLiveTest = test.skipIf(!process.env.GEMINI_API_KEY || !fs.existsSync(audioPath));
231
+ const openAiLiveTest = test.skipIf(!process.env.OPENAI_API_KEY || !fs.existsSync(audioPath));
232
+ const openAiOggLiveTest = test.skipIf(!process.env.OPENAI_API_KEY || !fs.existsSync(oggPath));
233
+ geminiLiveTest('transcribes with Gemini', { timeout: 30_000 }, async () => {
149
234
  const apiKey = process.env.GEMINI_API_KEY;
150
- if (!apiKey) {
151
- console.log('Skipping: GEMINI_API_KEY not set');
152
- return;
153
- }
154
- if (!fs.existsSync(audioPath)) {
155
- console.log('Skipping: example-audio.mp3 not found');
156
- return;
157
- }
235
+ if (!apiKey)
236
+ throw new Error('GEMINI_API_KEY is required');
158
237
  const audio = fs.readFileSync(audioPath);
159
238
  const result = await transcribeAudio({
160
239
  audio,
@@ -167,16 +246,10 @@ describe('transcribeAudio with real API', () => {
167
246
  expect(transcription.length).toBeGreaterThan(0);
168
247
  console.log('Gemini transcription:', result);
169
248
  });
170
- test('transcribes with OpenAI', { timeout: 30_000 }, async () => {
249
+ openAiLiveTest('transcribes with OpenAI', { timeout: 30_000 }, async () => {
171
250
  const apiKey = process.env.OPENAI_API_KEY;
172
- if (!apiKey) {
173
- console.log('Skipping: OPENAI_API_KEY not set');
174
- return;
175
- }
176
- if (!fs.existsSync(audioPath)) {
177
- console.log('Skipping: example-audio.mp3 not found');
178
- return;
179
- }
251
+ if (!apiKey)
252
+ throw new Error('OPENAI_API_KEY is required');
180
253
  const audio = fs.readFileSync(audioPath);
181
254
  const result = await transcribeAudio({
182
255
  audio,
@@ -189,17 +262,10 @@ describe('transcribeAudio with real API', () => {
189
262
  expect(transcription.length).toBeGreaterThan(0);
190
263
  console.log('OpenAI transcription:', result);
191
264
  });
192
- test('transcribes OGG with OpenAI (converts to WAV)', { timeout: 30_000 }, async () => {
265
+ openAiOggLiveTest('transcribes OGG with OpenAI (converts to WAV)', { timeout: 30_000 }, async () => {
193
266
  const apiKey = process.env.OPENAI_API_KEY;
194
- const oggPath = path.join(import.meta.dirname, '..', 'scripts', 'example-audio.ogg');
195
- if (!apiKey) {
196
- console.log('Skipping: OPENAI_API_KEY not set');
197
- return;
198
- }
199
- if (!fs.existsSync(oggPath)) {
200
- console.log('Skipping: example-audio.ogg not found');
201
- return;
202
- }
267
+ if (!apiKey)
268
+ throw new Error('OPENAI_API_KEY is required');
203
269
  const audio = fs.readFileSync(oggPath);
204
270
  const result = await transcribeAudio({
205
271
  audio,
@@ -0,0 +1,188 @@
1
+ // Verifies OpenCode workspace creation stays bound to the requested local clone.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { afterAll, beforeAll, expect, test } from 'vitest';
5
+ import { setDataDir } from './config.js';
6
+ import { tryWorkspaceCreate } from './commands/new-worktree.js';
7
+ import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
8
+ import { chooseLockPort } from './test-utils.js';
9
+ import { execAsync, getManagedWorktreeDirectory } from './worktrees.js';
10
+ import { KIMAKI_WORKTREE_ADAPTER_TYPE } from './git-worktree-core.js';
11
+ const WORKTREE_BRANCH = 'opencode/kimaki-clone-isolation';
12
+ const REJECTED_WORKTREE_BRANCH = 'opencode/kimaki-rejected-clone-isolation';
13
+ async function git({ cwd, args }) {
14
+ const result = await execAsync(`git ${args.map((arg) => JSON.stringify(arg)).join(' ')}`, {
15
+ cwd,
16
+ timeout: 60_000,
17
+ });
18
+ return result.stdout.trim();
19
+ }
20
+ let sandbox = '';
21
+ let requestedClone = '';
22
+ let otherClone = '';
23
+ beforeAll(async () => {
24
+ const root = path.resolve(process.cwd(), 'tmp');
25
+ fs.mkdirSync(root, { recursive: true });
26
+ sandbox = fs.mkdtempSync(path.join(root, 'worktree-clone-isolation-'));
27
+ const remote = path.join(sandbox, 'remote.git');
28
+ requestedClone = path.join(sandbox, 'holocron');
29
+ otherClone = path.join(sandbox, 'fumabase');
30
+ process.env['KIMAKI_LOCK_PORT'] = String(chooseLockPort({ key: 'worktree-clone-isolation' }));
31
+ setDataDir(path.join(sandbox, 'data'));
32
+ await git({ cwd: sandbox, args: ['init', '--bare', '-b', 'main', remote] });
33
+ await git({ cwd: sandbox, args: ['clone', remote, otherClone] });
34
+ await git({
35
+ cwd: otherClone,
36
+ args: ['config', 'user.email', 'kimaki-tests@example.com'],
37
+ });
38
+ await git({ cwd: otherClone, args: ['config', 'user.name', 'Kimaki Tests'] });
39
+ fs.writeFileSync(path.join(otherClone, 'preview.txt'), 'old preview\n');
40
+ await git({ cwd: otherClone, args: ['add', 'preview.txt'] });
41
+ await git({ cwd: otherClone, args: ['commit', '-m', 'old preview'] });
42
+ await git({ cwd: otherClone, args: ['push', 'origin', 'HEAD:main'] });
43
+ await git({ cwd: otherClone, args: ['switch', '-c', 'preview'] });
44
+ await git({ cwd: sandbox, args: ['clone', remote, requestedClone] });
45
+ await git({
46
+ cwd: requestedClone,
47
+ args: ['config', 'user.email', 'kimaki-tests@example.com'],
48
+ });
49
+ await git({ cwd: requestedClone, args: ['config', 'user.name', 'Kimaki Tests'] });
50
+ fs.mkdirSync(path.join(requestedClone, 'vite'));
51
+ fs.writeFileSync(path.join(requestedClone, 'vite', 'README.md'), 'new layout\n');
52
+ await git({ cwd: requestedClone, args: ['add', 'vite/README.md'] });
53
+ await git({ cwd: requestedClone, args: ['commit', '-m', 'add vite layout'] });
54
+ }, 20_000);
55
+ afterAll(async () => {
56
+ await stopOpencodeServer();
57
+ delete process.env['KIMAKI_LOCK_PORT'];
58
+ if (sandbox)
59
+ fs.rmSync(sandbox, { recursive: true, force: true });
60
+ });
61
+ test('creates a workspace from the exact requested clone and commit', async () => {
62
+ const requestedCommit = await git({
63
+ cwd: requestedClone,
64
+ args: ['rev-parse', 'HEAD^{commit}'],
65
+ });
66
+ const requestedCommonDirectory = path.resolve(requestedClone, await git({
67
+ cwd: requestedClone,
68
+ args: ['rev-parse', '--git-common-dir'],
69
+ }));
70
+ const requestedClientResult = await initializeOpencodeForDirectory(requestedClone);
71
+ if (requestedClientResult instanceof Error)
72
+ throw requestedClientResult;
73
+ const otherClientResult = await initializeOpencodeForDirectory(otherClone);
74
+ if (otherClientResult instanceof Error)
75
+ throw otherClientResult;
76
+ const requestedClient = requestedClientResult();
77
+ const otherClient = otherClientResult();
78
+ // Load the requested clone first, then overwrite the colliding OpenCode
79
+ // project adapter with the other clone before creating the workspace.
80
+ await requestedClient.config.get({ directory: requestedClone });
81
+ await otherClient.config.get({ directory: otherClone });
82
+ const response = await requestedClient.experimental.workspace.create({
83
+ directory: requestedClone,
84
+ type: KIMAKI_WORKTREE_ADAPTER_TYPE,
85
+ branch: WORKTREE_BRANCH,
86
+ extra: {
87
+ projectDirectory: requestedClone,
88
+ baseCommit: requestedCommit,
89
+ },
90
+ });
91
+ if (response.error)
92
+ throw new Error(JSON.stringify(response.error));
93
+ const workspace = response.data;
94
+ if (!workspace)
95
+ throw new Error('OpenCode returned no workspace');
96
+ try {
97
+ const actualCommit = await git({
98
+ cwd: workspace.directory,
99
+ args: ['rev-parse', 'HEAD^{commit}'],
100
+ });
101
+ const actualCommonDirectory = path.resolve(workspace.directory, await git({
102
+ cwd: workspace.directory,
103
+ args: ['rev-parse', '--git-common-dir'],
104
+ }));
105
+ expect({
106
+ usesRequestedClone: path.resolve(actualCommonDirectory) === path.resolve(requestedCommonDirectory),
107
+ usesRequestedCommit: actualCommit === requestedCommit,
108
+ hasRequestedLayout: fs.existsSync(path.join(workspace.directory, 'vite', 'README.md')),
109
+ absentFromOtherClone: !(await git({
110
+ cwd: otherClone,
111
+ args: ['worktree', 'list', '--porcelain'],
112
+ })).includes(workspace.directory),
113
+ }).toMatchInlineSnapshot(`
114
+ {
115
+ "absentFromOtherClone": true,
116
+ "hasRequestedLayout": true,
117
+ "usesRequestedClone": true,
118
+ "usesRequestedCommit": true,
119
+ }
120
+ `);
121
+ }
122
+ finally {
123
+ await requestedClient.experimental.workspace.remove({
124
+ id: workspace.id,
125
+ directory: requestedClone,
126
+ });
127
+ }
128
+ }, 30_000);
129
+ test('removes workspace state when identity validation rejects creation', async () => {
130
+ const requestedCommit = await git({
131
+ cwd: requestedClone,
132
+ args: ['rev-parse', 'HEAD^{commit}'],
133
+ });
134
+ const result = await tryWorkspaceCreate({
135
+ worktreeName: REJECTED_WORKTREE_BRANCH,
136
+ projectDirectory: requestedClone,
137
+ baseCommit: requestedCommit.toUpperCase(),
138
+ });
139
+ expect(result).toBeInstanceOf(Error);
140
+ const clientResult = await initializeOpencodeForDirectory(requestedClone);
141
+ if (clientResult instanceof Error)
142
+ throw clientResult;
143
+ const client = clientResult();
144
+ const listResponse = await client.experimental.workspace.list({
145
+ directory: requestedClone,
146
+ });
147
+ if (listResponse.error)
148
+ throw new Error(JSON.stringify(listResponse.error));
149
+ const rejectedWorkspaces = (listResponse.data ?? []).filter((workspace) => {
150
+ return workspace.branch === REJECTED_WORKTREE_BRANCH;
151
+ });
152
+ try {
153
+ const managedDirectory = getManagedWorktreeDirectory({
154
+ directory: requestedClone,
155
+ name: REJECTED_WORKTREE_BRANCH,
156
+ });
157
+ const [requestedWorktrees, otherWorktrees] = await Promise.all([
158
+ git({ cwd: requestedClone, args: ['worktree', 'list', '--porcelain'] }),
159
+ git({ cwd: otherClone, args: ['worktree', 'list', '--porcelain'] }),
160
+ ]);
161
+ const branchExists = await execAsync(`git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${REJECTED_WORKTREE_BRANCH}`)}`, { cwd: requestedClone })
162
+ .then(() => true)
163
+ .catch(() => false);
164
+ expect({
165
+ workspaceRows: rejectedWorkspaces.length,
166
+ directoryExists: fs.existsSync(managedDirectory),
167
+ requestedCloneRegistration: requestedWorktrees.includes(managedDirectory),
168
+ otherCloneRegistration: otherWorktrees.includes(managedDirectory),
169
+ branchExists,
170
+ }).toMatchInlineSnapshot(`
171
+ {
172
+ "branchExists": false,
173
+ "directoryExists": false,
174
+ "otherCloneRegistration": false,
175
+ "requestedCloneRegistration": false,
176
+ "workspaceRows": 0,
177
+ }
178
+ `);
179
+ }
180
+ finally {
181
+ await Promise.all(rejectedWorkspaces.map((workspace) => {
182
+ return client.experimental.workspace.remove({
183
+ id: workspace.id,
184
+ directory: requestedClone,
185
+ });
186
+ }));
187
+ }
188
+ }, 30_000);