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
@@ -0,0 +1,131 @@
1
+ // Voice routes must leave the source question answerable without aborting its run.
2
+ import { afterEach, describe, expect, test } from 'vitest'
3
+ import { setupQueueAdvancedSuite, TEST_USER_ID } from './queue-advanced-e2e-setup.js'
4
+ import { waitForBotMessageContaining, waitForFooterMessage } from './test-utils.js'
5
+ import { store } from './store.js'
6
+
7
+ const TEXT_CHANNEL_ID = '200000000000001083'
8
+
9
+ describe('voice routing preserves source questions', () => {
10
+ const ctx = setupQueueAdvancedSuite({
11
+ channelId: TEXT_CHANNEL_ID,
12
+ channelName: 'voice-routing-question',
13
+ dirName: 'voice-routing-question',
14
+ username: 'voice-question-tester',
15
+ })
16
+
17
+ afterEach(() => {
18
+ store.setState({ test: { deterministicTranscription: null } })
19
+ })
20
+
21
+ // Separate registrations permit distinct inline snapshots with Vitest 3.
22
+ for (const sessionAction of ['btw', 'new-session'] as const) {
23
+ test(`${sessionAction} preserves the source dropdown`, async () => {
24
+ const marker = `QUESTION_SELECT_QUEUE_MARKER voice ${sessionAction}`
25
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({ content: marker })
26
+ const source = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
27
+ timeout: 4_000,
28
+ predicate: (thread) => thread.name === marker,
29
+ })
30
+ const th = ctx.discord.thread(source.id)
31
+ const messages = await waitForBotMessageContaining({
32
+ discord: ctx.discord,
33
+ threadId: source.id,
34
+ text: 'How to proceed?',
35
+ timeout: 4_000,
36
+ })
37
+ const question = messages.find((message) => message.content.includes('How to proceed?'))
38
+ if (!question) throw new Error('Expected question message')
39
+ const customId = JSON.stringify(question.components).match(
40
+ /"custom_id":"(ask_question:[^"]+)"/,
41
+ )?.[1]
42
+ if (!customId) throw new Error('Expected question dropdown')
43
+
44
+ const prompt = `Explain routing without changing the source ${sessionAction}`
45
+ store.setState({
46
+ test: {
47
+ deterministicTranscription: {
48
+ transcription: prompt,
49
+ queueMessage: false,
50
+ sessionAction,
51
+ },
52
+ },
53
+ })
54
+ await th.user(TEST_USER_ID).sendVoiceMessage()
55
+ const confirmation = sessionAction === 'btw' ? 'Session forked!' : 'Created new session in'
56
+ const routingMessages = await waitForBotMessageContaining({
57
+ discord: ctx.discord,
58
+ threadId: source.id,
59
+ text: confirmation,
60
+ timeout: 4_000,
61
+ })
62
+ const targetId = routingMessages.find((message) => message.content.includes(confirmation))
63
+ ?.content.match(/<#(\d+)>/)?.[1]
64
+ if (!targetId) throw new Error('Expected destination thread link')
65
+ const target = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
66
+ timeout: 4_000,
67
+ predicate: (thread) => thread.id === targetId,
68
+ })
69
+ await waitForFooterMessage({ discord: ctx.discord, threadId: target.id, timeout: 4_000 })
70
+ const interaction = await th.user(TEST_USER_ID).selectMenu({
71
+ messageId: question.id,
72
+ customId,
73
+ values: ['0'],
74
+ })
75
+ await th.waitForInteractionAck({ interactionId: interaction.id, timeout: 4_000 })
76
+ await waitForBotMessageContaining({
77
+ discord: ctx.discord,
78
+ threadId: source.id,
79
+ text: '» **voice-question-tester:** Alpha',
80
+ timeout: 4_000,
81
+ })
82
+ await waitForFooterMessage({ discord: ctx.discord, threadId: source.id, timeout: 4_000 })
83
+ const transcript = (await th.text({ showInteractions: true })).replaceAll(
84
+ target.id,
85
+ 'TARGET_THREAD',
86
+ )
87
+ if (sessionAction === 'btw') {
88
+ expect(transcript).toMatchInlineSnapshot(`
89
+ "--- from: user (voice-question-tester)
90
+ QUESTION_SELECT_QUEUE_MARKER voice btw
91
+ --- from: assistant (TestBot)
92
+ > *using deterministic-provider/deterministic-v2*
93
+ **Select action**
94
+ How to proceed?
95
+ ✓ _Alpha_
96
+ --- from: user (voice-question-tester)
97
+ [attachment: voice-message.ogg]
98
+ --- from: assistant (TestBot)
99
+ 🎤 Transcribing voice message...
100
+ 📝 **Transcribed message:** Explain routing without changing the source btw
101
+ Session forked! Continue in <#TARGET_THREAD>
102
+ [user selects dropdown: 0]
103
+ » **voice-question-tester:** Alpha
104
+ > tool done
105
+ > *project ⋅ main ⋅ 1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
106
+ `)
107
+ } else {
108
+ expect(transcript).toMatchInlineSnapshot(`
109
+ "--- from: user (voice-question-tester)
110
+ QUESTION_SELECT_QUEUE_MARKER voice new-session
111
+ --- from: assistant (TestBot)
112
+ > *using deterministic-provider/deterministic-v2*
113
+ **Select action**
114
+ How to proceed?
115
+ ✓ _Alpha_
116
+ --- from: user (voice-question-tester)
117
+ [attachment: voice-message.ogg]
118
+ --- from: assistant (TestBot)
119
+ 🎤 Transcribing voice message...
120
+ 📝 **Transcribed message:** Explain routing without changing the source new-session
121
+ Created new session in <#TARGET_THREAD>
122
+ [user selects dropdown: 0]
123
+ » **voice-question-tester:** Alpha
124
+ > tool done
125
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
126
+ `)
127
+ }
128
+ expect(transcript).not.toContain('expired')
129
+ })
130
+ }
131
+ })
package/src/voice.test.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  extractTranscription,
11
11
  normalizeAudioMediaType,
