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,24 +1,109 @@
1
1
  // Message pre-processing pipeline for incoming Discord messages.
2
2
  // Extracts prompt text, voice transcription, file/text attachments, and
3
3
  // session context from a Discord Message before handing off to the runtime.
4
+ // Voice side/fresh-chat requests dispatch to separate threads instead.
4
5
  //
5
6
  // This module exists so discord-bot.ts stays a thin event router and the
6
7
  // expensive async work (voice transcription, context fetch, attachment
7
8
  // download) runs inside the runtime's serialized preprocessChain —
8
9
  // preserving arrival order without a separate threadIngressQueue.
9
10
  import { DiscordOperationError, OpenCodeSdkError } from './errors.js';
11
+ import { getOrCreateRuntime } from './session-handler/thread-session-runtime.js';
10
12
  import { resolveMentions, getFileAttachments, getTextAttachments, } from './message-formatting.js';
11
13
  import { processVoiceAttachment } from './voice-handler.js';
12
14
  import { isVoiceAttachment } from './voice-attachment.js';
13
15
  import { initializeOpencodeForDirectory } from './opencode.js';
14
16
  import { getCompactSessionContext, getLastSessionId } from './markdown.js';
15
- import { getThreadSession } from './database.js';
17
+ import { getThreadSession, getThreadWorktreeOrWorkspace } from './database.js';
18
+ import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage } from './discord-utils.js';
19
+ import { forkSessionToBtwThread } from './commands/btw.js';
20
+ import { createNewSessionThread } from './commands/session.js';
16
21
  import * as errore from 'errore';
17
22
  import { createLogger, LogPrefix } from './logger.js';
18
23
  import { notifyError } from './sentry.js';
19
24
  const logger = createLogger(LogPrefix.SESSION);
20
25
  const voiceLogger = createLogger(LogPrefix.VOICE);
21
26
  export const VOICE_MESSAGE_TRANSCRIPTION_PREFIX = 'Voice message transcription from Discord user:\n';
27
+ // Dispatch before returning a prompt so the source runtime never receives the side request.
28
+ async function routeVoiceSession({ voiceResult, message, thread, appId, }) {
29
+ if (!voiceResult?.sessionAction)
30
+ return false;
31
+ const resolved = await resolveWorkingDirectory({ channel: thread });
32
+ if (!resolved) {
33
+ await sendThreadMessage(thread, 'Could not determine project directory. Use /add-project to configure it, then retry.');
34
+ return true;
35
+ }
36
+ const images = await getFileAttachments(message);
37
+ const textAttachments = await getTextAttachments(message);
38
+ const caption = resolveMentions(message).trim();
39
+ const prompt = [caption, voiceResult.transcription, textAttachments]
40
+ .filter((part) => part?.trim())
41
+ .join('\n\n');
42
+ const modelPrompt = [caption, `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`, textAttachments]
43
+ .filter((part) => part?.trim())
44
+ .join('\n\n');
45
+ if (voiceResult.sessionAction === 'btw') {
46
+ const result = await forkSessionToBtwThread({
47
+ sourceThread: thread,
48
+ projectDirectory: resolved.projectDirectory,
49
+ sdkDirectory: resolved.workingDirectory,
50
+ prompt,
51
+ modelPrompt,
52
+ images,
53
+ agent: voiceResult.agent,
54
+ userId: message.author.id,
55
+ username: message.member?.displayName || message.author.displayName,
56
+ appId,
57
+ })
58
+ .catch((cause) => new DiscordOperationError({ operation: 'forkVoiceSession', cause }));
59
+ if (result instanceof Error) {
60
+ logger.error('Voice side chat failed:', result);
61
+ await sendThreadMessage(thread, `${result.message}. Start a session first, then retry with /btw.`);
62
+ return true;
63
+ }
64
+ await sendThreadMessage(thread, `Session forked! Continue in ${result.thread.toString()}`);
65
+ return true;
66
+ }
67
+ const textChannel = await resolveTextChannel(thread);
68
+ if (!textChannel) {
69
+ await sendThreadMessage(thread, 'Could not resolve parent text channel. Retry /new-session in the project channel.');
70
+ return true;
71
+ }
72
+ const result = await createNewSessionThread({
73
+ textChannel,
74
+ projectDirectory: resolved.projectDirectory,
75
+ prompt,
76
+ userId: message.author.id,
77
+ sourceWorkspace: await getThreadWorktreeOrWorkspace(thread.id),
78
+ }).catch((cause) => new DiscordOperationError({ operation: 'createVoiceSessionThread', cause }));
79
+ if (result instanceof Error) {
80
+ logger.error('Voice new session failed:', result);
81
+ await sendThreadMessage(thread, `${result.message}. Retry with /new-session.`);
82
+ return true;
83
+ }
84
+ await sendThreadMessage(thread, `Created new session in ${result.toString()}`);
85
+ const runtime = getOrCreateRuntime({
86
+ threadId: result.id,
87
+ thread: result,
88
+ projectDirectory: resolved.projectDirectory,
89
+ sdkDirectory: resolved.workingDirectory,
90
+ channelId: textChannel.id,
91
+ appId,
92
+ });
93
+ await runtime.enqueueIncoming({
94
+ prompt: modelPrompt,
95
+ images,
96
+ agent: voiceResult.agent,
97
+ userId: message.author.id,
98
+ username: message.member?.displayName || message.author.displayName,
99
+ appId,
100
+ mode: 'opencode',
101
+ }).catch(async (error) => {
102
+ logger.error('Voice session dispatch failed:', error);
103
+ await sendThreadMessage(result, 'Could not send the request to OpenCode. Send your request again in this thread.');
104
+ });
105
+ return true;
106
+ }
22
107
  /** Fetch available agents from OpenCode for voice transcription agent selection. */
