kimaki 0.26.0 → 0.27.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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -325,7 +325,7 @@ describeIf('gateway-proxy e2e', () => {
325
325
  --- from: assistant (TestBot)
326
326
  *using deterministic-provider/deterministic-v2*
327
327
  ⬥ gateway-proxy-reply
328
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
328
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@900000000000000001>"
329
329
  `);
330
330
  expect(reply).toBeDefined();
331
331
  expect(reply.content.trim().length).toBeGreaterThan(0);
@@ -352,12 +352,12 @@ describeIf('gateway-proxy e2e', () => {
352
352
  --- from: assistant (TestBot)
353
353
  *using deterministic-provider/deterministic-v2*
354
354
  ⬥ gateway-proxy-reply
355
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
355
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@900000000000000001>
356
356
  --- from: user (proxy-tester)
357
357
  follow up through proxy
358
358
  --- from: assistant (TestBot)
359
359
  ⬥ gateway-proxy-reply
360
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
360
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@900000000000000001>"
361
361
  `);
362
362
  expect(reply).toBeDefined();
363
363
  expect(reply.content.trim().length).toBeGreaterThan(0);
@@ -384,12 +384,12 @@ describeIf('gateway-proxy e2e', () => {
384
384
  --- from: assistant (TestBot)
385
385
  *using deterministic-provider/deterministic-v2*
386
386
  ⬥ gateway-proxy-reply
387
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
387
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@900000000000000001>
388
388
  --- from: user (proxy-tester)
389
389
  follow up through proxy
390
390
  --- from: assistant (TestBot)
391
391
  ⬥ gateway-proxy-reply
392
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
392
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@900000000000000001>
393
393
  --- from: user (proxy-tester)
394
394
  !echo proxy-shell-test
395
395
  --- from: assistant (TestBot)
@@ -1,4 +1,4 @@
1
- // Plugin-safe git worktree creation and removal primitives.
1
+ // Plugin-safe Git worktree creation, identity validation, and removal primitives.
2
2
  // This module must NOT import config.ts, logger.ts, or any module that
3
3
  // transitively pulls them in. It is used by both:
4
4
  // - worktrees.ts (bot process — wraps with kimaki logger + config)
@@ -7,6 +7,7 @@
7
7
  // All logging goes through an optional `log` callback so callers control output.
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
+ import * as errore from 'errore';
10
11
  import { execAsync } from './exec-async.js';
11
12
  const SUBMODULE_INIT_TIMEOUT_MS = 20 * 60_000;
12
13
  const INSTALL_TIMEOUT_MS = 60_000;
@@ -17,6 +18,12 @@ const LOCKFILE_TO_INSTALL_COMMAND = [
17
18
  ['yarn.lock', 'yarn install'],
18
19
  ['package-lock.json', 'npm install'],
19
20
  ];
21
+ export const KIMAKI_WORKTREE_ADAPTER_TYPE = 'kimaki-worktree';
22
+ export class WorktreeIdentityError extends errore.createTaggedError({
23
+ name: 'WorktreeIdentityError',
24
+ message: 'Created worktree does not match the requested checkout: $reason',
25
+ }) {
26
+ }
20
27
  const silentLog = {
21
28
  info() { },
22
29
  warn() { },
@@ -114,7 +121,7 @@ async function hasSubmoduleGitMetadata(directory) {
114
121
  * Remove broken submodule stubs created by `git worktree add`.
115
122
  * git worktree creates .git files pointing to incomplete gitdirs (missing HEAD).
116
123
  */
117
- async function removeBrokenSubmoduleStubs(directory, log) {
124
+ async function removeBrokenSubmoduleStubs({ directory, expectedCommonGitDirectory, log, }) {
118
125
  const submodulePaths = await getSubmodulePaths(directory, log);
119
126
  for (const subPath of submodulePaths) {
120
127
  const fullPath = path.join(directory, subPath);
@@ -130,7 +137,9 @@ async function removeBrokenSubmoduleStubs(directory, log) {
130
137
  const gitdir = path.resolve(fullPath, match[1].trim());
131
138
  const headFile = path.join(gitdir, 'HEAD');
132
139
  const headExists = await fs.promises.access(headFile).then(() => true).catch(() => false);
133
- if (!headExists) {
140
+ const relativeGitdir = path.relative(expectedCommonGitDirectory, gitdir);
141
+ const pointsOutsideExpectedRepository = relativeGitdir === '..' || relativeGitdir.startsWith(`..${path.sep}`);
142
+ if (!headExists || pointsOutsideExpectedRepository) {
134
143
  log.info(`Removing broken submodule stub: ${subPath}`);
135
144
  await fs.promises.rm(fullPath, { recursive: true, force: true });
136
145
  }
@@ -186,17 +195,27 @@ async function initializeSubmodulesWithLocalReferences({ sourceDirectory, worktr
186
195
  }
187
196
  }
188
197
  }
189
- async function validateSubmodulePointers(directory, log) {
190
- const submodulePaths = await getSubmodulePaths(directory, log);
198
+ async function validateSubmodulePointers({ directory, expectedCommonGitDirectory, log, }) {
199
+ const submoduleConfigs = await readSubmoduleConfigs(directory);
200
+ if (submoduleConfigs instanceof Error) {
201
+ log.warn(submoduleConfigs.message);
202
+ return;
203
+ }
204
+ const submodulePaths = submoduleConfigs.map(({ path: submodulePath }) => {
205
+ return submodulePath;
206
+ });
191
207
  if (submodulePaths.length === 0)
192
208
  return;
193
209
  const issues = [];
210
+ const foreignGitdirs = [];
194
211
  await Promise.all(submodulePaths.map(async (subPath) => {
195
212
  const gitFile = path.join(directory, subPath, '.git');
196
213
  try {
197
214
  const stat = await fs.promises.stat(gitFile);
198
- if (!stat.isFile())
215
+ if (!stat.isFile()) {
216
+ issues.push(`${subPath}: .git is not a file`);
199
217
  return;
218
+ }
200
219
  const content = await fs.promises.readFile(gitFile, 'utf-8');
201
220
  const match = content.match(/^gitdir:\s*(.+)$/m);
202
221
  const gitdir = match?.[1]?.trim();
@@ -205,18 +224,26 @@ async function validateSubmodulePointers(directory, log) {
205
224
  return;
206
225
  }
207
226
  const resolvedGitdir = path.resolve(path.join(directory, subPath), gitdir);
227
+ const relativeGitdir = path.relative(expectedCommonGitDirectory, resolvedGitdir);
228
+ if (relativeGitdir === '..' || relativeGitdir.startsWith(`..${path.sep}`)) {
229
+ foreignGitdirs.push(`${subPath}: ${resolvedGitdir}`);
230
+ return;
231
+ }
208
232
  const headExists = await fs.promises.access(path.join(resolvedGitdir, 'HEAD')).then(() => true).catch(() => false);
209
233
  if (!headExists) {
210
234
  issues.push(`${subPath}: gitdir missing HEAD (${resolvedGitdir})`);
235
+ return;
211
236
  }
212
237
  }
213
- catch {
214
- // skip
238
+ catch (e) {
239
+ issues.push(`${subPath}: failed to validate .git (${e instanceof Error ? e.message : String(e)})`);
215
240
  }
216
241
  }));
217
- if (issues.length > 0) {
218
- log.warn(`Submodule validation issues: ${issues.join('; ')}`);
242
+ if (foreignGitdirs.length > 0) {
243
+ return new Error(`Submodule gitdir belongs to another repository: ${foreignGitdirs.join('; ')}`);
219
244
  }
245
+ if (issues.length > 0)
246
+ log.warn(`Submodule validation issues: ${issues.join('; ')}`);
220
247
  }
221
248
  function detectInstallCommand(directory) {
222
249
  for (const [lockfile, command] of LOCKFILE_TO_INSTALL_COMMAND) {
@@ -239,18 +266,107 @@ async function runDependencyInstall(directory, log) {
239
266
  return new Error(`Install failed: ${formatCommandError(e)}`, { cause: e });
240
267
  }
241
268
  }
269
+ async function canonicalizePath(value) {
270
+ return await fs.promises.realpath(value).catch(() => path.resolve(value));
271
+ }
272
+ export async function resolveGitCommit({ directory, ref, }) {
273
+ const result = await execAsync(`git rev-parse --verify ${JSON.stringify(`${ref}^{commit}`)}`, {
274
+ cwd: directory,
275
+ timeout: 10_000,
276
+ }).catch((e) => new Error(`Failed to resolve Git commit ${ref}`, { cause: e }));
277
+ if (result instanceof Error)
278
+ return result;
279
+ const commit = result.stdout.trim();
280
+ if (!/^[0-9a-f]{40}$/i.test(commit)) {
281
+ return new Error(`Git returned an invalid commit for ${ref}: ${commit}`);
282
+ }
283
+ return commit;
284
+ }
285
+ export async function resolveGitCommonDirectory({ directory, }) {
286
+ const result = await execAsync('git rev-parse --git-common-dir', {
287
+ cwd: directory,
288
+ timeout: 10_000,
289
+ }).catch((e) => new Error(`Failed to resolve Git common directory for ${directory}`, { cause: e }));
290
+ if (result instanceof Error)
291
+ return result;
292
+ const commonDirectory = path.isAbsolute(result.stdout.trim())
293
+ ? result.stdout.trim()
294
+ : path.resolve(directory, result.stdout.trim());
295
+ return canonicalizePath(commonDirectory);
296
+ }
297
+ async function resolveGitDirectory({ directory, }) {
298
+ const result = await execAsync('git rev-parse --absolute-git-dir', {
299
+ cwd: directory,
300
+ timeout: 10_000,
301
+ }).catch((e) => new Error(`Failed to resolve Git directory for ${directory}`, { cause: e }));
302
+ if (result instanceof Error)
303
+ return result;
304
+ return canonicalizePath(result.stdout.trim());
305
+ }
306
+ export async function validateWorktreeIdentity({ projectDirectory, worktreeDirectory, baseCommit, }) {
307
+ const [sourceCommonGitDirectory, commonGitDirectory, gitDirectory, headCommit] = await Promise.all([
308
+ resolveGitCommonDirectory({ directory: projectDirectory }),
309
+ resolveGitCommonDirectory({ directory: worktreeDirectory }),
310
+ resolveGitDirectory({ directory: worktreeDirectory }),
311
+ resolveGitCommit({ directory: worktreeDirectory, ref: 'HEAD' }),
312
+ ]);
313
+ if (sourceCommonGitDirectory instanceof Error) {
314
+ return new WorktreeIdentityError({
315
+ reason: sourceCommonGitDirectory.message,
316
+ cause: sourceCommonGitDirectory,
317
+ });
318
+ }
319
+ if (commonGitDirectory instanceof Error) {
320
+ return new WorktreeIdentityError({
321
+ reason: commonGitDirectory.message,
322
+ cause: commonGitDirectory,
323
+ });
324
+ }
325
+ if (headCommit instanceof Error) {
326
+ return new WorktreeIdentityError({
327
+ reason: headCommit.message,
328
+ cause: headCommit,
329
+ });
330
+ }
331
+ if (gitDirectory instanceof Error) {
332
+ return new WorktreeIdentityError({
333
+ reason: gitDirectory.message,
334
+ cause: gitDirectory,
335
+ });
336
+ }
337
+ if (commonGitDirectory !== sourceCommonGitDirectory) {
338
+ return new WorktreeIdentityError({
339
+ reason: `expected common directory ${sourceCommonGitDirectory}, received ${commonGitDirectory}`,
340
+ });
341
+ }
342
+ if (gitDirectory === commonGitDirectory) {
343
+ return new WorktreeIdentityError({
344
+ reason: `${worktreeDirectory} is not a linked worktree`,
345
+ });
346
+ }
347
+ if (headCommit !== baseCommit) {
348
+ return new WorktreeIdentityError({
349
+ reason: `expected HEAD ${baseCommit}, received ${headCommit}`,
350
+ });
351
+ }
352
+ return { commonGitDirectory, headCommit };
353
+ }
242
354
  /**
243
355
  * Create a git worktree with full submodule initialization and dependency install.
244
356
  * Plugin-safe: no config.ts or logger.ts imports. Logging is done via the `log`
245
357
  * callback (defaults to silent no-op for plugin use).
246
358
  */
247
- export async function createWorktreeCore({ projectDirectory, targetDirectory, branchName, baseBranch, onProgress, log = silentLog, }) {
359
+ export async function createWorktreeCore({ projectDirectory, targetDirectory, branchName, baseCommit, onProgress, log = silentLog, }) {
248
360
  if (fs.existsSync(targetDirectory)) {
249
361
  return new Error(`Worktree directory already exists: ${targetDirectory}`);
250
362
  }
363
+ const commonGitDirectory = await resolveGitCommonDirectory({
364
+ directory: projectDirectory,
365
+ });
366
+ if (commonGitDirectory instanceof Error)
367
+ return commonGitDirectory;
251
368
  await fs.promises.mkdir(path.dirname(targetDirectory), { recursive: true });
252
- const targetRef = baseBranch || 'HEAD';
253
- const createCmd = `git worktree add ${JSON.stringify(targetDirectory)} -B ${JSON.stringify(branchName)} ${JSON.stringify(targetRef)}`;
369
+ const createCmd = `git worktree add ${JSON.stringify(targetDirectory)} -B ${JSON.stringify(branchName)} ${JSON.stringify(baseCommit)}`;
254
370
  const createResult = await execAsync(createCmd, {
255
371
  cwd: projectDirectory,
256
372
  timeout: SUBMODULE_INIT_TIMEOUT_MS,
@@ -258,7 +374,11 @@ export async function createWorktreeCore({ projectDirectory, targetDirectory, br
258
374
  if (createResult instanceof Error)
259
375
  return createResult;
260
376
  // Remove broken submodule stubs before init
261
- await removeBrokenSubmoduleStubs(targetDirectory, log);
377
+ await removeBrokenSubmoduleStubs({
378
+ directory: targetDirectory,
379
+ expectedCommonGitDirectory: commonGitDirectory,
380
+ log,
381
+ });
262
382
  // Init submodules with local --reference directories
263
383
  log.info(`Initializing submodules in ${targetDirectory}`);
264
384
  const submoduleResult = await initializeSubmodulesWithLocalReferences({
@@ -272,8 +392,25 @@ export async function createWorktreeCore({ projectDirectory, targetDirectory, br
272
392
  else {
273
393
  log.info(`Submodules initialized in ${targetDirectory}`);
274
394
  }
275
- // Validate submodule pointers (non-fatal)
276
- await validateSubmodulePointers(targetDirectory, log);
395
+ // Invalid submodule pointers can route later Git commands into another clone.
396
+ const submoduleValidation = await validateSubmodulePointers({
397
+ directory: targetDirectory,
398
+ expectedCommonGitDirectory: commonGitDirectory,
399
+ log,
400
+ });
401
+ if (submoduleValidation instanceof Error) {
402
+ const cleanupResult = await removeWorktreeCore({
403
+ projectDirectory,
404
+ worktreeDirectory: targetDirectory,
405
+ branchName,
406
+ });
407
+ if (cleanupResult instanceof Error) {
408
+ return new Error(`${submoduleValidation.message}; cleanup failed: ${cleanupResult.message}`, {
409
+ cause: submoduleValidation,
410
+ });
411
+ }
412
+ return submoduleValidation;
413
+ }
277
414
  // Dependency install (non-fatal)
278
415
  onProgress?.('Installing dependencies...');
279
416
  const installResult = await runDependencyInstall(targetDirectory, log);
@@ -291,6 +428,34 @@ export async function removeWorktreeCore({ projectDirectory, worktreeDirectory,
291
428
  if (removeResult instanceof Error)
292
429
  return removeResult;
293
430
  if (branchName) {
294
- await execAsync(`git branch -D ${JSON.stringify(branchName)}`, { cwd: projectDirectory, timeout: 10_000 }).catch(() => { });
431
+ const listResult = await execAsync(`git branch --list --format=${JSON.stringify('%(refname:short)')} ${JSON.stringify(branchName)}`, { cwd: projectDirectory, timeout: 10_000 }).catch((e) => new Error(`git branch list failed: ${formatCommandError(e)}`, { cause: e }));
432
+ if (listResult instanceof Error)
433
+ return listResult;
434
+ if (listResult.stdout.trim() === branchName) {
435
+ const deleteResult = await execAsync(`git branch -D ${JSON.stringify(branchName)}`, { cwd: projectDirectory, timeout: 10_000 }).catch((e) => new Error(`git branch delete failed: ${formatCommandError(e)}`, { cause: e }));
436
+ if (deleteResult instanceof Error)
437
+ return deleteResult;
438
+ }
295
439
  }
296
440
  }
441
+ export async function removeWorktreeFromOwnRepository({ worktreeDirectory, branchName, }) {
442
+ if (!fs.existsSync(worktreeDirectory))
443
+ return;
444
+ const listResult = await execAsync('git worktree list --porcelain', {
445
+ cwd: worktreeDirectory,
446
+ timeout: 10_000,
447
+ }).catch((e) => new Error('Failed to locate the worktree repository', { cause: e }));
448
+ if (listResult instanceof Error)
449
+ return listResult;
450
+ const mainWorktreeLine = listResult.stdout
451
+ .split('\n')
452
+ .find((line) => line.startsWith('worktree '));
453
+ if (!mainWorktreeLine) {
454
+ return new Error('Git returned no main worktree during cleanup');
455
+ }
456
+ return removeWorktreeCore({
457
+ projectDirectory: mainWorktreeLine.slice('worktree '.length),
458
+ worktreeDirectory,
459
+ branchName,
460
+ });
461
+ }
@@ -1,9 +1,10 @@
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.
@@ -180,6 +181,65 @@ const ipcToolsPlugin = async () => {
180
181
  return 'Action button request timed out';
181
182
  },
182
183
  }),
184
+ kimaki_sleep: tool({
185
+ description: dedent `
186
+ Sleep this session until a future time, then wake with a new message.
187
+ Use this to wait hours or days for CI, a deploy, a date, or any later event.
188
+ The sleep is stored in SQLite and survives bot restarts.
189
+
190
+ Pass either duration (2h, 30m, 1d) or until (UTC ISO ending with Z).
191
+ Do not pass both. You MUST call kimaki_sleep LAST, after ALL text.
192
+ Do not call more tools after it. A new user message cancels the sleep.
193
+ `,
194
+ args: {
195
+ duration: z
196
+ .string()
197
+ .optional()
198
+ .describe('Relative wait. Examples: 30s, 2h, 1d. Do not pass with until.'),
199
+ until: z
200
+ .string()
201
+ .optional()
202
+ .describe('UTC ISO date ending with Z. Example: 2026-08-20T09:00:00Z.'),
203
+ reason: z
204
+ .string()
205
+ .optional()
206
+ .describe('Why the session is sleeping. Shown when it wakes.'),
207
+ },
208
+ async execute({ duration, until, reason }, context) {
209
+ const { parseSleepWakeAt, formatSessionSleepWakeAt } = await import('./task-schedule.js');
210
+ const wakeAt = parseSleepWakeAt({
211
+ duration,
212
+ until,
213
+ now: new Date(),
214
+ });
215
+ if (wakeAt instanceof Error) {
216
+ return wakeAt.message;
217
+ }
218
+ const { getThreadIdBySessionId, upsertSessionSleep } = await loadDatabaseModule();
219
+ // Only a check that a thread owns this session: subagents have none.
220
+ // The wake resolves the thread again at delivery time, so nothing
221
+ // here can go stale if /resume rebinds the session later.
222
+ const threadId = await getThreadIdBySessionId(context.sessionID);
223
+ if (!threadId) {
224
+ return 'sleep is only available in the main session';
225
+ }
226
+ await upsertSessionSleep({
227
+ sessionId: context.sessionID,
228
+ wakeAt,
229
+ reason,
230
+ });
231
+ // The title carries the resolved absolute time so Discord shows the
232
+ // real wake instant instead of a relative input like "2h".
233
+ const untilLabel = formatSessionSleepWakeAt(wakeAt);
234
+ const reasonText = reason?.trim()
235
+ ? ` Reason: ${reason.trim()}.`
236
+ : '';
237
+ return {
238
+ title: `until ${untilLabel}`,
239
+ output: `Sleeping until ${untilLabel}.${reasonText} Stop now. You will be woken with a new message.`,
240
+ };
241
+ },
242
+ }),
183
243
  },
184
244
  };
185
245
  };
@@ -1,56 +1,64 @@
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
  import { spawn } from 'node:child_process';
6
8
  import fs from 'node:fs';
7
9
  import path from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import { test, expect } from 'vitest';
10
+ import { createRequire } from 'node:module';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { afterAll, beforeAll, expect, test } from 'vitest';
13
+ import { PROVIDER_IDS } from '@subrouter/cli';
10
14
  import { resolveOpencodeCommand } from './opencode.js';
11
15
  import { getSpawnCommandAndArgs } from './opencode-command.js';
12
16
  import { chooseLockPort } from './test-utils.js';
13
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
- async function waitForHealth({ port, maxAttempts = 30, }) {
15
- for (let i = 0; i < maxAttempts; i++) {
16
- try {
17
- const response = await fetch(`http://127.0.0.1:${port}/api/health`);
18
- if (response.status < 500) {
19
- return true;
20
- }
21
- }
22
- catch {
23
- // connection refused, retry
24
- }
17
+ const require = createRequire(import.meta.url);
18
+ const port = chooseLockPort({ key: 'opencode-plugin-loading-e2e' });
19
+ const projectDir = path.resolve(process.cwd(), 'tmp', 'plugin-loading-e2e');
20
+ const subrouterHome = path.join(projectDir, 'subrouter-home');
21
+ const stderrLines = [];
22
+ let serverProcess;
23
+ async function api(routePath) {
24
+ const url = new URL(routePath, `http://127.0.0.1:${port}`);
25
+ url.searchParams.set('directory', projectDir);
26
+ const response = await fetch(url, { headers: { 'x-opencode-directory': projectDir } });
27
+ if (!response.ok)
28
+ throw new Error(`${routePath} returned ${response.status}`);
29
+ return (await response.json());
30
+ }
31
+ async function waitForHealth() {
32
+ for (let attempt = 0; attempt < 60; attempt++) {
33
+ const ok = await fetch(`http://127.0.0.1:${port}/api/health`)
34
+ .then((r) => r.status < 500)
35
+ .catch(() => false);
36
+ if (ok)
37
+ return true;
25
38
  await new Promise((resolve) => {
26
- setTimeout(resolve, 1000);
39
+ setTimeout(resolve, 500);
27
40
  });
28
41
  }
29
42
  return false;
30
43
  }
31
- test('opencode server loads plugin without errors', async () => {
32
- const projectDir = path.resolve(process.cwd(), 'tmp', 'plugin-loading-e2e');
33
- fs.mkdirSync(projectDir, { recursive: true });
34
- const port = chooseLockPort({ key: 'opencode-plugin-loading-e2e' });
35
- const pluginPath = new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href;
36
- const stderrLines = [];
37
- const isolatedOpencodeRoot = path.join(projectDir, 'opencode-test-home');
44
+ beforeAll(async () => {
45
+ fs.rmSync(projectDir, { recursive: true, force: true });
46
+ const opencodeRoot = path.join(projectDir, 'opencode-test-home');
38
47
  const xdgDirectories = {
39
- OPENCODE_CONFIG_DIR: path.join(isolatedOpencodeRoot, '.opencode-kimaki'),
40
- XDG_CONFIG_HOME: path.join(isolatedOpencodeRoot, '.config'),
41
- XDG_DATA_HOME: path.join(isolatedOpencodeRoot, '.local', 'share'),
42
- XDG_CACHE_HOME: path.join(isolatedOpencodeRoot, '.cache'),
43
- XDG_STATE_HOME: path.join(isolatedOpencodeRoot, '.local', 'state'),
48
+ OPENCODE_CONFIG_DIR: path.join(opencodeRoot, '.opencode-kimaki'),
49
+ XDG_CONFIG_HOME: path.join(opencodeRoot, '.config'),
50
+ XDG_DATA_HOME: path.join(opencodeRoot, '.local', 'share'),
51
+ XDG_CACHE_HOME: path.join(opencodeRoot, '.cache'),
52
+ XDG_STATE_HOME: path.join(opencodeRoot, '.local', 'state'),
44
53
  };
45
- fs.mkdirSync(isolatedOpencodeRoot, { recursive: true });
46
- Object.values(xdgDirectories).forEach((directory) => {
54
+ [...Object.values(xdgDirectories), subrouterHome].forEach((directory) => {
47
55
  fs.mkdirSync(directory, { recursive: true });
48
56
  });
49
- const { command, args, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
57
+ const { command, args, windowsVerbatimArguments } = getSpawnCommandAndArgs({
50
58
  resolvedCommand: resolveOpencodeCommand(),
51
59
  baseArgs: ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
52
60
  });
53
- const serverProcess = spawn(command, args, {
61
+ serverProcess = spawn(command, args, {
54
62
  stdio: 'pipe',
55
63
  cwd: projectDir,
56
64
  windowsVerbatimArguments,
@@ -60,35 +68,91 @@ test('opencode server loads plugin without errors', async () => {
60
68
  $schema: 'https://opencode.ai/config.json',
61
69
  lsp: false,
62
70
  formatter: false,
63
- plugin: [pluginPath],
71
+ plugin: [
72
+ new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href,
73
+ pathToFileURL(require.resolve('@subrouter/opencode')).href,
74
+ ],
64
75
  }),
65
- OPENCODE_TEST_HOME: isolatedOpencodeRoot,
76
+ OPENCODE_TEST_HOME: opencodeRoot,
77
+ SUBROUTER_HOME: subrouterHome,
78
+ SUBROUTER_MANUAL_OAUTH: '1',
66
79
  ...xdgDirectories,
67
80
  },
68
81
  });
69
82
  serverProcess.stderr?.on('data', (data) => {
70
83
  stderrLines.push(...data.toString().split('\n').filter(Boolean));
71
84
  });
72
- try {
73
- const healthy = await waitForHealth({ port });
74
- expect(healthy).toBe(true);
75
- // Check no plugin-related errors in stderr
76
- const pluginErrorPatterns = [
77
- /plugin.*error/i,
78
- /failed to load plugin/i,
79
- /cannot find module/i,
80
- /ERR_MODULE_NOT_FOUND/i,
81
- /plugin.*failed/i,
82
- /plugin.*crash/i,
83
- ];
84
- const errorLines = stderrLines.filter((line) => {
85
- return pluginErrorPatterns.some((pattern) => {
86
- return pattern.test(line);
87
- });
85
+ expect(await waitForHealth()).toBe(true);
86
+ }, 120_000);
87
+ afterAll(() => {
88
+ serverProcess?.kill('SIGTERM');
89
+ });
90
+ test('server loads both plugins without errors', () => {
91
+ const pluginErrorPatterns = [
92
+ /plugin.*error/i,
93
+ /failed to load plugin/i,
94
+ /cannot find module/i,
95
+ /ERR_MODULE_NOT_FOUND/i,
96
+ /plugin.*failed/i,
97
+ /plugin.*crash/i,
98
+ ];
99
+ const errorLines = stderrLines.filter((line) => {
100
+ return pluginErrorPatterns.some((pattern) => {
101
+ return pattern.test(line);
88
102
  });
89
- expect(errorLines).toEqual([]);
90
- }
91
- finally {
92
- serverProcess.kill('SIGTERM');
93
- }
94
- }, 60_000);
103
+ });
104
+ expect(errorLines).toEqual([]);
105
+ });
106
+ test('subrouter is a connected provider so /model accepts subrouter/default', async () => {
107
+ const providers = await api('/provider');
108
+ expect(providers.connected).toContain('subrouter');
109
+ const subrouter = providers.all.find((entry) => {
110
+ return entry.id === 'subrouter';
111
+ });
112
+ expect(subrouter?.name).toBe('subrouter.org');
113
+ expect(subrouter?.models.default).toMatchObject({
114
+ capabilities: {
115
+ attachment: false,
116
+ input: { image: false, pdf: false },
117
+ },
118
+ });
119
+ });
120
+ test('login offers subrouter and asks which subscription to add first', async () => {
121
+ const methods = await api('/provider/auth');
122
+ expect(methods.subrouter).toHaveLength(1);
123
+ expect(methods.subrouter?.[0]).toMatchObject({ type: 'oauth', label: 'Add a subscription' });
124
+ // Derived from subrouter, not hardcoded: it adds providers over time and a
125
+ // literal list here just breaks on the next one.
126
+ const prompt = methods.subrouter?.[0]?.prompts?.[0];
127
+ expect(prompt).toMatchObject({ type: 'select', key: 'provider' });
128
+ expect(prompt?.options?.map((option) => option.value)).toEqual([...PROVIDER_IDS]);
129
+ });
130
+ test('authorize and callback add an opencode-go subscription', async () => {
131
+ const url = new URL('/provider/subrouter/oauth/authorize', `http://127.0.0.1:${port}`);
132
+ url.searchParams.set('directory', projectDir);
133
+ const response = await fetch(url, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json', 'x-opencode-directory': projectDir },
136
+ body: JSON.stringify({ method: 0, inputs: { provider: 'opencode-go' } }),
137
+ });
138
+ expect(response.ok).toBe(true);
139
+ const authorization = (await response.json());
140
+ expect(authorization.method).toBe('code');
141
+ expect(authorization.url).toBe('https://opencode.ai/auth');
142
+ expect(authorization.instructions).toContain('API key');
143
+ const callbackUrl = new URL('/provider/subrouter/oauth/callback', `http://127.0.0.1:${port}`);
144
+ callbackUrl.searchParams.set('directory', projectDir);
145
+ const callbackResponse = await fetch(callbackUrl, {
146
+ method: 'POST',
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ 'x-opencode-directory': projectDir,
150
+ },
151
+ body: JSON.stringify({ method: 0, code: 'zen-key-1' }),
152
+ });
153
+ expect(callbackResponse.ok).toBe(true);
154
+ const config = JSON.parse(fs.readFileSync(path.join(subrouterHome, 'config.json'), 'utf8'));
155
+ expect(config.providers['opencode-go']?.accounts).toMatchObject([
156
+ { type: 'api', key: 'zen-key-1' },
157
+ ]);
158
+ });
@@ -4,22 +4,26 @@
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
13
  export { ipcToolsPlugin } from './ipc-tools-plugin.js';
14
14
  export { contextAwarenessPlugin } from './context-awareness-plugin.js';
15
15
  export { memoryOverviewPlugin } from './memory-overview-plugin.js';
16
16
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
17
+ // LEGACY per-provider account rotation. Superseded by @subrouter/opencode
18
+ // (registered separately in opencode.ts), which also fails over across
19
+ // providers. These stay because they own `anthropic/*` auth, which opencode
20
+ // itself does not provide. See anthropic-auth-plugin.ts for the full note.
17
21
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
18
22
  export { openaiRotationPlugin } from './openai-auth-plugin.js';
19
23
  export { xaiRotationPlugin } from './xai-auth-plugin.js';
20
24
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
21
- export { subagentRateLimitPlugin } from './subagent-rate-limit-plugin.js';
22
25
  export { cacheDriftPlugin } from './cache-drift-plugin.js';
23
26
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
24
27
  export { injectionGuardInternal as injectionGuard } from 'opencode-injection-guard';
25
28
  export { kimakiWorkspaceAdaptorPlugin } from './kimaki-workspace-adaptor.js';
29
+ export { fileEditTrackerPlugin } from './file-edit-log.js';