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
@@ -2,25 +2,26 @@
2
2
  // Uses OpenCode SDK v2 to create worktrees with kimaki- prefix
3
3
  // Creates thread immediately, then worktree in background so user can type
4
4
  import { ChannelType, REST, } from 'discord.js';
5
+ import crypto from 'node:crypto';
5
6
  import fs from 'node:fs';
6
7
  import { OpenCodeSdkError } from '../errors.js';
7
- import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, getThreadWorktreeOrWorkspace, setThreadSession, } from '../database.js';
8
+ import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
8
9
  import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
9
10
  import { createLogger, LogPrefix } from '../logger.js';
10
11
  import { notifyError } from '../sentry.js';
11
- import { execAsync, listBranchesByLastCommit, resolveBestBaseRef, validateBranchRef, } from '../worktrees.js';
12
+ import { execAsync, getManagedWorktreeDirectory, listBranchesByLastCommit, resolveBestBaseRef, validateBranchRef, } from '../worktrees.js';
13
+ import { KIMAKI_WORKTREE_ADAPTER_TYPE, removeWorktreeFromOwnRepository, resolveGitCommit, validateWorktreeIdentity, } from '../git-worktree-core.js';
12
14
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
13
15
  import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
14
16
  import { WORKTREE_PREFIX } from './merge-worktree.js';
15
17
  import * as errore from 'errore';
16
18
  import { copyCurrentSessionModel } from './model.js';
17
19
  const logger = createLogger(LogPrefix.WORKTREE);
