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
@@ -76,6 +76,7 @@ import {
76
76
  handleAgentCommand,
77
77
  handleAgentSelectMenu,
78
78
  handleQuickAgentCommand,
79
+ handleQuickAgentAutocomplete,
79
80
  } from './commands/agent.js'
80
81
  import { handleAskQuestionSelectMenu } from './commands/ask-question.js'
81
82
  import {
@@ -115,15 +116,29 @@ import { hasKimakiAdminPermission, hasKimakiBotPermission } from './discord-util
115
116
  import { createLogger, LogPrefix } from './logger.js'
116
117
  import { notifyError } from './sentry.js'
117
118
  import { getChannelDirectory } from './database.js'
119
+ import {
120
+ reserveThreadIngress,
121
+ runInThreadIngressSlot,
122
+ } from './session-handler/thread-session-runtime.js'
118
123
 
119
124
  const interactionLogger = createLogger(LogPrefix.INTERACTION)
120
125
 
121
- /** Setup commands that should reply with guidance when used in
122
- * non-project channels, instead of silently ignoring. */
123
- const SETUP_COMMANDS = new Set([
124
- 'add-project',
125
- 'create-new-project',
126
- ])
126
+ function serialIngressChannelId(interaction: Interaction): string | undefined {
127
+ if (!interaction.isChatInputCommand()) {
128
+ return undefined
129
+ }
130
+ const name = interaction.commandName
131
+ if (name.endsWith('-cmd') || name.endsWith('-skill') || name.endsWith('-mcp-prompt')) {
132
+ return interaction.channelId ?? undefined
133
+ }
134
+ if (!name.endsWith('-agent') || name === 'agent') {
135
+ return undefined
136
+ }
137
+ if (interaction.options.getString('prompt')) {
138
+ return undefined
139
+ }
140
+ return interaction.channelId ?? undefined
141
+ }
127
142
 
128
143
  /**
129
144
  * Check if the interaction's channel is owned by this machine (has a project
@@ -175,6 +190,11 @@ export function registerInteractionHandler({
175
190
  discordClient.on(
176
191
  Events.InteractionCreate,
177
192
  async (interaction: Interaction) => {
193
+ const ingressChannelId = serialIngressChannelId(interaction)
194
+ const ingressSlot = ingressChannelId
195
+ ? reserveThreadIngress(ingressChannelId)
196
+ : undefined
197
+ await runInThreadIngressSlot(ingressSlot, async () => {
178
198
  try {
179
199
  interactionLogger.log(
180
200
  `[INTERACTION] Received: ${interaction.type} - ${
@@ -189,14 +209,9 @@ export function registerInteractionHandler({
189
209
  // Multi-machine routing: only handle interactions for channels owned
190
210
  // by this machine (have a project directory configured in local db).
191
211
  // If not owned, silently return so the other machine handles it.
192
- // Setup commands (create-new-project, add-project) bypass this check
193
- // because they are designed to run from any channel — they create new
194
- // project channels rather than requiring one to already exist.
195
- const isSetupCommand =
196
- interaction.isChatInputCommand() &&
197
- SETUP_COMMANDS.has(interaction.commandName)
212
+ // Setup commands must not let an unconfigured server grant itself host access.
198
213
  const owned = await isInteractionOwnedByThisMachine(interaction)
199
- if (!owned && !isSetupCommand) {
214
+ if (!owned) {
200
215
  interactionLogger.log(
201
216
  `[IGNORED] Channel ${interaction.channelId} has no project directory configured, skipping interaction`,
202
217
  )
@@ -241,6 +256,13 @@ export function registerInteractionHandler({
241
256
  return
242
257
 
243
258
  default:
259
+ if (
260
+ interaction.commandName.endsWith('-agent') &&
261
+ interaction.commandName !== 'agent'
262
+ ) {
263
+ await handleQuickAgentAutocomplete({ interaction, appId })
264
+ return
265
+ }
244
266
  await interaction.respond([])
245
267
  return
246
268
  }
@@ -740,6 +762,7 @@ export function registerInteractionHandler({
740
762
  )
741
763
  }
742
764
  }
765
+ })
743
766
  },
744
767
  )
745
768
  }
@@ -1,15 +1,16 @@
1
- // OpenCode plugin that provides IPC-based tools for Discord interaction:
1
+ // OpenCode plugin that provides Discord and session tools:
2
2
  // - kimaki_file_upload: prompts the Discord user to upload files via native picker
3
3
  // - kimaki_action_buttons: shows clickable action buttons in the Discord thread
4
+ // - kimaki_sleep: persist a wake time so the session can sleep hours or days
4
5
  //
5
- // Tools communicate with the bot process via IPC rows in SQLite (the plugin
6
- // runs inside the OpenCode server process, not the bot process).
6
+ // IPC tools talk to the bot process via SQLite rows. Sleep writes session_sleeps
7
+ // directly. The plugin runs inside the OpenCode server process, not the bot.
7
8
  //
8
9
  // Exported from kimaki-opencode-plugin.ts — each export is treated as a separate
9
10
  // plugin by OpenCode's plugin loader.
10
11
 
11
12
  import type { Plugin } from '@opencode-ai/plugin'
12
- import type { ToolContext } from '@opencode-ai/plugin/tool'
13
+ import type { ToolContext, ToolResult } from '@opencode-ai/plugin/tool'
13
14
  import dedent from 'string-dedent'
14
15
  import { z } from 'zod'
15
16
  import { setDataDir } from './config.js'
@@ -33,7 +34,7 @@ function tool<Args extends z.ZodRawShape>(input: {
33
34
  execute(
34
35
  args: z.infer<z.ZodObject<Args>>,
35
36
  context: ToolContext,
36
- ): Promise<string>
37
+ ): Promise<ToolResult>
37
38
  }) {
38
39
  return input
39
40
  }
@@ -222,6 +223,82 @@ const ipcToolsPlugin: any = async () => {
222
223
  return 'Action button request timed out'
223
224
  },
224
225
  }),
226
+ kimaki_sleep: tool({
227
+ description: dedent`
228
+ Sleep this session until a future time. Kimaki later posts a wake
229
+ message in this thread and the same session continues.
230
+ Use this to wait hours or days for CI, a deploy, a date, or any later event.
231
+ The sleep is stored in SQLite and survives bot restarts.
232
+
233
+ Pass either duration (2h, 30m, 1d) or until (UTC ISO ending with Z).
234
+ Do not pass both. You MUST call kimaki_sleep LAST, after ALL text.
235
+ Do not call more tools after it.
236
+
237
+ The tool result is not a wake. After it succeeds, write one short
238
+ waiting line and stop. Do not continue the waited work until a later
239
+ Discord message that starts with "Woke after sleeping until".
240
+ A new user message cancels the sleep. If you still need that later
241
+ wake after answering, call kimaki_sleep again with until set to the
242
+ original UTC time.
243
+ `,
244
+ args: {
245
+ duration: z
246
+ .string()
247
+ .optional()
248
+ .describe(
249
+ 'Relative wait. Examples: 30s, 2h, 1d. Do not pass with until.',
250
+ ),
251
+ until: z
252
+ .string()
253
+ .optional()
254
+ .describe(
255
+ 'UTC ISO date ending with Z. Example: 2026-08-20T09:00:00Z.',
256
+ ),
257
+ reason: z
258
+ .string()
259
+ .optional()
260
+ .describe('Why the session is sleeping. Shown when it wakes.'),
261
+ },
262
+ async execute({ duration, until, reason }, context) {
263
+ const {
264
+ parseSleepWakeAt,
265
+ formatSessionSleepWakeAt,
266
+ formatSessionSleepToolOutput,
267
+ } = await import('./task-schedule.js')
268
+ const wakeAt = parseSleepWakeAt({
269
+ duration,
270
+ until,
271
+ now: new Date(),
272
+ })
273
+ if (wakeAt instanceof Error) {
274
+ return wakeAt.message
275
+ }
276
+
277
+ const { getThreadIdBySessionId, upsertSessionSleep } =
278
+ await loadDatabaseModule()
279
+ // Only a check that a thread owns this session: subagents have none.
280
+ // The wake resolves the thread again at delivery time, so nothing
281
+ // here can go stale if /resume rebinds the session later.
282
+ const threadId = await getThreadIdBySessionId(context.sessionID)
283
+ if (!threadId) {
284
+ return 'sleep is only available in the main session'
285
+ }
286
+
287
+ await upsertSessionSleep({
288
+ sessionId: context.sessionID,
289
+ wakeAt,
290
+ reason,
291
+ })
292
+
293
+ // The title carries the resolved absolute time so Discord shows the
294
+ // real wake instant instead of a relative input like "2h".
295
+ const untilLabel = formatSessionSleepWakeAt(wakeAt)
296
+ return {
297
+ title: `until ${untilLabel}`,
298
+ output: formatSessionSleepToolOutput({ wakeAt, reason }),
299
+ }
300
+ },
301
+ }),
225
302
  },
226
303
  }
227
304
  }
@@ -1,117 +1,208 @@
1
1
  // E2e test for OpenCode plugin loading.
2
- // Spawns `opencode serve` directly with our plugin in OPENCODE_CONFIG_CONTENT,
3
- // waits for the health endpoint, then checks stderr for plugin errors.
4
- // No Discord infrastructure needed — just the OpenCode server process.
2
+ // Spawns one `opencode serve` with the same plugin list kimaki writes (its own
3
+ // plugin plus @subrouter/opencode), then checks stderr for load errors and
4
+ // hits the real HTTP API. One server for both plugins, because that is how
5
+ // they actually run, and because booting two costs seconds per file.
6
+ // No Discord infrastructure and no real provider APIs.
5
7
 
6
8
  import { spawn, type ChildProcess } from 'node:child_process'
7
9
  import fs from 'node:fs'
8
10
  import path from 'node:path'
9
- import { fileURLToPath } from 'node:url'
10
- import { test, expect } from 'vitest'
11
+ import { createRequire } from 'node:module'
12
+ import { pathToFileURL } from 'node:url'
13
+ import { afterAll, beforeAll, expect, test } from 'vitest'
14
+ import { PROVIDER_IDS, type AuthFile } from '@subrouter/cli'
11
15
  import { resolveOpencodeCommand } from './opencode.js'
12
16
  import { getSpawnCommandAndArgs } from './opencode-command.js'
13
17
  import { chooseLockPort } from './test-utils.js'
14
18
 
15
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
16
-
17
- async function waitForHealth({
18
- port,
19
- maxAttempts = 30,
20
- }: {
21
- port: number
22
- maxAttempts?: number
23
- }): Promise<boolean> {
24
- for (let i = 0; i < maxAttempts; i++) {
25
- try {
26
- const response = await fetch(`http://127.0.0.1:${port}/api/health`)
27
- if (response.status < 500) {
28
- return true
29
- }
30
- } catch {
31
- // connection refused, retry
32
- }
19
+ const require = createRequire(import.meta.url)
20
+
21
+ const port = chooseLockPort({ key: 'opencode-plugin-loading-e2e' })
22
+ const projectDir = path.resolve(process.cwd(), 'tmp', 'plugin-loading-e2e')
23
+ const subrouterHome = path.join(projectDir, 'subrouter-home')
24
+ const stderrLines: string[] = []
25
+
26
+ let serverProcess: ChildProcess | undefined
27
+
28
+ async function api<T>(routePath: string): Promise<T> {
29
+ const url = new URL(routePath, `http://127.0.0.1:${port}`)
30
+ url.searchParams.set('directory', projectDir)
31
+ const response = await fetch(url, { headers: { 'x-opencode-directory': projectDir } })
32
+ if (!response.ok) throw new Error(`${routePath} returned ${response.status}`)
33
+ return (await response.json()) as T
34
+ }
35
+
36
+ async function waitForHealth() {
37
+ for (let attempt = 0; attempt < 60; attempt++) {
38
+ const ok = await fetch(`http://127.0.0.1:${port}/api/health`)
39
+ .then((r) => r.status < 500)
40
+ .catch(() => false)
41
+ if (ok) return true
33
42
  await new Promise((resolve) => {
34
- setTimeout(resolve, 1000)
43
+ setTimeout(resolve, 500)
35
44
  })
36
45
  }
37
46
  return false
38
47
  }
39
48
 
40
- test(
41
- 'opencode server loads plugin without errors',
42
- async () => {
43
- const projectDir = path.resolve(process.cwd(), 'tmp', 'plugin-loading-e2e')
44
- fs.mkdirSync(projectDir, { recursive: true })
45
-
46
- const port = chooseLockPort({ key: 'opencode-plugin-loading-e2e' })
47
- const pluginPath = new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href
48
- const stderrLines: string[] = []
49
- const isolatedOpencodeRoot = path.join(projectDir, 'opencode-test-home')
50
- const xdgDirectories = {
51
- OPENCODE_CONFIG_DIR: path.join(isolatedOpencodeRoot, '.opencode-kimaki'),
52
- XDG_CONFIG_HOME: path.join(isolatedOpencodeRoot, '.config'),
53
- XDG_DATA_HOME: path.join(isolatedOpencodeRoot, '.local', 'share'),
54
- XDG_CACHE_HOME: path.join(isolatedOpencodeRoot, '.cache'),
55
- XDG_STATE_HOME: path.join(isolatedOpencodeRoot, '.local', 'state'),
56
- }
57
-
58
- fs.mkdirSync(isolatedOpencodeRoot, { recursive: true })
59
- Object.values(xdgDirectories).forEach((directory) => {
60
- fs.mkdirSync(directory, { recursive: true })
61
- })
49
+ beforeAll(async () => {
50
+ fs.rmSync(projectDir, { recursive: true, force: true })
51
+ const opencodeRoot = path.join(projectDir, 'opencode-test-home')
52
+ const xdgDirectories = {
53
+ OPENCODE_CONFIG_DIR: path.join(opencodeRoot, '.opencode-kimaki'),
54
+ XDG_CONFIG_HOME: path.join(opencodeRoot, '.config'),
55
+ XDG_DATA_HOME: path.join(opencodeRoot, '.local', 'share'),
56
+ XDG_CACHE_HOME: path.join(opencodeRoot, '.cache'),
57
+ XDG_STATE_HOME: path.join(opencodeRoot, '.local', 'state'),
58
+ }
59
+ ;[...Object.values(xdgDirectories), subrouterHome].forEach((directory) => {
60
+ fs.mkdirSync(directory, { recursive: true })
61
+ })
62
62
 
63
- const {
64
- command,
65
- args,
66
- windowsVerbatimArguments,
67
- } = getSpawnCommandAndArgs({
68
- resolvedCommand: resolveOpencodeCommand(),
69
- baseArgs: ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
70
- })
63
+ const { command, args, windowsVerbatimArguments } = getSpawnCommandAndArgs({
64
+ resolvedCommand: resolveOpencodeCommand(),
65
+ baseArgs: ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
66
+ })
71
67
 
72
- const serverProcess: ChildProcess = spawn(command, args, {
73
- stdio: 'pipe',
74
- cwd: projectDir,
75
- windowsVerbatimArguments,
76
- env: {
77
- ...process.env,
78
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
79
- $schema: 'https://opencode.ai/config.json',
80
- lsp: false,
81
- formatter: false,
82
- plugin: [pluginPath],
83
- }),
84
- OPENCODE_TEST_HOME: isolatedOpencodeRoot,
85
- ...xdgDirectories,
86
- },
87
- })
68
+ serverProcess = spawn(command, args, {
69
+ stdio: 'pipe',
70
+ cwd: projectDir,
71
+ windowsVerbatimArguments,
72
+ env: {
73
+ ...process.env,
74
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
75
+ $schema: 'https://opencode.ai/config.json',
76
+ lsp: false,
77
+ formatter: false,
78
+ plugin: [
79
+ new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href,
80
+ pathToFileURL(require.resolve('@subrouter/opencode')).href,
81
+ ],
82
+ }),
83
+ OPENCODE_TEST_HOME: opencodeRoot,
84
+ SUBROUTER_HOME: subrouterHome,
85
+ SUBROUTER_MANUAL_OAUTH: '1',
86
+ ...xdgDirectories,
87
+ },
88
+ })
89
+
90
+ serverProcess.stderr?.on('data', (data) => {
91
+ stderrLines.push(...data.toString().split('\n').filter(Boolean))
92
+ })
88
93
 
89
- serverProcess.stderr?.on('data', (data) => {
90
- stderrLines.push(...data.toString().split('\n').filter(Boolean))
94
+ expect(await waitForHealth()).toBe(true)
95
+ }, 120_000)
96
+
97
+ afterAll(() => {
98
+ serverProcess?.kill('SIGTERM')
99
+ })
100
+
101
+ test('server loads both plugins without errors', () => {
102
+ const pluginErrorPatterns = [
103
+ /plugin.*error/i,
104
+ /failed to load plugin/i,
105
+ /cannot find module/i,
106
+ /ERR_MODULE_NOT_FOUND/i,
107
+ /plugin.*failed/i,
108
+ /plugin.*crash/i,
109
+ ]
110
+ const errorLines = stderrLines.filter((line) => {
111
+ return pluginErrorPatterns.some((pattern) => {
112
+ return pattern.test(line)
91
113
  })
114
+ })
115
+ expect(errorLines).toEqual([])
116
+ })
117
+
118
+ test('subrouter is a connected provider so /model accepts subrouter/default', async () => {
119
+ const providers = await api<{
120
+ all: Array<{
121
+ id: string
122
+ name: string
123
+ models: Record<
124
+ string,
125
+ {
126
+ name: string
127
+ capabilities: {
128
+ attachment: boolean
129
+ input: { image: boolean; pdf: boolean }
130
+ }
131
+ }
132
+ >
133
+ }>
134
+ connected: string[]
135
+ }>('/provider')
136
+
137
+ expect(providers.connected).toContain('subrouter')
138
+ const subrouter = providers.all.find((entry) => {
139
+ return entry.id === 'subrouter'
140
+ })
141
+ expect(subrouter?.name).toBe('subrouter.org')
142
+ expect(subrouter?.models.default).toMatchObject({
143
+ capabilities: {
144
+ attachment: false,
145
+ input: { image: false, pdf: false },
146
+ },
147
+ })
148
+ })
149
+
150
+ test('login offers subrouter and asks which subscription to add first', async () => {
151
+ const methods = await api<
152
+ Record<
153
+ string,
154
+ Array<{
155
+ type: string
156
+ label: string
157
+ prompts?: Array<{ type: string; key: string; options?: Array<{ value: string }> }>
158
+ }>
159
+ >
160
+ >('/provider/auth')
161
+
162
+ expect(methods.subrouter).toHaveLength(1)
163
+ expect(methods.subrouter?.[0]).toMatchObject({ type: 'oauth', label: 'Add a subscription' })
164
+
165
+ // Derived from subrouter, not hardcoded: it adds providers over time and a
166
+ // literal list here just breaks on the next one.
167
+ const prompt = methods.subrouter?.[0]?.prompts?.[0]
168
+ expect(prompt).toMatchObject({ type: 'select', key: 'provider' })
169
+ expect(prompt?.options?.map((option) => option.value)).toEqual([...PROVIDER_IDS])
170
+ })
171
+
172
+ test('authorize and callback add an opencode-go subscription', async () => {
173
+ const url = new URL('/provider/subrouter/oauth/authorize', `http://127.0.0.1:${port}`)
174
+ url.searchParams.set('directory', projectDir)
175
+ const response = await fetch(url, {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json', 'x-opencode-directory': projectDir },
178
+ body: JSON.stringify({ method: 0, inputs: { provider: 'opencode-go' } }),
179
+ })
180
+
181
+ expect(response.ok).toBe(true)
182
+ const authorization = (await response.json()) as { url: string; method: string; instructions: string }
183
+ expect(authorization.method).toBe('code')
184
+ expect(authorization.url).toBe('https://opencode.ai/auth')
185
+ expect(authorization.instructions).toContain('API key')
186
+
187
+ const callbackUrl = new URL(
188
+ '/provider/subrouter/oauth/callback',
189
+ `http://127.0.0.1:${port}`,
190
+ )
191
+ callbackUrl.searchParams.set('directory', projectDir)
192
+ const callbackResponse = await fetch(callbackUrl, {
193
+ method: 'POST',
194
+ headers: {
195
+ 'Content-Type': 'application/json',
196
+ 'x-opencode-directory': projectDir,
197
+ },
198
+ body: JSON.stringify({ method: 0, code: 'zen-key-1' }),
199
+ })
92
200
 
93
- try {
94
- const healthy = await waitForHealth({ port })
95
- expect(healthy).toBe(true)
96
-
97
- // Check no plugin-related errors in stderr
98
- const pluginErrorPatterns = [
99
- /plugin.*error/i,
100
- /failed to load plugin/i,
101
- /cannot find module/i,
102
- /ERR_MODULE_NOT_FOUND/i,
103
- /plugin.*failed/i,
104
- /plugin.*crash/i,
105
- ]
106
- const errorLines = stderrLines.filter((line) => {
107
- return pluginErrorPatterns.some((pattern) => {
108
- return pattern.test(line)
109
- })
110
- })
111
- expect(errorLines).toEqual([])
112
- } finally {
113
- serverProcess.kill('SIGTERM')
114
- }
115
- },
116
- 60_000,
117
- )
201
+ expect(callbackResponse.ok).toBe(true)
202
+ const auth = JSON.parse(
203
+ fs.readFileSync(path.join(subrouterHome, 'auth.json'), 'utf8'),
204
+ ) as AuthFile
205
+ expect(auth.providers['opencode-go']?.accounts).toMatchObject([
206
+ { type: 'api', key: 'zen-key-1' },
207
+ ])
208
+ })
@@ -4,23 +4,29 @@
4
4
  // initializer functions. OpenCode calls every export as a plugin.
5
5
  //
6
6
  // Plugins are split into focused modules:
7
- // - ipc-tools-plugin: file upload + action buttons (IPC-based Discord tools)
7
+ // - ipc-tools-plugin: file upload, action buttons, and session sleep
8
8
  // - context-awareness-plugin: branch, pwd, memory reminder, onboarding tutorial
9
9
  // - memory-overview-plugin: frozen MEMORY.md heading overview per session
10
10
  // - opencode-interrupt-plugin: interrupt queued messages at step boundaries
11
- // - subagent-rate-limit-plugin: aborts only task subagents after rate limits
12
11
  // - kitty-graphics-plugin: extract Kitty Graphics Protocol images from bash output
12
+ // - file-edit-log: record edit/write/apply_patch files per session
13
+ // - bash-tool-schema-plugin: add description and hasSideEffect to bash
13
14
 
14
15
  export { ipcToolsPlugin } from './ipc-tools-plugin.js'
15
16
  export { contextAwarenessPlugin } from './context-awareness-plugin.js'
16
17
  export { memoryOverviewPlugin } from './memory-overview-plugin.js'
17
18
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
19
+ // LEGACY per-provider account rotation. Superseded by @subrouter/opencode
20
+ // (registered separately in opencode.ts), which also fails over across
21
+ // providers. These stay because they own `anthropic/*` auth, which opencode
22
+ // itself does not provide. See anthropic-auth-plugin.ts for the full note.
18
23
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js'
19
24
  export { openaiRotationPlugin } from './openai-auth-plugin.js'
20
25
  export { xaiRotationPlugin } from './xai-auth-plugin.js'
21
26
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js'
22
- export { subagentRateLimitPlugin } from './subagent-rate-limit-plugin.js'
23
27
  export { cacheDriftPlugin } from './cache-drift-plugin.js'
24
28
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent'
25
29
  export { injectionGuardInternal as injectionGuard } from 'opencode-injection-guard'
26
30
  export { kimakiWorkspaceAdaptorPlugin } from './kimaki-workspace-adaptor.js'
31
+ export { fileEditTrackerPlugin } from './file-edit-log.js'
32
+ export { bashToolSchemaPlugin } from './bash-tool-schema-plugin.js'
@@ -9,7 +9,11 @@
9
9
  import type { Plugin, WorkspaceAdapter, WorkspaceInfo } from '@opencode-ai/plugin'
10
10
  import crypto from 'node:crypto'
11
11
  import path from 'node:path'
12
- import { createWorktreeCore, removeWorktreeCore } from './git-worktree-core.js'
12
+ import {
13
+ createWorktreeCore,
14
+ KIMAKI_WORKTREE_ADAPTER_TYPE,
15
+ removeWorktreeFromOwnRepository,
16
+ } from './git-worktree-core.js'
13
17
 
14
18
  /**
15
19
  * Compute the on-disk directory for a managed worktree.
@@ -39,15 +43,41 @@ function computeWorktreeDirectory({
39
43
  return path.join(dataDir, 'worktrees', projectHash, withoutPrefix)
40
44
  }
41
45
 
42
- function createKimakiWorktreeAdaptor(projectDirectory: string): WorkspaceAdapter {
46
+ function getWorktreeIdentity(info: WorkspaceInfo) {
47
+ if (!info.extra || typeof info.extra !== 'object') {
48
+ return new Error('Kimaki worktree identity is missing')
49
+ }
50
+ const identity: { projectDirectory?: string; baseCommit?: string } = {}
51
+ Object.assign(identity, info.extra)
52
+ if (
53
+ typeof identity.projectDirectory !== 'string' ||
54
+ !path.isAbsolute(identity.projectDirectory)
55
+ ) {
56
+ return new Error('Kimaki worktree project directory must be absolute')
57
+ }
58
+ if (
59
+ typeof identity.baseCommit !== 'string' ||
60
+ !/^[0-9a-f]{40}$/i.test(identity.baseCommit)
61
+ ) {
62
+ return new Error('Kimaki worktree base commit must be a full commit SHA')
63
+ }
64
+ return {
65
+ projectDirectory: identity.projectDirectory,
66
+ baseCommit: identity.baseCommit,
67
+ }
68
+ }
69
+
70
+ function createKimakiWorktreeAdaptor(): WorkspaceAdapter {
43
71
  return {
44
72
  name: 'Kimaki Worktree',
45
73
  description: 'Create a git worktree managed by Kimaki',
46
74
 
47
75
  configure(info: WorkspaceInfo): WorkspaceInfo {
76
+ const identity = getWorktreeIdentity(info)
77
+ if (identity instanceof Error) throw identity
48
78
  const branchName = info.branch || info.name
49
79
  const directory = computeWorktreeDirectory({
50
- projectDirectory,
80
+ projectDirectory: identity.projectDirectory,
51
81
  branchName,
52
82
  })
53
83
  if (directory instanceof Error) {
@@ -65,12 +95,13 @@ function createKimakiWorktreeAdaptor(projectDirectory: string): WorkspaceAdapter
65
95
  if (!info.directory) {
66
96
  throw new Error('Workspace directory not set — configure() likely failed')
67
97
  }
68
- const baseBranch = (info.extra as { baseBranch?: string })?.baseBranch || undefined
98
+ const identity = getWorktreeIdentity(info)
99
+ if (identity instanceof Error) throw identity
69
100
  const result = await createWorktreeCore({
70
- projectDirectory,
101
+ projectDirectory: identity.projectDirectory,
71
102
  targetDirectory: info.directory,
72
103
  branchName: info.branch || info.name,
73
- baseBranch,
104
+ baseCommit: identity.baseCommit,
74
105
  // Silent log — plugin must not write to stdout/stderr
75
106
  })
76
107
  if (result instanceof Error) {
@@ -80,8 +111,7 @@ function createKimakiWorktreeAdaptor(projectDirectory: string): WorkspaceAdapter
80
111
 
81
112
  async remove(info: WorkspaceInfo): Promise<void> {
82
113
  if (!info.directory) return
83
- const result = await removeWorktreeCore({
84
- projectDirectory,
114
+ const result = await removeWorktreeFromOwnRepository({
85
115
  worktreeDirectory: info.directory,
86
116
  branchName: info.branch || '',
87
117
  })
@@ -104,12 +134,11 @@ function createKimakiWorktreeAdaptor(projectDirectory: string): WorkspaceAdapter
104
134
  * Called by OpenCode's plugin loader.
105
135
  */
106
136
  export const kimakiWorkspaceAdaptorPlugin: Plugin = async ({
107
- directory,
108
137
  experimental_workspace,
109
138
  }) => {
110
139
  experimental_workspace.register(
111
- 'kimaki-worktree',
112
- createKimakiWorktreeAdaptor(directory),
140
+ KIMAKI_WORKTREE_ADAPTER_TYPE,
141
+ createKimakiWorktreeAdaptor(),
113
142
  )
114
143
  return {}
115
144
  }