23
108
  async function fetchAvailableAgents(getClient, directory) {
24
109
  if (getClient instanceof Error) {
@@ -170,7 +255,15 @@ export async function preprocessExistingThreadMessage({ message, thread, project
170
255
  currentSessionContext,
171
256
  lastSessionContext,
172
257
  agents,
258
+ canForkSession: true,
173
259
  });
260
+ if (voiceResult instanceof Error) {
261
+ voiceLogger.warn('Voice request not dispatched:', voiceResult);
262
+ return { prompt: '', mode: 'opencode', skip: true };
263
+ }
264
+ if (await routeVoiceSession({ voiceResult, message, thread, appId })) {
265
+ return { prompt: '', mode: 'opencode', skip: true };
266
+ }
174
267
  if (voiceResult) {
175
268
  messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`;
176
269
  }
@@ -231,6 +324,13 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
231
324
  appId,
232
325
  agents,
233
326
  });
327
+ if (voiceResult instanceof Error) {
328
+ voiceLogger.warn('Voice request not dispatched:', voiceResult);
329
+ return { prompt: '', mode: 'opencode', skip: true };
330
+ }
331
+ if (await routeVoiceSession({ voiceResult, message, thread, appId })) {
332
+ return { prompt: '', mode: 'opencode', skip: true };
333
+ }
234
334
  if (voiceResult) {
235
335
  prompt = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`;
236
336
  }
@@ -304,6 +404,10 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
304
404
  appId,
305
405
  agents,
306
406
  });