12
12
  getOpenAIAudioConversionStrategy,
13
+ buildTranscriptionTool,
13
14
  } from './voice.js'
14
15
  import {
15
16
  getVoiceAttachmentMatchReason,
@@ -65,9 +66,92 @@ describe('voice attachment detection', () => {
65
66
  ]
66
67
  `)
67
68
  })
69
+
70
+ test('does not treat iOS and Discord videos as voice', () => {
71
+ expect(
72
+ [
73
+ getVoiceAttachmentMatchReason({
74
+ name: 'ScreenRecording_08-26-2026_11-10-29_1.mov',
75
+ contentType: 'video/quicktime',
76
+ duration: 12.4,
77
+ width: 1170,
78
+ height: 2532,
79
+ }),
80
+ getVoiceAttachmentMatchReason({
81
+ name: 'IMG_1234.MOV',
82
+ contentType: null,
83
+ duration: 8,
84
+ width: 1920,
85
+ height: 1080,
86
+ }),
87
+ getVoiceAttachmentMatchReason({
88
+ name: 'clip.mp4',
89
+ contentType: 'video/mp4',
90
+ duration: 4,
91
+ }),
92
+ getVoiceAttachmentMatchReason({
93
+ name: 'clip.mp4',
94
+ contentType: null,
95
+ }),
96
+ isVoiceAttachment({
97
+ name: 'memo.m4a',
98
+ contentType: null,
99
+ duration: 3.2,
100
+ }),
101
+ ]).toMatchInlineSnapshot(`
102
+ [
103
+ null,
104
+ null,
105
+ null,
106
+ null,
107
+ true,
108
+ ]
109
+ `)
110
+ })
68
111
  })
69
112
 
70
113
  describe('extractTranscription', () => {
114
+ test('only offers contextual routing when a source session exists', () => {
115
+ expect(buildTranscriptionTool({}).inputSchema).toMatchObject({
116
+ properties: { sessionAction: { enum: ['new-session'] } },
117
+ })
118
+ expect(buildTranscriptionTool({ canForkSession: true }).inputSchema).toMatchObject({
119
+ properties: { sessionAction: { enum: ['btw', 'new-session'] } },
120
+ })
121
+ expect(JSON.stringify(buildTranscriptionTool({ canForkSession: true })))
122
+ .not.toContain('by the way')
123
+ })
124
+
125
+ test.each(['btw', 'new-session'])('extracts %s routing without queueing', (sessionAction) => {
126
+ const result = extractTranscription([{
127
+ type: 'tool-call',
128
+ toolCallId: 'routing',
129
+ toolName: 'transcriptionResult',
130
+ input: JSON.stringify({
131
+ transcription: 'Explain the authentication flow',
132
+ sessionAction,
133
+ queueMessage: true,
134
+ agent: 'plan',
135
+ }),
136
+ }])
137
+ expect(result).toEqual({
138
+ transcription: 'Explain the authentication flow',
139
+ sessionAction,
140
+ queueMessage: false,
141
+ agent: 'plan',
142
+ })
143
+ })
144
+
145
+ test('ignores unknown session routing', () => {
146
+ const result = extractTranscription([{
147
+ type: 'tool-call',
148
+ toolCallId: 'routing',
149
+ toolName: 'transcriptionResult',
150
+ input: JSON.stringify({ transcription: 'Keep working', sessionAction: 'invalid' }),
151
+ }])
152
+ expect(result).toEqual({ transcription: 'Keep working', queueMessage: false, agent: undefined })
153
+ })
154
+
71
155
  test('extracts transcription from tool call', () => {
72
156
  const result = extractTranscription([
73
157
  {
@@ -81,6 +165,7 @@ describe('extractTranscription', () => {
81
165
  {
82
166
  "agent": undefined,
83
167
  "queueMessage": false,
168
+ "sessionAction": undefined,
84
169
  "transcription": "hello world",
85
170
  }
86
171
  `)
@@ -102,6 +187,7 @@ describe('extractTranscription', () => {
102
187
  {
103
188
  "agent": undefined,
104
189
  "queueMessage": true,
190
+ "sessionAction": undefined,
105
191
  "transcription": "Fix the login bug in auth.ts",
106
192
  }
107
193
  `)
@@ -172,24 +258,31 @@ describe('extractTranscription', () => {
172
258
  })
173
259
  })
174
260
 
175
- describe('transcribeAudio with real API', () => {
261
+ const liveVoiceTest = describe.skipIf(
262
+ process.env['KIMAKI_LIVE_VOICE_TESTS'] !== '1',
263
+ )
264
+
265
+ liveVoiceTest('transcribeAudio with real API', () => {
176
266
  const audioPath = path.join(
177
267
  import.meta.dirname,
178
268
  '..',
179
269
  'scripts',
180
270
  'example-audio.mp3',
181
271
  )
272
+ const oggPath = path.join(import.meta.dirname, '..', 'scripts', 'example-audio.ogg')
273
+ const geminiLiveTest = test.skipIf(
274
+ !process.env.GEMINI_API_KEY || !fs.existsSync(audioPath),
275
+ )
276
+ const openAiLiveTest = test.skipIf(
277
+ !process.env.OPENAI_API_KEY || !fs.existsSync(audioPath),
278
+ )
279
+ const openAiOggLiveTest = test.skipIf(
280
+ !process.env.OPENAI_API_KEY || !fs.existsSync(oggPath),
281
+ )
182
282
 
183
- test('transcribes with Gemini', { timeout: 30_000 }, async () => {
283
+ geminiLiveTest('transcribes with Gemini', { timeout: 30_000 }, async () => {
184
284
  const apiKey = process.env.GEMINI_API_KEY
185
- if (!apiKey) {
186
- console.log('Skipping: GEMINI_API_KEY not set')
187
- return
188
- }
189
- if (!fs.existsSync(audioPath)) {
190
- console.log('Skipping: example-audio.mp3 not found')
191
- return
192
- }
285
+ if (!apiKey) throw new Error('GEMINI_API_KEY is required')
193
286
 
194
287
  const audio = fs.readFileSync(audioPath)
195
288
  const result = await transcribeAudio({
@@ -205,16 +298,9 @@ describe('transcribeAudio with real API', () => {
205
298
  console.log('Gemini transcription:', result)
206
299
  })
207
300
 
208
- test('transcribes with OpenAI', { timeout: 30_000 }, async () => {
301
+ openAiLiveTest('transcribes with OpenAI', { timeout: 30_000 }, async () => {
209
302
  const apiKey = process.env.OPENAI_API_KEY
210
- if (!apiKey) {
211
- console.log('Skipping: OPENAI_API_KEY not set')
212
- return
213
- }
214
- if (!fs.existsSync(audioPath)) {
215
- console.log('Skipping: example-audio.mp3 not found')
216
- return
217
- }
303
+ if (!apiKey) throw new Error('OPENAI_API_KEY is required')
218
304
 
219
305
  const audio = fs.readFileSync(audioPath)
220
306
  const result = await transcribeAudio({
@@ -230,17 +316,9 @@ describe('transcribeAudio with real API', () => {
230
316
  console.log('OpenAI transcription:', result)
231
317
  })
232
318
 
233
- test('transcribes OGG with OpenAI (converts to WAV)', { timeout: 30_000 }, async () => {
319
+ openAiOggLiveTest('transcribes OGG with OpenAI (converts to WAV)', { timeout: 30_000 }, async () => {
234
320
  const apiKey = process.env.OPENAI_API_KEY
235
- const oggPath = path.join(import.meta.dirname, '..', 'scripts', 'example-audio.ogg')
236
- if (!apiKey) {
237
- console.log('Skipping: OPENAI_API_KEY not set')
238
- return
239
- }
240
- if (!fs.existsSync(oggPath)) {
241
- console.log('Skipping: example-audio.ogg not found')
242
- return
243
- }
321
+ if (!apiKey) throw new Error('OPENAI_API_KEY is required')
244
322
 
245
323
  const audio = fs.readFileSync(oggPath)
246
324
  const result = await transcribeAudio({
package/src/voice.ts CHANGED
@@ -258,22 +258,31 @@ type TranscriptionLoopError =
258
258
 
259
259
  // Build the transcription tool schema dynamically so the agent field can
260
260
  // use an enum constrained to the actual available agent names.
261
- function buildTranscriptionTool({
261
+ export function buildTranscriptionTool({
262
262
  agentNames,
263
+ canForkSession = false,
263
264
  }: {
264
265
  agentNames?: string[]
266
+ canForkSession?: boolean
265
267
  }): LanguageModelV3FunctionTool {
266
268
  const properties: Record<string, Record<string, unknown>> = {
267
269
  transcription: {
268
270
  type: 'string',
269
271
  description:
270
- '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]".',
272
+ '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]".',
271
273
  },
272
274
  queueMessage: {
273
275
  type: 'boolean',
274
276
  description:
275
277
  '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.',
276
278
  },
279
+ sessionAction: {
280
+ type: 'string',
281
+ enum: canForkSession ? ['btw', 'new-session'] : ['new-session'],
282
+ description:
283
+ '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.' +
284
+ (canForkSession ? ' Use "btw" only when explicitly asked to create a side chat or fork with current context.' : ''),
285
+ },
277
286
  }
278
287
 
279
288
  if (agentNames && agentNames.length > 0) {
@@ -301,6 +310,7 @@ function buildTranscriptionTool({
301
310
  export type TranscriptionResult = {
302
311
  transcription: string
303
312
  queueMessage: boolean
313
+ sessionAction?: 'btw' | 'new-session'
304
314
  /** Agent name extracted from voice message, only set if user explicitly requested an agent. */
305
315
  agent?: string
306
316
  }
@@ -308,7 +318,7 @@ export type TranscriptionResult = {
308
318
  /**
309
319
  * Extract transcription result from doGenerate content array.
310
320
  * Looks for a tool-call named 'transcriptionResult', falls back to text content.
311
- * Returns structured result with transcription text and queueMessage flag.
321
+ * Returns transcription text, queue/session routing, and optional agent selection.
312
322
  */
313
323
  export function extractTranscription(
314
324
  content: Array<LanguageModelV3Content>,
@@ -327,7 +337,10 @@ export function extractTranscription(
327
337
  return {}
328
338
  })()
329
339
  const transcription = (typeof args.transcription === 'string' ? args.transcription : '').trim()
330
- const queueMessage = args.queueMessage === true
340
+ const sessionAction = args.sessionAction === 'btw' || args.sessionAction === 'new-session'
341
+ ? args.sessionAction
342
+ : undefined
343
+ const queueMessage = !sessionAction && args.queueMessage === true
331
344
  const agent = typeof args.agent === 'string' ? args.agent : undefined
332
345
  voiceLogger.log(
333
346
  `Transcription result received: "${transcription.slice(0, 100)}..."${queueMessage ? ' [QUEUE]' : ''}${agent ? ` [AGENT:${agent}]` : ''}`,
@@ -335,7 +348,7 @@ export function extractTranscription(
335
348
  if (!transcription) {
336
349
  return new EmptyTranscriptionError()
337
350
  }
338
- return { transcription, queueMessage, agent }
351
+ return { transcription, queueMessage, agent, sessionAction }
339
352
  }
340
353
 
341
354
  // Fall back to text content if no tool call
@@ -363,6 +376,7 @@ async function runTranscriptionOnce({
363
376
  mediaType,
364
377
  temperature,
365
378
  agentNames,
379
+ canForkSession,
366
380
  provider,
367
381
  }: {
368
382
  model: LanguageModelV3
@@ -371,9 +385,10 @@ async function runTranscriptionOnce({
371
385
  mediaType: string
372
386
  temperature: number
373
387
  agentNames?: string[]
388
+ canForkSession?: boolean
374
389
  provider?: TranscriptionProvider
375
390
  }): Promise<TranscriptionLoopError | TranscriptionResult> {
376
- const tool = buildTranscriptionTool({ agentNames })
391
+ const tool = buildTranscriptionTool({ agentNames, canForkSession })
377
392
  const options: LanguageModelV3CallOptions = {
378
393
  prompt: [
379
394
  {
@@ -471,6 +486,7 @@ export async function transcribeAudio({
471
486
  currentSessionContext,
472
487
  lastSessionContext,
473
488
  agents,
489
+ canForkSession = false,
474
490
  }: {
475
491
  audio: Buffer | Uint8Array | ArrayBuffer | string
476
492
  prompt?: string
@@ -485,6 +501,7 @@ export async function transcribeAudio({
485
501
  lastSessionContext?: string
486
502
  /** Available agents for agent selection via voice. Names used as enum values in the tool schema. */
487
503
  agents?: Array<{ name: string; description?: string }>
504
+ canForkSession?: boolean
488
505
  }): Promise<TranscribeAudioErrors | TranscriptionResult> {
489
506
  const apiKey =
490
507
  apiKeyParam || process.env.OPENAI_API_KEY || process.env.GEMINI_API_KEY
@@ -595,6 +612,16 @@ This is a software development environment. The speaker is giving instructions t
595
612
  - Example: "Queue this message. Fix the login bug in auth.ts" → transcription: "Fix the login bug in auth.ts", queueMessage: true
596
613
  - 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.
597
614
  - If no queue intent is detected, omit queueMessage or set it to false.
615
+
616
+ SESSION ROUTING:
617
+ - 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.
618
+ ${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.'}
619
+ - Remove these routing words from the transcription. Include only the actual request, not instructions about where to send it.
620
+ - Example: "Fix the login bug. Create this as a new chat session" -> transcription: "Fix the login bug", sessionAction: "new-session".
621
+ - Do not infer routing from conversational phrases, task content, or session context.
622
+ - 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.
623
+ - If there is no actual request after removing routing words, return an empty transcription and omit sessionAction. Never invent placeholder content.
624
+ - Otherwise omit sessionAction. Agent selection can be combined with either route.
598
625
  ${agents && agents.length > 0 ? `
599
626
  AGENT SELECTION:
600
627
  - 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.
@@ -634,6 +661,7 @@ Note: "critique" is a CLI tool for showing diffs in the browser.`
634
661
  mediaType,
635
662
  temperature: temperature ?? 0.3,
636
663
  agentNames: agentNames && agentNames.length > 0 ? agentNames : undefined,
664
+ canForkSession,
637
665
  provider: resolvedProvider,
638
666
  })
639
667
  }
@@ -0,0 +1,213 @@
1
+ // Verifies OpenCode workspace creation stays bound to the requested local clone.
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { afterAll, beforeAll, expect, test } from 'vitest'
6
+ import { setDataDir } from './config.js'
7
+ import { tryWorkspaceCreate } from './commands/new-worktree.js'
8
+ import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js'
9
+ import { chooseLockPort } from './test-utils.js'
10
+ import { execAsync, getManagedWorktreeDirectory } from './worktrees.js'
11
+ import { KIMAKI_WORKTREE_ADAPTER_TYPE } from './git-worktree-core.js'
12
+
13
+ const WORKTREE_BRANCH = 'opencode/kimaki-clone-isolation'
14
+ const REJECTED_WORKTREE_BRANCH = 'opencode/kimaki-rejected-clone-isolation'
15
+
16
+ async function git({ cwd, args }: { cwd: string; args: string[] }) {
17
+ const result = await execAsync(`git ${args.map((arg) => JSON.stringify(arg)).join(' ')}`, {
18
+ cwd,
19
+ timeout: 60_000,
20
+ })
21
+ return result.stdout.trim()
22
+ }
23
+
24
+ let sandbox = ''
25
+ let requestedClone = ''
26
+ let otherClone = ''
27
+
28
+ beforeAll(async () => {
29
+ const root = path.resolve(process.cwd(), 'tmp')
30
+ fs.mkdirSync(root, { recursive: true })
31
+ sandbox = fs.mkdtempSync(path.join(root, 'worktree-clone-isolation-'))
32
+ const remote = path.join(sandbox, 'remote.git')
33
+ requestedClone = path.join(sandbox, 'holocron')
34
+ otherClone = path.join(sandbox, 'fumabase')
35
+
36
+ process.env['KIMAKI_LOCK_PORT'] = String(chooseLockPort({ key: 'worktree-clone-isolation' }))
37
+ setDataDir(path.join(sandbox, 'data'))
38
+
39
+ await git({ cwd: sandbox, args: ['init', '--bare', '-b', 'main', remote] })
40
+ await git({ cwd: sandbox, args: ['clone', remote, otherClone] })
41
+ await git({
42
+ cwd: otherClone,
43
+ args: ['config', 'user.email', 'kimaki-tests@example.com'],
44
+ })
45
+ await git({ cwd: otherClone, args: ['config', 'user.name', 'Kimaki Tests'] })
46
+ fs.writeFileSync(path.join(otherClone, 'preview.txt'), 'old preview\n')
47
+ await git({ cwd: otherClone, args: ['add', 'preview.txt'] })
48
+ await git({ cwd: otherClone, args: ['commit', '-m', 'old preview'] })
49
+ await git({ cwd: otherClone, args: ['push', 'origin', 'HEAD:main'] })
50
+ await git({ cwd: otherClone, args: ['switch', '-c', 'preview'] })
51
+
52
+ await git({ cwd: sandbox, args: ['clone', remote, requestedClone] })
53
+ await git({
54
+ cwd: requestedClone,
55
+ args: ['config', 'user.email', 'kimaki-tests@example.com'],
56
+ })
57
+ await git({ cwd: requestedClone, args: ['config', 'user.name', 'Kimaki Tests'] })
58
+ fs.mkdirSync(path.join(requestedClone, 'vite'))
59
+ fs.writeFileSync(path.join(requestedClone, 'vite', 'README.md'), 'new layout\n')
60
+ await git({ cwd: requestedClone, args: ['add', 'vite/README.md'] })
61
+ await git({ cwd: requestedClone, args: ['commit', '-m', 'add vite layout'] })
62
+ }, 20_000)
63
+
64
+ afterAll(async () => {
65
+ await stopOpencodeServer()
66
+ delete process.env['KIMAKI_LOCK_PORT']
67
+ if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true })
68
+ })
69
+
70
+ test('creates a workspace from the exact requested clone and commit', async () => {
71
+ const requestedCommit = await git({
72
+ cwd: requestedClone,
73
+ args: ['rev-parse', 'HEAD^{commit}'],
74
+ })
75
+ const requestedCommonDirectory = path.resolve(
76
+ requestedClone,
77
+ await git({
78
+ cwd: requestedClone,
79
+ args: ['rev-parse', '--git-common-dir'],
80
+ }),
81
+ )
82
+
83
+ const requestedClientResult = await initializeOpencodeForDirectory(requestedClone)
84
+ if (requestedClientResult instanceof Error) throw requestedClientResult
85
+ const otherClientResult = await initializeOpencodeForDirectory(otherClone)
86
+ if (otherClientResult instanceof Error) throw otherClientResult
87
+ const requestedClient = requestedClientResult()
88
+ const otherClient = otherClientResult()
89
+
90
+ // Load the requested clone first, then overwrite the colliding OpenCode
91
+ // project adapter with the other clone before creating the workspace.
92
+ await requestedClient.config.get({ directory: requestedClone })
93
+ await otherClient.config.get({ directory: otherClone })
94
+
95
+ const response = await requestedClient.experimental.workspace.create({
96
+ directory: requestedClone,
97
+ type: KIMAKI_WORKTREE_ADAPTER_TYPE,
98
+ branch: WORKTREE_BRANCH,
99
+ extra: {
100
+ projectDirectory: requestedClone,
101
+ baseCommit: requestedCommit,
102
+ },
103
+ })
104
+ if (response.error) throw new Error(JSON.stringify(response.error))
105
+ const workspace = response.data
106
+ if (!workspace) throw new Error('OpenCode returned no workspace')
107
+
108
+ try {
109
+ const actualCommit = await git({
110
+ cwd: workspace.directory!,
111
+ args: ['rev-parse', 'HEAD^{commit}'],
112
+ })
113
+ const actualCommonDirectory = path.resolve(
114
+ workspace.directory!,
115
+ await git({
116
+ cwd: workspace.directory!,
117
+ args: ['rev-parse', '--git-common-dir'],
118
+ }),
119
+ )
120
+
121
+ expect({
122
+ usesRequestedClone:
123
+ path.resolve(actualCommonDirectory) === path.resolve(requestedCommonDirectory),
124
+ usesRequestedCommit: actualCommit === requestedCommit,
125
+ hasRequestedLayout: fs.existsSync(path.join(workspace.directory!, 'vite', 'README.md')),
126
+ absentFromOtherClone: !(
127
+ await git({
128
+ cwd: otherClone,
129
+ args: ['worktree', 'list', '--porcelain'],
130
+ })
131
+ ).includes(workspace.directory!),
132
+ }).toMatchInlineSnapshot(`
133
+ {
134
+ "absentFromOtherClone": true,
135
+ "hasRequestedLayout": true,
136
+ "usesRequestedClone": true,
137
+ "usesRequestedCommit": true,
138
+ }
139
+ `)
140
+ } finally {
141
+ await requestedClient.experimental.workspace.remove({
142
+ id: workspace.id,
143
+ directory: requestedClone,
144
+ })
145
+ }
146
+ }, 30_000)
147
+
148
+ test('removes workspace state when identity validation rejects creation', async () => {
149
+ const requestedCommit = await git({
150
+ cwd: requestedClone,
151
+ args: ['rev-parse', 'HEAD^{commit}'],
152
+ })
153
+
154
+ const result = await tryWorkspaceCreate({
155
+ worktreeName: REJECTED_WORKTREE_BRANCH,
156
+ projectDirectory: requestedClone,
157
+ baseCommit: requestedCommit.toUpperCase(),
158
+ })
159
+ expect(result).toBeInstanceOf(Error)
160
+
161
+ const clientResult = await initializeOpencodeForDirectory(requestedClone)
162
+ if (clientResult instanceof Error) throw clientResult
163
+ const client = clientResult()
164
+ const listResponse = await client.experimental.workspace.list({
165
+ directory: requestedClone,
166
+ })
167
+ if (listResponse.error) throw new Error(JSON.stringify(listResponse.error))
168
+ const rejectedWorkspaces = (listResponse.data ?? []).filter((workspace) => {
169
+ return workspace.branch === REJECTED_WORKTREE_BRANCH
170
+ })
171
+
172
+ try {
173
+ const managedDirectory = getManagedWorktreeDirectory({
174
+ directory: requestedClone,
175
+ name: REJECTED_WORKTREE_BRANCH,
176
+ })
177
+ const [requestedWorktrees, otherWorktrees] = await Promise.all([
178
+ git({ cwd: requestedClone, args: ['worktree', 'list', '--porcelain'] }),
179
+ git({ cwd: otherClone, args: ['worktree', 'list', '--porcelain'] }),
180
+ ])
181
+ const branchExists = await execAsync(
182
+ `git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${REJECTED_WORKTREE_BRANCH}`)}`,
183
+ { cwd: requestedClone },
184
+ )
185
+ .then(() => true)
186
+ .catch(() => false)
187
+
188
+ expect({
189
+ workspaceRows: rejectedWorkspaces.length,
190
+ directoryExists: fs.existsSync(managedDirectory),
191
+ requestedCloneRegistration: requestedWorktrees.includes(managedDirectory),
192
+ otherCloneRegistration: otherWorktrees.includes(managedDirectory),
193
+ branchExists,
194
+ }).toMatchInlineSnapshot(`
195
+ {
196
+ "branchExists": false,
197
+ "directoryExists": false,
198
+ "otherCloneRegistration": false,
199
+ "requestedCloneRegistration": false,
200
+ "workspaceRows": 0,
201
+ }
202
+ `)
203
+ } finally {
204
+ await Promise.all(
205
+ rejectedWorkspaces.map((workspace) => {
206
+ return client.experimental.workspace.remove({
207
+ id: workspace.id,
208
+ directory: requestedClone,
209
+ })
210
+ }),
211
+ )
212
+ }
213
+ }, 30_000)