18
- const DEFAULT_WORKTREE_BASE_REF = 'HEAD';
19
20
  async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch, }) {
20
21
  if (!rawBaseBranch) {
21
22
  // Default to the current local HEAD so worktrees can branch from
22
23
  // unpublished commits in the main checkout.
23
- return DEFAULT_WORKTREE_BASE_REF;
24
+ return resolveGitCommit({ directory: projectDirectory, ref: 'HEAD' });
24
25
  }
25
26
  const validated = await validateBranchRef({
26
27
  directory: projectDirectory,
@@ -37,7 +38,7 @@ async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch
37
38
  if (bestRef !== validated) {
38
39
  logger.log(`Base branch resolved: ${validated} → ${bestRef} (remote is ahead)`);
39
40
  }
40
- return bestRef;
41
+ return resolveGitCommit({ directory: projectDirectory, ref: bestRef });
41
42
  }
42
43
  /** Status message shown while a worktree is being created. */
43
44
  export function worktreeCreatingMessage(worktreeName) {
@@ -153,28 +154,83 @@ async function getProjectDirectoryFromChannel(channel) {
153
154
  /**
154
155
  * Try creating a worktree via the OpenCode workspace SDK.
155
156
  * Returns the workspace directory on success, or an Error if the workspace
156
- * feature is not available or the creation fails. Callers fall back to the
157
- * direct git path on Error.
157
+ * feature is not available or the creation fails.
158
158
  */
159
- async function tryWorkspaceCreate({ threadId, worktreeName, projectDirectory, baseBranch, }) {
159
+ export async function tryWorkspaceCreate({ worktreeName, projectDirectory, baseCommit, }) {
160
160
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
161
161
  if (getClient instanceof Error)
162
162
  return getClient;
163
163
  const client = getClient();
164
+ const workspaceId = `wrk_${crypto.randomUUID()}`;
165
+ const managedDirectory = getManagedWorktreeDirectory({
166
+ directory: projectDirectory,
167
+ name: worktreeName,
168
+ });
169
+ const cleanupFailedWorkspace = async (worktreeDirectory) => {
170
+ const removeResponse = await client.experimental.workspace.remove({
171
+ id: workspaceId,
172
+ directory: projectDirectory,
173
+ }).catch((e) => new OpenCodeSdkError({ operation: 'workspace.remove', cause: e }));
174
+ const sdkCleanupError = (() => {
175
+ if (removeResponse instanceof Error)
176
+ return removeResponse;
177
+ if (removeResponse.error) {
178
+ return new Error(`Workspace cleanup failed: ${JSON.stringify(removeResponse.error)}`);
179
+ }
180
+ return undefined;
181
+ })();
182
+ const gitCleanupError = fs.existsSync(worktreeDirectory)
183
+ ? await removeWorktreeFromOwnRepository({
184
+ worktreeDirectory,
185
+ branchName: worktreeName,
186
+ })
187
+ : undefined;
188
+ return sdkCleanupError ?? gitCleanupError;
189
+ };
164
190
  const response = await client.experimental.workspace.create({
191
+ id: workspaceId,
165
192
  directory: projectDirectory,
166
- type: 'kimaki-worktree',
193
+ type: KIMAKI_WORKTREE_ADAPTER_TYPE,
167
194
  branch: worktreeName,
168
- extra: baseBranch ? { baseBranch } : null,
195
+ extra: {
196
+ projectDirectory,
197
+ baseCommit,
198
+ },
169
199
  }).catch((e) => new OpenCodeSdkError({ operation: 'workspace.create', cause: e }));
170
- if (response instanceof Error)
171
- return response;
172
- if (response.error) {
173
- return new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`);
200
+ if (response instanceof Error || response.error) {
201
+ const creationError = response instanceof Error
202
+ ? response
203
+ : new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`);
204
+ const cleanupError = await cleanupFailedWorkspace(managedDirectory);
205
+ if (cleanupError instanceof Error) {
206
+ return new Error(`${creationError.message}; cleanup failed: ${cleanupError.message}`, {
207
+ cause: creationError,
208
+ });
209
+ }
210
+ return creationError;
174
211
  }
175
212
  const workspace = response.data;
176
213
  if (!workspace?.directory || !workspace.id) {
177
- return new Error('Workspace SDK returned no directory or ID');
214
+ const creationError = new Error('Workspace SDK returned no directory or ID');
215
+ const cleanupError = await cleanupFailedWorkspace(managedDirectory);
216
+ if (cleanupError instanceof Error) {
217
+ return new Error(`${creationError.message}; cleanup failed: ${cleanupError.message}`, {
218
+ cause: creationError,
219
+ });
220
+ }
221
+ return creationError;
222
+ }
223
+ const identityResult = await validateWorktreeIdentity({
224
+ projectDirectory,
225
+ worktreeDirectory: workspace.directory,
226
+ baseCommit,
227
+ });
228
+ if (identityResult instanceof Error) {
229
+ const cleanupError = await cleanupFailedWorkspace(workspace.directory);
230
+ if (cleanupError instanceof Error) {
231
+ return new Error(`${identityResult.message}; cleanup failed: ${cleanupError.message}`, { cause: identityResult });
232
+ }
233
+ return identityResult;
178
234
  }
179
235
  return { directory: workspace.directory, workspaceId: workspace.id };
180
236
  }
@@ -188,13 +244,13 @@ async function tryWorkspaceCreate({ threadId, worktreeName, projectDirectory, ba
188
244
  * starterMessage is optional — if omitted, status edits are skipped (creation
189
245
  * still proceeds). This keeps worktree creation independent of Discord message
190
246
  * delivery, so a transient send failure never silently skips the worktree.
247
+ * beforeReady can bind a session before incoming messages may use the checkout.
191
248
  *
192
249
  * Returns the worktree directory on success, or an Error on failure.
193
250
  * Never throws — all internal errors are caught and returned as Error values.
194
251
  */
195
- export async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory, baseBranch, rest, }) {
252
+ export async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory, baseCommit, rest, beforeReady, }) {
196
253
  return (async () => {
197
- logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}${baseBranch ? ` from ${baseBranch}` : ''}`);
198
254
  // Serialize status message edits so onProgress can't overwrite the
199
255
  // final success/error edit even if Discord's API is slow.
200
256
  let editChain = Promise.resolve();
@@ -213,11 +269,23 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
213
269
  workspaceName: worktreeName,
214
270
  projectDirectory,
215
271
  });
272
+ const resolvedBaseCommit = baseCommit
273
+ ? baseCommit
274
+ : await resolveGitCommit({ directory: projectDirectory, ref: 'HEAD' });
275
+ if (resolvedBaseCommit instanceof Error) {
276
+ await setWorkspaceError({
277
+ threadId: thread.id,
278
+ errorMessage: resolvedBaseCommit.message,
279
+ });
280
+ editStatus(`🌳 **Worktree: ${worktreeName}**\n❌ ${resolvedBaseCommit.message}`);
281
+ await editChain;
282
+ return resolvedBaseCommit;
283
+ }
284
+ logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory} from ${resolvedBaseCommit}`);
216
285
  const workspaceResult = await tryWorkspaceCreate({
217
- threadId: thread.id,
218
286
  worktreeName,
219
287
  projectDirectory,
220
- baseBranch,
288
+ baseCommit: resolvedBaseCommit,
221
289
  });
222
290
  if (workspaceResult instanceof Error) {
223
291
  const errorMsg = workspaceResult.message;
@@ -227,6 +295,14 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
227
295
  await editChain;
228
296
  return workspaceResult;
229
297
  }
298
+ const preparationResult = beforeReady
299
+ ? await beforeReady(workspaceResult).catch((e) => new WorktreeError('Failed to prepare worktree session', { cause: e }))
300
+ : undefined;
301
+ if (preparationResult instanceof Error) {
302
+ logger.error('[WORKTREE] Session preparation failed:', preparationResult);
303
+ void notifyError(preparationResult, 'Worktree session preparation failed');
304
+ await sendThreadMessage(thread, `Worktree is ready, but failed to prepare its session: ${preparationResult.message}`);
305
+ }
230
306
  await setWorkspaceReady({
231
307
  threadId: thread.id,
232
308
  workspaceId: workspaceResult.workspaceId,
@@ -308,11 +384,11 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
308
384
  return;
309
385
  }
310
386
  // Parallelize: base branch validation and existing worktree check are independent
311
- const [baseBranch, existingWorktree] = await Promise.all([
387
+ const [baseCommit, existingWorktree] = await Promise.all([
312
388
  resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch }),
313
389
  findExistingWorktreePath({ projectDirectory, worktreeName }),
314
390
  ]);
315
- if (baseBranch instanceof Error) {
391
+ if (baseCommit instanceof Error) {
316
392
  await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``);
317
393
  return;
318
394
  }
@@ -354,7 +430,7 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
354
430
  starterMessage,
355
431
  worktreeName,
356
432
  projectDirectory,
357
- baseBranch,
433
+ baseCommit,
358
434
  rest: command.client.rest,
359
435
  }).catch((e) => {
360
436
  logger.error('[NEW-WORKTREE] Background error:', e);
@@ -391,12 +467,12 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
391
467
  // Parallelize: base branch validation, existing worktree check, and parent channel
392
468
  // resolve are all independent. resolveTextChannel fetches the parent from Discord
393
469
  // cache/API which can overlap with the git operations.
394
- const [baseBranch, existingWorktreePath, textChannel] = await Promise.all([
470
+ const [baseCommit, existingWorktreePath, textChannel] = await Promise.all([
395
471
  resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch }),
396
472
  findExistingWorktreePath({ projectDirectory, worktreeName }),
397
473
  resolveTextChannel(thread),
398
474
  ]);
399
- if (baseBranch instanceof Error) {
475
+ if (baseCommit instanceof Error) {
400
476
  await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``);
401
477
  return;
402
478
  }
@@ -440,86 +516,78 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
440
516
  starterMessage: statusMessage,
441
517
  worktreeName,
442
518
  projectDirectory,
443
- baseBranch,
519
+ baseCommit,
444
520
  rest: command.client.rest,
445
- })
446
- .then(async (result) => {
447
- if (result instanceof Error)
448
- return;
449
- const sourceSessionId = await getThreadSession(thread.id);
450
- if (!sourceSessionId) {
451
- await sendThreadMessage(worktreeThread, 'Worktree is ready. Send a message here to start a fresh session in this checkout.');
452
- return;
453
- }
454
- const workspace = await getThreadWorktreeOrWorkspace(worktreeThread.id);
455
- if (!workspace?.workspace_id) {
456
- await sendThreadMessage(worktreeThread, '✗ Worktree is ready, but OpenCode returned no workspace ID for context reuse.');
457
- return;
458
- }
459
- const getClient = await initializeOpencodeForDirectory(result, {
460
- originalRepoDirectory: projectDirectory,
461
- channelId: parent.id,
462
- });
463
- if (getClient instanceof Error) {
464
- await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to initialize OpenCode for context reuse: ${getClient.message}`);
465
- return;
466
- }
467
- const forkResponse = await getClient().session.fork({
468
- sessionID: sourceSessionId,
469
- directory: result,
470
- workspace: workspace.workspace_id,
471
- }).catch((e) => new OpenCodeSdkError({ operation: 'session.fork', cause: e }));
472
- if (forkResponse instanceof Error) {
473
- logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse);
474
- void notifyError(forkResponse, 'Failed to fork session into worktree');
475
- await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${forkResponse.message}`);
476
- return;
477
- }
478
- const forkedSession = forkResponse.data;
479
- if (!forkedSession) {
480
- const error = new Error('OpenCode did not return a forked session');
481
- logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', error);
482
- void notifyError(error, 'Failed to fork session into worktree');
483
- await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
484
- return;
485
- }
486
- await copyCurrentSessionModel({
487
- sourceSessionId,
488
- targetSessionId: forkedSession.id,
489
- channelId: parent.id,
490
- appId,
491
- getClient,
492
- directory: projectDirectory,
493
- });
494
- const permissionResponse = await getClient().session.update({
495
- sessionID: forkedSession.id,
496
- directory: result,
497
- permission: buildSessionPermissions({
498
- directory: result,
521
+ beforeReady: async ({ directory, workspaceId }) => {
522
+ const sourceSessionId = await getThreadSession(thread.id);
523
+ if (!sourceSessionId) {
524
+ await sendThreadMessage(worktreeThread, 'Worktree is ready. Send a message here to start a fresh session in this checkout.');
525
+ return;
526
+ }
527
+ const getClient = await initializeOpencodeForDirectory(directory, {
499
528
  originalRepoDirectory: projectDirectory,
500
- }),
501
- }).catch((e) => new OpenCodeSdkError({ operation: 'session.update', cause: e }));
502
- if (permissionResponse instanceof Error || permissionResponse.error) {
503
- const error = permissionResponse instanceof Error
504
- ? permissionResponse
505
- : new Error('OpenCode rejected forked session permission update');
506
- logger.error('[NEW-WORKTREE] Failed to update forked session permissions:', error);
507
- void notifyError(error, 'Failed to update forked session permissions');
508
- await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to update forked session permissions: ${error.message}`);
509
- return;
510
- }
511
- await setThreadSession(worktreeThread.id, forkedSession.id);
512
- getOrCreateRuntime({
513
- threadId: worktreeThread.id,
514
- thread: worktreeThread,
515
- projectDirectory,
516
- sdkDirectory: result,
517
- channelId: parent.id,
518
- appId,
519
- });
520
- await sendThreadMessage(worktreeThread, `Reusing context from <#${thread.id}> in worktree session \`${forkedSession.id}\`.`);
521
- })
522
- .catch((e) => {
529
+ channelId: parent.id,
530
+ });
531
+ if (getClient instanceof Error) {
532
+ await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to initialize OpenCode for context reuse: ${getClient.message}`);
533
+ return;
534
+ }
535
+ const forkResponse = await getClient().session.fork({
536
+ sessionID: sourceSessionId,
537
+ directory,
538
+ workspace: workspaceId,
539
+ }).catch((e) => new OpenCodeSdkError({ operation: 'session.fork', cause: e }));
540
+ if (forkResponse instanceof Error) {
541
+ logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse);
542
+ void notifyError(forkResponse, 'Failed to fork session into worktree');
543
+ await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${forkResponse.message}`);
544
+ return;
545
+ }
546
+ const forkedSession = forkResponse.data;
547
+ if (!forkedSession) {
548
+ const error = new Error('OpenCode did not return a forked session');
549
+ logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', error);
550
+ void notifyError(error, 'Failed to fork session into worktree');
551
+ await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
552
+ return;
553
+ }
554
+ await copyCurrentSessionModel({
555
+ sourceSessionId,
556
+ targetSessionId: forkedSession.id,
557
+ channelId: parent.id,
558
+ appId,
559
+ getClient,
560
+ directory: projectDirectory,
561
+ });
562
+ const permissionResponse = await getClient().session.update({
563
+ sessionID: forkedSession.id,
564
+ directory,
565
+ permission: buildSessionPermissions({
566
+ directory,
567
+ originalRepoDirectory: projectDirectory,
568
+ }),
569
+ }).catch((e) => new OpenCodeSdkError({ operation: 'session.update', cause: e }));
570
+ if (permissionResponse instanceof Error || permissionResponse.error) {
571
+ const error = permissionResponse instanceof Error
572
+ ? permissionResponse
573
+ : new Error('OpenCode rejected forked session permission update');
574
+ logger.error('[NEW-WORKTREE] Failed to update forked session permissions:', error);
575
+ void notifyError(error, 'Failed to update forked session permissions');
576
+ await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to update forked session permissions: ${error.message}`);
577
+ return;
578
+ }
579
+ await setThreadSession(worktreeThread.id, forkedSession.id);
580
+ getOrCreateRuntime({
581
+ threadId: worktreeThread.id,
582
+ thread: worktreeThread,
583
+ projectDirectory,
584
+ sdkDirectory: directory,
585
+ channelId: parent.id,
586
+ appId,
587
+ });
588
+ await sendThreadMessage(worktreeThread, `Reusing context from <#${thread.id}> in worktree session \`${forkedSession.id}\`.`);
589
+ },
590
+ }).catch((e) => {
523
591
  logger.error('[NEW-WORKTREE] Background error:', e);
524
592
  void notifyError(e, 'Background worktree creation failed (in-thread)');
525
593
  });
@@ -153,14 +153,12 @@ export const handleUserCommand = async ({ command, appId, }) => {
153
153
  rest: command.client.rest,
154
154
  });
155
155
  }
156
- const sessionDirectory = await (async () => {
157
- if (!worktreePromise)
158
- return projectDirectory;
159
- const result = await worktreePromise;
160
- if (result instanceof Error)
161
- return projectDirectory;
162
- return result;
163
- })();
156
+ const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory;
157
+ if (worktreeResult instanceof Error) {
158
+ await command.editReply(`Worktree creation failed: ${worktreeResult.message}`);
159
+ return;
160
+ }
161
+ const sessionDirectory = worktreeResult;
164
162
  await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
165
163
  const runtime = getOrCreateRuntime({
166
164
  threadId: newThread.id,
@@ -9,22 +9,15 @@ import { deleteThreadWorkspace, getChannelWorktreesEnabled, setChannelWorktreesE
9
9
  import { getDb } from '../db.js';
10
10
  import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
11
11
  import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
12
- import * as errore from 'errore';
13
12
  import crypto from 'node:crypto';
14
- import { GitCommandError, OpenCodeSdkError } from '../errors.js';
13
+ import { OpenCodeSdkError } from '../errors.js';
15
14
  import { resolveTextChannel, resolveWorkingDirectory } from '../discord-utils.js';
16
15
  import { initializeOpencodeForDirectory } from '../opencode.js';
17
- import { deleteWorktree, git, getDefaultBranch, listGitWorktrees, } from '../worktrees.js';
16
+ import { deleteWorktree, extractGitExecOutput, git, getDefaultBranch, listGitWorktrees, } from '../worktrees.js';
18
17
  import path from 'node:path';
19
- // Extracts the git stderr from a deleteWorktree error via errore.findCause.
20
- // Chain: Error { cause: GitCommandError { cause: CommandError { stderr } } }.
21
18
  export function extractGitStderr(error) {
22
- const gitErr = errore.findCause(error, GitCommandError);
23
- const stderr = gitErr?.cause?.stderr?.trim();
24
- if (stderr && stderr.length > 0) {
25
- return stderr;
26
- }
27
- return undefined;
19
+ const stderr = extractGitExecOutput(error).stderr;
20
+ return stderr.length > 0 ? stderr : undefined;
28
21
  }
29
22
  export function formatTimeAgo(date) {
30
23
  const diffMs = Date.now() - date.getTime();
@@ -73,8 +66,7 @@ function detectWorktreeSource({ branch, directory, }) {
73
66
  // Returns null when the directory is missing / git commands fail / timeout.
74
67
  async function getWorktreeGitStatus({ directory, defaultBranch, }) {
75
68
  try {
76
- // Use raw git calls so errors/timeouts are visible — isDirty() swallows
77
- // errors and returns false, which would render "merged" instead of "unknown".
69
+ // Use raw git calls so status and ahead-count share one timeout window.
78
70
  const [statusResult, aheadResult] = await Promise.all([
79
71
  git(directory, 'status --porcelain', { timeout: GIT_CMD_TIMEOUT }),
80
72
  git(directory, `rev-list --count "${defaultBranch}..HEAD"`, {
package/dist/config.js CHANGED
@@ -10,7 +10,7 @@ import { store } from './store.js';
10
10
  const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki');
11
11
  /**
12
12
  * Get the data directory path.
13
- * Falls back to ~/.kimaki if not explicitly set.
13
+ * Order: store value, vitest temp dir, KIMAKI_DATA_DIR, then ~/.kimaki.
14
14
  * Under vitest (KIMAKI_VITEST env var), auto-creates an isolated temp dir so
15
15
  * tests never touch the real ~/.kimaki/ database. Tests that need a specific
16
16
  * dir can still call setDataDir() before any DB access to override this.
@@ -20,11 +20,20 @@ export function getDataDir() {
20
20
  if (current) {
21
21
  return current;
22
22
  }
23
+ // Tests stay isolated even if the parent process exported KIMAKI_DATA_DIR.
23
24
  if (process.env.KIMAKI_VITEST) {
24
25
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-test-'));
25
26
  store.setState({ dataDir: tmpDir });
26
27
  return tmpDir;
27
28
  }
29
+ // Child processes (OpenCode server, kimaki shim commands) inherit this from
30
+ // opencode.ts. Without it they would silently use ~/.kimaki.
31
+ const fromEnv = process.env.KIMAKI_DATA_DIR;
32
+ if (fromEnv) {
33
+ const resolved = path.resolve(fromEnv);
34
+ store.setState({ dataDir: resolved });
35
+ return resolved;
36
+ }
28
37
  store.setState({ dataDir: DEFAULT_DATA_DIR });
29
38
  return DEFAULT_DATA_DIR;
30
39
  }
@@ -79,6 +88,12 @@ export function getPermissionTimeoutMs() {
79
88
  export function getRestrictExternalDirectories() {
80
89
  return store.getState().restrictExternalDirectories;
81
90
  }
91
+ export function getOpencodeHostname() {
92
+ return store.getState().opencodeHostname;
93
+ }
94
+ export function getOpencodePort() {
95
+ return store.getState().opencodePort;
96
+ }
82
97
  const DEFAULT_LOCK_PORT = 29988;
83
98
  /**
84
99
  * Derive a lock port from the data directory path.
package/dist/database.js CHANGED
@@ -213,6 +213,104 @@ export async function setSessionStartSource({ sessionId, scheduleKind, scheduled
213
213
  set: { schedule_kind: scheduleKind, scheduled_task_id: scheduledTaskId ?? null, updated_at: new Date() },
214
214
  });
215
215
  }
216
+ export async function upsertSessionSleep({ sessionId, wakeAt, reason, }) {
217
+ const db = await getDb();
218
+ // A new occurrence gets a fresh delivery_id, which invalidates any in-flight
219
+ // wake for the previous occurrence: every delivery write is guarded on it.
220
+ const deliveryId = crypto.randomUUID();
221
+ await db.insert(schema.session_sleeps)
222
+ .values({
223
+ session_id: sessionId,
224
+ wake_at: wakeAt,
225
+ reason: reason ?? null,
226
+ status: 'planned',
227
+ delivery_id: deliveryId,
228
+ })
229
+ .onConflictDoUpdate({
230
+ target: schema.session_sleeps.session_id,
231
+ set: {
232
+ wake_at: wakeAt,
233
+ reason: reason ?? null,
234
+ status: 'planned',
235
+ delivery_id: deliveryId,
236
+ attempts: 0,
237
+ last_attempt_at: null,
238
+ created_at: new Date(),
239
+ },
240
+ });
241
+ return deliveryId;
242
+ }
243
+ export async function getSessionSleep({ sessionId }) {
244
+ const db = await getDb();
245
+ return await db.query.session_sleeps.findFirst({
246
+ where: { session_id: sessionId },
247
+ }) ?? null;
248
+ }
249
+ /** Rows that are due and not attempted too recently. Status stays `planned`. */
250
+ export async function getDueSessionSleeps({ now, retryAfterMs, limit, }) {
251
+ const db = await getDb();
252
+ const retryBefore = new Date(now.getTime() - retryAfterMs);
253
+ return db.select()
254
+ .from(schema.session_sleeps)
255
+ .where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.lte(schema.session_sleeps.wake_at, now), orm.or(orm.isNull(schema.session_sleeps.last_attempt_at), orm.lte(schema.session_sleeps.last_attempt_at, retryBefore))))
256
+ .orderBy(orm.asc(schema.session_sleeps.wake_at))
257
+ .limit(limit);
258
+ }
259
+ /**
260
+ * Reserve one delivery attempt. The row never leaves `planned` here.
261
+ *
262
+ * Only `last_attempt_at` moves, which keeps concurrent ticks off the same row
263
+ * for retryAfterMs. Because posting does not change the status either, one
264
+ * retry path covers every loss: dying before the post, dying after it, or
265
+ * never receiving the gateway event for the message we posted.
266
+ */
267
+ export async function claimSessionSleepAttempt({ sessionId, deliveryId, now, retryAfterMs, }) {
268
+ const db = await getDb();
269
+ const retryBefore = new Date(now.getTime() - retryAfterMs);
270
+ const rows = await db.update(schema.session_sleeps)
271
+ .set({ last_attempt_at: now, attempts: orm.sql `${schema.session_sleeps.attempts} + 1` })
272
+ .where(orm.and(orm.eq(schema.session_sleeps.session_id, sessionId), orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned'), orm.lte(schema.session_sleeps.wake_at, now), orm.or(orm.isNull(schema.session_sleeps.last_attempt_at), orm.lte(schema.session_sleeps.last_attempt_at, retryBefore))))
273
+ .returning();
274
+ return rows[0] ?? null;
275
+ }
276
+ export async function markSessionSleepFailed({ sessionId, deliveryId, }) {
277
+ const db = await getDb();
278
+ const rows = await db.update(schema.session_sleeps)
279
+ .set({ status: 'failed' })
280
+ .where(orm.and(orm.eq(schema.session_sleeps.session_id, sessionId), orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned')))
281
+ .returning({ session_id: schema.session_sleeps.session_id });
282
+ return countRows(rows) > 0;
283
+ }
284
+ /**
285
+ * Ingress commit point. Marks the wake as delivered to the session.
286
+ *
287
+ * Returns false when the row is missing or no longer `planned`, which is how a
288
+ * wake that raced a user cancellation, or a duplicate of one already delivered,
289
+ * gets dropped instead of starting a turn.
290
+ */
291
+ export async function consumeSessionSleepWake({ deliveryId }) {
292
+ const db = await getDb();
293
+ const rows = await db.update(schema.session_sleeps)
294
+ .set({ status: 'consumed' })
295
+ .where(orm.and(orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned')))
296
+ .returning({ session_id: schema.session_sleeps.session_id });
297
+ return countRows(rows) > 0;
298
+ }
299
+ /**
300
+ * Cancel the pending sleep of the session currently bound to a thread.
301
+ *
302
+ * Runs on every ingress, so the binding is resolved with a correlated subquery
303
+ * rather than a second round trip. thread_id is the primary key of
304
+ * thread_sessions, so it yields at most one session.
305
+ */
306
+ export async function cancelSessionSleepForThread({ threadId }) {
307
+ const db = await getDb();
308
+ const rows = await db.update(schema.session_sleeps)
309
+ .set({ status: 'cancelled' })
310
+ .where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.eq(schema.session_sleeps.session_id, orm.sql `(SELECT ${schema.thread_sessions.session_id} FROM ${schema.thread_sessions} WHERE ${schema.thread_sessions.thread_id} = ${threadId})`)))
311
+ .returning({ session_id: schema.session_sleeps.session_id });
312
+ return countRows(rows) > 0;
313
+ }
216
314
  export async function getSessionStartSourcesBySessionIds(sessionIds) {
217
315
  if (sessionIds.length === 0)
218
316
  return new Map();
@@ -440,9 +538,18 @@ export async function setThreadSession(threadId, sessionId) {
440
538
  }
441
539
  export async function upsertThreadSession({ threadId, sessionId, source }) {
442
540
  const db = await getDb();
541
+ // updated_at is written explicitly on BOTH paths, never left to the column's
542
+ // CURRENT_TIMESTAMP default. The default stores "YYYY-MM-DD HH:MM:SS" while
543
+ // Date values store ISO with a "T", and those two text formats do not sort
544
+ // against each other correctly. Writing ISO everywhere keeps the ordering in
545
+ // getThreadIdBySessionId honest and gives it millisecond resolution.
546
+ const boundAt = new Date();
443
547
  await db.insert(schema.thread_sessions)
444
- .values({ thread_id: threadId, session_id: sessionId, source })
445
- .onConflictDoUpdate({ target: schema.thread_sessions.thread_id, set: { session_id: sessionId, source } });
548
+ .values({ thread_id: threadId, session_id: sessionId, source, updated_at: boundAt })
549
+ .onConflictDoUpdate({
550
+ target: schema.thread_sessions.thread_id,
551
+ set: { session_id: sessionId, source, updated_at: boundAt },
552
+ });
446
553
  }
447
554
  export async function getThreadParentSessionId(threadId) {
448
555
  const db = await getDb();
@@ -462,9 +569,23 @@ export async function getThreadSessionSource(threadId) {
462
569
  const db = await getDb();
463
570
  return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source;
464
571
  }
572
+ /**
573
+ * Reverse lookup of the Discord thread that currently owns an OpenCode session.
574
+ *
575
+ * `thread_sessions.session_id` is NOT unique: /resume binds an existing session
576
+ * to a brand new thread without clearing the old row. An unordered findFirst
577
+ * therefore returns an arbitrary (often dead) thread, which made plugin tools
578
+ * post into the wrong conversation. Order by the most recent binding instead,
579
+ * falling back to created_at for rows written before updated_at existed.
580
+ */
465
581
  export async function getThreadIdBySessionId(sessionId) {
466
582
  const db = await getDb();
467
- return (await db.query.thread_sessions.findFirst({ where: { session_id: sessionId } }))?.thread_id;
583
+ const rows = await db.select({ thread_id: schema.thread_sessions.thread_id })
584
+ .from(schema.thread_sessions)
585
+ .where(orm.eq(schema.thread_sessions.session_id, sessionId))
586
+ .orderBy(orm.desc(orm.sql `COALESCE(${schema.thread_sessions.updated_at}, ${schema.thread_sessions.created_at})`))
587
+ .limit(1);
588
+ return rows[0]?.thread_id;
468
589
  }
469
590
  export async function getAllThreadSessionIds() {
470
591
  const db = await getDb();