407
+ if (voiceResult instanceof Error) {
408
+ voiceLogger.warn('Voice request not dispatched:', voiceResult);
409
+ return { prompt: '', mode: 'opencode', skip: true };
410
+ }
307
411
  if (voiceResult) {
308
412
  messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`;
309
413
  }
@@ -1,3 +1,13 @@
1
+ /**
2
+ * LEGACY. Superseded by @subrouter/opencode, registered alongside the kimaki
3
+ * plugin in opencode.ts. This whole rotation path only cycles accounts inside
4
+ * one provider; subrouter also fails over to a different provider when every
5
+ * account of one is exhausted.
6
+ *
7
+ * Kept because opencode ships no Claude Pro/Max auth of its own, so deleting
8
+ * it would strand anyone still picking `anthropic/*` directly. To move to
9
+ * subrouter, log in again through /login and pick Subrouter.
10
+ */
1
11
  /**
2
12
  * Shared utilities for multi-provider OAuth account rotation.
3
13
  * Used by both anthropic-auth-state.ts and openai-auth-state.ts to avoid
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * OpenAI OAuth rotation plugin for OpenCode.
3
4
  *
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * OpenAI OAuth account store and rotation.
3
4
  * Mirrors anthropic-auth-state.ts but for OpenAI/Codex OAuth accounts.
@@ -4,7 +4,35 @@
4
4
  // and creates a stable `kimaki` shim for OpenCode child processes.
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import { OpencodeIncompatibleVersionError } from './errors.js';
7
8
  const WINDOWS_CMD_SHIM_REGEX = /\.(cmd|bat)$/i;
9
+ const OPENCODE_SEMVER_REGEX = /(\d+)\.(\d+)\.(\d+)/;
10
+ // Kimaki still talks to OpenCode 1.x. Flip this to 1 after the v2 port ships.
11
+ export const INCOMPATIBLE_OPENCODE_MAJOR_VERSION = 2;
12
+ export function parseOpencodeVersion(output) {
13
+ const match = output.match(OPENCODE_SEMVER_REGEX);
14
+ if (!match)
15
+ return null;
16
+ const raw = match[0];
17
+ const major = Number(match[1]);
18
+ const minor = Number(match[2]);
19
+ const patch = Number(match[3]);
20
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) {
21
+ return null;
22
+ }
23
+ return { major, minor, patch, raw };
24
+ }
25
+ export function isIncompatibleOpencodeMajor({ major, }) {
26
+ return major === INCOMPATIBLE_OPENCODE_MAJOR_VERSION;
27
+ }
28
+ export function getIncompatibleOpencodeVersionError(output) {
29
+ const parsed = parseOpencodeVersion(output);
30
+ if (!parsed)
31
+ return null;
32
+ if (!isIncompatibleOpencodeMajor(parsed))
33
+ return null;
34
+ return new OpencodeIncompatibleVersionError({ version: parsed.raw });
35
+ }
8
36
  function quotePosixShellSegment(value) {
9
37
  return `'${value.replaceAll("'", `'\\''`)}'`;
10
38
  }
@@ -1,9 +1,58 @@
1
1
  // Regression tests for Windows OpenCode command resolution and spawn args.
2
2
  import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
- import { ensureKimakiCommandShim, getSpawnCommandAndArgs, sanitizeShimExecArgv, selectResolvedCommand, splitCommandLookupOutput, } from './opencode-command.js';
7
+ import { ensureKimakiCommandShim, getIncompatibleOpencodeVersionError, getSpawnCommandAndArgs, INCOMPATIBLE_OPENCODE_MAJOR_VERSION, isIncompatibleOpencodeMajor, parseOpencodeVersion, sanitizeShimExecArgv, selectResolvedCommand, splitCommandLookupOutput, } from './opencode-command.js';
8
+ import { OpencodeIncompatibleVersionError } from './errors.js';
9
+ describe('parseOpencodeVersion', () => {
10
+ test('extracts major.minor.patch from opencode --version output', () => {
11
+ expect(parseOpencodeVersion('1.2.3')).toEqual({
12
+ major: 1,
13
+ minor: 2,
14
+ patch: 3,
15
+ raw: '1.2.3',
16
+ });
17
+ expect(parseOpencodeVersion('opencode 2.0.0\n')).toEqual({
18
+ major: 2,
19
+ minor: 0,
20
+ patch: 0,
21
+ raw: '2.0.0',
22
+ });
23
+ expect(parseOpencodeVersion('v2.1.0-beta.1')).toEqual({
24
+ major: 2,
25
+ minor: 1,
26
+ patch: 0,
27
+ raw: '2.1.0',
28
+ });
29
+ });
30
+ test('returns null when output has no three-part version', () => {
31
+ expect(parseOpencodeVersion('')).toBeNull();
32
+ expect(parseOpencodeVersion('opencode')).toBeNull();
33
+ expect(parseOpencodeVersion('2.0')).toBeNull();
34
+ });
35
+ });
36
+ describe('isIncompatibleOpencodeMajor', () => {
37
+ test('rejects the incompatible major and allows every other major', () => {
38
+ expect(INCOMPATIBLE_OPENCODE_MAJOR_VERSION).toBe(2);
39
+ expect(isIncompatibleOpencodeMajor({ major: 2 })).toBe(true);
40
+ expect(isIncompatibleOpencodeMajor({ major: 1 })).toBe(false);
41
+ expect(isIncompatibleOpencodeMajor({ major: 0 })).toBe(false);
42
+ expect(isIncompatibleOpencodeMajor({ major: 3 })).toBe(false);
43
+ });
44
+ });
45
+ describe('getIncompatibleOpencodeVersionError', () => {
46
+ test('returns a tagged error for OpenCode 2.x', () => {
47
+ const error = getIncompatibleOpencodeVersionError('2.0.0');
48
+ expect(error).toBeInstanceOf(OpencodeIncompatibleVersionError);
49
+ expect(error?.message).toMatchInlineSnapshot(`"Kimaki is not compatible with OpenCode version 2.0.0. Install an OpenCode 1.x release."`);
50
+ });
51
+ test('allows 1.x and unparseable output', () => {
52
+ expect(getIncompatibleOpencodeVersionError('1.4.0')).toBeNull();
53
+ expect(getIncompatibleOpencodeVersionError('opencode')).toBeNull();
54
+ });
55
+ });
7
56
  describe('splitCommandLookupOutput', () => {
8
57
  test('splits windows command lookup output into trimmed lines', () => {
9
58
  expect(splitCommandLookupOutput('C:\\Program Files\\nodejs\\opencode\r\nC:\\Program Files\\nodejs\\opencode.cmd\r\n')).toEqual([
@@ -26,6 +75,65 @@ describe('selectResolvedCommand', () => {
26
75
  })).toBe('/usr/local/bin/opencode');
27
76
  });
28
77
  });
78
+ describe('buildOpencodeServeArgs', () => {
79
+ test('always passes --hostname so opencode.json cannot bind 0.0.0.0', async () => {
80
+ const { buildOpencodeServeArgs } = await import('./opencode.js');
81
+ expect(buildOpencodeServeArgs({ port: 4096 })).toEqual([
82
+ 'serve',
83
+ '--port',
84
+ '4096',
85
+ '--hostname',
86
+ '127.0.0.1',
87
+ '--print-logs',
88
+ '--log-level',
89
+ 'WARN',
90
+ ]);
91
+ });
92
+ test('passes --hostname when set', async () => {
93
+ const { buildOpencodeServeArgs } = await import('./opencode.js');
94
+ expect(buildOpencodeServeArgs({ port: 4096, hostname: '0.0.0.0' })).toEqual([
95
+ 'serve',
96
+ '--port',
97
+ '4096',
98
+ '--hostname',
99
+ '0.0.0.0',
100
+ '--print-logs',
101
+ '--log-level',
102
+ 'WARN',
103
+ ]);
104
+ });
105
+ });
106
+ describe('published runtime artifacts', () => {
107
+ test('lists @subrouter/opencode as a runtime dependency', () => {
108
+ const pkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf8'));
109
+ expect(pkg.dependencies?.['@subrouter/opencode']).toMatch(/^(workspace:\^|\^)/);
110
+ });
111
+ });
112
+ describe('resolveSubrouterPluginSpec', () => {
113
+ test('uses npm package identity in production for OpenCode deduplication', async () => {
114
+ const { resolveSubrouterPluginSpec } = await import('./opencode.js');
115
+ const require = createRequire(import.meta.url);
116
+ const packageJsonPath = require.resolve('@subrouter/opencode/package.json');
117
+ const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
118
+ expect(resolveSubrouterPluginSpec({ isDev: false })).toBe(`@subrouter/opencode@${version}`);
119
+ });
120
+ test('loads workspace source directly in development', async () => {
121
+ const { resolveSubrouterPluginSpec } = await import('./opencode.js');
122
+ expect(resolveSubrouterPluginSpec({ isDev: true })).toMatch(/^file:.*\/subrouter\/opencode\/dist\/index\.js$/);
123
+ });
124
+ });
125
+ describe('publicOpencodeBindRequiresPassword', () => {
126
+ test('allows loopback without a password', async () => {
127
+ const { publicOpencodeBindRequiresPassword } = await import('./opencode.js');
128
+ expect(publicOpencodeBindRequiresPassword({ hostname: null })).toBe(false);
129
+ expect(publicOpencodeBindRequiresPassword({ hostname: '127.0.0.1' })).toBe(false);
130
+ expect(publicOpencodeBindRequiresPassword({ hostname: 'localhost' })).toBe(false);
131
+ });
132
+ test('requires a password for 0.0.0.0', async () => {
133
+ const { publicOpencodeBindRequiresPassword } = await import('./opencode.js');
134
+ expect(publicOpencodeBindRequiresPassword({ hostname: '0.0.0.0' })).toBe(true);
135
+ });
136
+ });
29
137
  describe('getSpawnCommandAndArgs', () => {
30
138
  test('wraps windows cmd shims through cmd.exe without double-quoting by node', () => {
31
139
  expect(getSpawnCommandAndArgs({
@@ -216,6 +216,10 @@ const interruptOpencodeSessionOnUserMessage = async (ctx) => {
216
216
  return;
217
217
  if (output.parts.length === 0)
218
218
  return;
219
+ // Hidden context notices are not user turns. Subrouter posts these while
220
+ // a session is already busy; aborting would cancel running subagents.
221
+ if (output.parts.every((part) => part.type === 'text' && part.ignored === true))
222
+ return;
219
223
  const messageID = input.messageID || output.message.id;
220
224
  if (!messageID)
221
225
  return;
@@ -205,6 +205,30 @@ describe('interruptOpencodeSessionOnUserMessage', () => {
205
205
  expect(stub.abortCalls).toEqual([]);
206
206
  expect(stub.promptAsyncCalls).toEqual([]);
207
207
  });
208
+ test('ignored context notices do not abort a busy session', async () => {
209
+ process.env['KIMAKI_INTERRUPT_STEP_TIMEOUT_MS'] = '20';
210
+ const { chatHook } = await requireHooks();
211
+ const sessionID = 'ses-oracle';
212
+ const messageID = 'msg-fallback-notice';
213
+ stub.setStatus(sessionID, { type: 'busy' });
214
+ await chatHook({ sessionID, messageID }, createChatOutput({
215
+ sessionID,
216
+ messageID,
217
+ parts: [
218
+ {
219
+ id: 'prt-fallback',
220
+ sessionID,
221
+ messageID,
222
+ type: 'text',
223
+ text: 'Subrouter: Using xai/grok-4.6 because openai/gpt-5.6-sol is rate limited.',
224
+ ignored: true,
225
+ },
226
+ ],
227
+ }));
228
+ await delay({ ms: 80 });
229
+ expect(stub.abortCalls).toEqual([]);
230
+ expect(stub.promptAsyncCalls).toEqual([]);
231
+ });
208
232
  test('replayed message does not schedule another interrupt (no abort loop)', async () => {
209
233
  process.env['KIMAKI_INTERRUPT_STEP_TIMEOUT_MS'] = '20';
210
234
  const { chatHook } = await requireHooks();
package/dist/opencode.js CHANGED
@@ -40,18 +40,32 @@ import net from 'node:net';
40
40
  import os from 'node:os';
41
41
  import path from 'node:path';
42
42
  import readline from 'node:readline';
43
- import { fileURLToPath } from 'node:url';
43
+ import { createRequire } from 'node:module';
44
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
46
  import { createOpencodeClient, } from '@opencode-ai/sdk/v2';
46
47
  import { restartGlobalEventListener, waitForGlobalEventListener, } from './session-handler/global-event-listener.js';
47
- import { getDataDir, getLockPort, getRestrictExternalDirectories, } from './config.js';
48
+ import { getDataDir, getLockPort, getRestrictExternalDirectories, getOpencodeHostname, getOpencodePort, } from './config.js';
48
49
  import { store } from './store.js';
49
50
  import { getHranaUrl } from './hrana-server.js';
51
+ export function resolveSubrouterPluginSpec({ isDev }) {
52
+ const require = createRequire(import.meta.url);
53
+ const entry = require.resolve('@subrouter/opencode');
54
+ if (isDev)
55
+ return pathToFileURL(entry).href;
56
+ const packageJsonPath = require.resolve('@subrouter/opencode/package.json');
57
+ const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version;
58
+ if (typeof version !== 'string' || !version) {
59
+ throw new Error(`Missing @subrouter/opencode version in ${packageJsonPath}`);
60
+ }
61
+ return `@subrouter/opencode@${version}`;
62
+ }
50
63
  import * as errore from 'errore';
51
64
  import { createLogger, LogPrefix } from './logger.js';
52
65
  import { notifyError } from './sentry.js';
53
- import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
54
- import { ensureKimakiCommandShim, getPathEnvKey, getSpawnCommandAndArgs, prependPathEntry, selectResolvedCommand, } from './opencode-command.js';
66
+ import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, OpencodeIncompatibleVersionError, } from './errors.js';
67
+ import { ensureKimakiCommandShim, getIncompatibleOpencodeVersionError, getPathEnvKey, getSpawnCommandAndArgs, prependPathEntry, selectResolvedCommand, } from './opencode-command.js';
68
+ import { execAsync } from './exec-async.js';
55
69
  import { computeSkillPermission } from './skill-filter.js';
56
70
  const opencodeLogger = createLogger(LogPrefix.OPENCODE);
57
71
  /**
@@ -66,6 +80,26 @@ export function getOpencodeServerAuthHeaders() {
66
80
  const encoded = Buffer.from(`${username}:${serverPassword}`).toString('base64');
67
81
  return { Authorization: `Basic ${encoded}` };
68
82
  }
83
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1']);
84
+ export function publicOpencodeBindRequiresPassword({ hostname, }) {
85
+ if (!hostname)
86
+ return false;
87
+ return !LOOPBACK_HOSTNAMES.has(hostname);
88
+ }
89
+ // Always pass --hostname so opencode.json server.hostname / mdns cannot bind 0.0.0.0.
90
+ const DEFAULT_OPENCODE_HOSTNAME = '127.0.0.1';
91
+ export function buildOpencodeServeArgs({ port, hostname, }) {
92
+ return [
93
+ 'serve',
94
+ '--port',
95
+ port.toString(),
96
+ '--hostname',
97
+ hostname || DEFAULT_OPENCODE_HOSTNAME,
98
+ '--print-logs',
99
+ '--log-level',
100
+ 'WARN',
101
+ ];
102
+ }
69
103
  // Tracks directories that have been initialized, to avoid repeated log spam
70
104
  // from the external sync polling loop.
71
105
  const initializedDirectories = new Set();
@@ -89,6 +123,7 @@ export async function requestHealthcheck({ url, timeoutMs = 2000, }) {
89
123
  method: 'GET',
90
124
  headers: {
91
125
  connection: 'close',
126
+ ...getOpencodeServerAuthHeaders(),
92
127
  },
93
128
  }, (res) => {
94
129
  const chunks = [];
@@ -337,6 +372,23 @@ export function resolveOpencodeCommand() {
337
372
  opencodeLogger.log(`Resolved opencode binary: ${result}`);
338
373
  return result;
339
374
  }
375
+ export async function assertCompatibleOpencodeVersion({ resolvedCommand = resolveOpencodeCommand(), } = {}) {
376
+ const { command, args } = getSpawnCommandAndArgs({
377
+ resolvedCommand,
378
+ baseArgs: ['--version'],
379
+ });
380
+ const result = await execAsync({ command, args }, { timeout: 5000, encoding: 'utf8' }).catch((cause) => {
381
+ return new Error('Failed to read OpenCode version', { cause });
382
+ });
383
+ if (result instanceof Error) {
384
+ opencodeLogger.warn(result.message);
385
+ return true;
386
+ }
387
+ const incompatible = getIncompatibleOpencodeVersionError(`${result.stdout}\n${result.stderr}`);
388
+ if (incompatible)
389
+ return incompatible;
390
+ return true;
391
+ }
340
392
  async function getOpenPort() {
341
393
  return new Promise((resolve, reject) => {
342
394
  const server = net.createServer();
@@ -462,6 +514,9 @@ async function ensureSingleServer({ directory, } = {}) {
462
514
  singleServer = discovered;
463
515
  return discovered;
464
516
  }
517
+ const compatibility = await assertCompatibleOpencodeVersion();
518
+ if (compatibility instanceof Error)
519
+ return compatibility;
465
520
  return startSingleServer({ directory: startupDirectory });
466
521
  })();
467
522
  try {
@@ -473,15 +528,17 @@ async function ensureSingleServer({ directory, } = {}) {
473
528
  }
474
529
  async function startSingleServer({ directory, } = {}) {
475
530
  ensureProcessCleanupHandlersRegistered();
476
- const port = await getOpenPort();
477
- const serveArgs = [
478
- 'serve',
479
- '--port',
480
- port.toString(),
481
- '--print-logs',
482
- '--log-level',
483
- 'WARN',
484
- ];
531
+ const configuredPort = getOpencodePort();
532
+ const port = configuredPort ?? (await getOpenPort());
533
+ const hostname = getOpencodeHostname() ?? DEFAULT_OPENCODE_HOSTNAME;
534
+ if (publicOpencodeBindRequiresPassword({ hostname }) &&
535
+ !process.env.OPENCODE_SERVER_PASSWORD) {
536
+ return new ServerStartError({
537
+ port,
538
+ reason: `OPENCODE_SERVER_PASSWORD is required when --opencode-hostname is ${hostname}`,
539
+ });
540
+ }
541
+ const serveArgs = buildOpencodeServeArgs({ port, hostname });
485
542
  const { command: spawnCommand, args: spawnArgs, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
486
543
  resolvedCommand: resolveOpencodeCommand(),
487
544
  baseArgs: serveArgs,
@@ -552,6 +609,9 @@ async function startSingleServer({ directory, } = {}) {
552
609
  formatter: false,
553
610
  plugin: [
554
611
  new URL(isDev ? './kimaki-opencode-plugin.ts' : './kimaki-opencode-plugin.js', import.meta.url).href,
612
+ // npm identity lets opencode dedupe a user-installed copy by package
613
+ // name. Development still loads this workspace's built package directly.
614
+ resolveSubrouterPluginSpec({ isDev }),
555
615
  ],
556
616
  permission: {
557
617
  edit: 'allow',
@@ -639,6 +699,8 @@ async function startSingleServer({ directory, } = {}) {
639
699
  OPENCODE_CONFIG: opencodeConfigPath,
640
700
  OPENCODE_PORT: port.toString(),
641
701
  KIMAKI: '1',
702
+ // The browser is not on this machine, so no localhost callback fires.
703
+ SUBROUTER_MANUAL_OAUTH: '1',
642
704
  OPENCODE_EXPERIMENTAL_WORKSPACES: 'true',
643
705
  OPENCODE_ENABLE_EXA: '1',
644
706
  KIMAKI_DATA_DIR: getDataDir(),
@@ -661,7 +723,7 @@ async function startSingleServer({ directory, } = {}) {
661
723
  const logBuffer = [];
662
724
  const startupStderrTail = [];
663
725
  let serverReady = false;
664
- logBuffer.push(`Spawned opencode serve --port ${port} (pid: ${serverProcess.pid})`);
726
+ logBuffer.push(`Spawned opencode ${serveArgs.join(' ')} (pid: ${serverProcess.pid})`);
665
727
  const stdoutReader = subscribeToProcessLogStream({
666
728
  stream: serverProcess.stdout,
667
729
  onLine: (line) => {
@@ -1058,7 +1120,9 @@ export function extractSdkErrorMessage(error) {
1058
1120
  return 'Unknown OpenCode API error';
1059
1121
  }
1060
1122
  if (error.data?.message) {
1061
- return error.data.message;
1123
+ const name = error.name ? `${error.name}: ` : '';
1124
+ const ref = error.data.ref ? ` (${error.data.ref})` : '';
1125
+ return `${name}${error.data.message}${ref}`;
1062
1126
  }
1063
1127
  if (error.message) {
1064
1128
  return error.message;
@@ -1069,6 +1133,9 @@ export function extractSdkErrorMessage(error) {
1069
1133
  if (error._tag) {
1070
1134
  return error._tag;
1071
1135
  }
1136
+ if (error.name) {
1137
+ return error.name;
1138
+ }
1072
1139
  return 'Unknown OpenCode API error';
1073
1140
  }
1074
1141
  /**
@@ -0,0 +1,62 @@
1
+ // Tests that `kimaki project add` refuses a folder already registered locally.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
+ import { setDataDir } from './config.js';
7
+ import { closeDb } from './db.js';
8
+ import { findRegisteredTextChannelForDirectory, formatProjectAlreadyRegisteredError, initDatabase, setChannelDirectory, } from './database.js';
9
+ describe('project add duplicate directory', () => {
10
+ let tmpDir;
11
+ let projectDir;
12
+ beforeEach(async () => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-project-add-'));
14
+ projectDir = path.join(tmpDir, 'repo');
15
+ fs.mkdirSync(projectDir);
16
+ setDataDir(path.join(tmpDir, 'data'));
17
+ await closeDb();
18
+ await initDatabase();
19
+ });
20
+ afterEach(async () => {
21
+ await closeDb();
22
+ fs.rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+ test('returns the existing text channel for a registered folder', async () => {
25
+ await setChannelDirectory({
26
+ channelId: '111',
27
+ directory: projectDir,
28
+ channelType: 'text',
29
+ });
30
+ const existing = await findRegisteredTextChannelForDirectory(projectDir);
31
+ expect(existing).toMatchObject({
32
+ channel_id: '111',
33
+ directory: projectDir,
34
+ channel_type: 'text',
35
+ });
36
+ });
37
+ test('matches a symlink to the same registered folder', async () => {
38
+ await setChannelDirectory({
39
+ channelId: '222',
40
+ directory: fs.realpathSync(projectDir),
41
+ channelType: 'text',
42
+ });
43
+ const alias = path.join(tmpDir, 'alias');
44
+ fs.symlinkSync(projectDir, alias);
45
+ const existing = await findRegisteredTextChannelForDirectory(alias);
46
+ expect(existing?.channel_id).toBe('222');
47
+ });
48
+ test('returns null when the folder is not registered', async () => {
49
+ const existing = await findRegisteredTextChannelForDirectory(projectDir);
50
+ expect(existing).toBe(null);
51
+ });
52
+ test('error message tells the user how to remove the mapping', () => {
53
+ expect(formatProjectAlreadyRegisteredError({
54
+ channelId: '111',
55
+ directory: '/tmp/repo',
56
+ })).toMatchInlineSnapshot(`
57
+ "Channel already exists for this directory: /tmp/repo
58
+ Channel ID: 111
59
+ Remove the mapping first: kimaki project remove 111"
60
+ `);
61
+ });
62
+ });