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
@@ -266,6 +266,11 @@ type MarkdownBlock =
266
266
  | { type: 'text'; text: string }
267
267
  | { type: 'callout'; content: string; callout: CalloutDescriptor }
268
268
 
269
+ type MarkdownFence = {
270
+ marker: '`' | '~'
271
+ length: number
272
+ }
273
+
269
274
  function splitMarkdownByCallouts({
270
275
  markdown,
271
276
  }: {
@@ -276,9 +281,33 @@ function splitMarkdownByCallouts({
276
281
  }) ?? [markdown]
277
282
  const blocks: MarkdownBlock[] = []
278
283
  let textBuffer = ''
284
+ let fence: MarkdownFence | null = null
279
285
 
280
286
  for (let index = 0; index < lines.length; index++) {
281
287
  const line = lines[index]!
288
+ if (fence) {
289
+ textBuffer += line
290
+ if (isMarkdownFenceClose({ line, fence })) fence = null
291
+ continue
292
+ }
293
+
294
+ const openingFence = parseMarkdownFenceOpen({ line })
295
+ if (openingFence) {
296
+ fence = openingFence
297
+ textBuffer += line
298
+ continue
299
+ }
300
+
301
+ const singleLineCallout = parseSingleLineCallout({ line })
302
+ if (singleLineCallout) {
303
+ if (textBuffer.length > 0) {
304
+ blocks.push({ type: 'text', text: textBuffer })
305
+ textBuffer = ''
306
+ }
307
+ blocks.push(singleLineCallout)
308
+ continue
309
+ }
310
+
282
311
  const callout = parseCalloutOpenLine({ line })
283
312
  if (!callout) {
284
313
  textBuffer += line
@@ -503,6 +532,52 @@ function collectCalloutBodyFromLines({
503
532
  return new Error('Unclosed <callout> block')
504
533
  }
505
534
 
535
+ function parseMarkdownFenceOpen({
536
+ line,
537
+ }: {
538
+ line: string
539
+ }): MarkdownFence | null {
540
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]
541
+ if (!marker) return null
542
+ return {
543
+ marker: marker.startsWith('`') ? '`' : '~',
544
+ length: marker.length,
545
+ }
546
+ }
547
+
548
+ function isMarkdownFenceClose({
549
+ line,
550
+ fence,
551
+ }: {
552
+ line: string
553
+ fence: MarkdownFence
554
+ }): boolean {
555
+ const marker = line.trim()
556
+ return marker.length >= fence.length &&
557
+ [...marker].every((character) => character === fence.marker)
558
+ }
559
+
560
+ function parseSingleLineCallout({
561
+ line,
562
+ }: {
563
+ line: string
564
+ }): Extract<MarkdownBlock, { type: 'callout' }> | null {
565
+ const match = line.trim().match(/^(<callout(?:\s+[^>]*)?>)(.*?)<\/callout>$/i)
566
+ if (!match) return null
567
+
568
+ const content = match[2]!
569
+ if (/<\/?callout(?:\s|>)/i.test(content)) return null
570
+
571
+ const callout = parseCalloutOpenLine({ line: match[1]! })
572
+ if (!callout) return null
573
+
574
+ return {
575
+ type: 'callout',
576
+ content,
577
+ callout,
578
+ }
579
+ }
580
+
506
581
  function parseCalloutOpenLine({
507
582
  line,
508
583
  }: {
@@ -318,6 +318,34 @@ function waitForClientReady({
318
318
  })
319
319
  }
320
320
 
321
+ function waitForGatewayUnavailable({
322
+ client,
323
+ timeoutMs = 10_000,
324
+ }: {
325
+ client: Client
326
+ timeoutMs?: number
327
+ }): Promise<void> {
328
+ return new Promise<void>((resolve, reject) => {
329
+ let reconnectAttempts = 0
330
+ const timeout = setTimeout(() => {
331
+ client.off(Events.ShardReconnecting, onReconnecting)
332
+ reject(
333
+ new Error(
334
+ `Client did not observe gateway unavailability within ${timeoutMs}ms`,
335
+ ),
336
+ )
337
+ }, timeoutMs)
338
+ const onReconnecting = () => {
339
+ reconnectAttempts += 1
340
+ if (reconnectAttempts < 2) return
341
+ clearTimeout(timeout)
342
+ client.off(Events.ShardReconnecting, onReconnecting)
343
+ resolve()
344
+ }
345
+ client.on(Events.ShardReconnecting, onReconnecting)
346
+ })
347
+ }
348
+
321
349
  function waitForReconnection({
322
350
  client,
323
351
  events,
@@ -336,14 +364,14 @@ function waitForReconnection({
336
364
  resolve(false)
337
365
  }, timeoutMs)
338
366
 
339
- client.on(Events.ShardReady, () => {
367
+ const finish = () => {
340
368
  clearTimeout(timeout)
369
+ client.off(Events.ShardReady, finish)
370
+ client.off(Events.ShardResume, finish)
341
371
  resolve(true)
342
- })
343
- client.on(Events.ShardResume, () => {
344
- clearTimeout(timeout)
345
- resolve(true)
346
- })
372
+ }
373
+ client.on(Events.ShardReady, finish)
374
+ client.on(Events.ShardResume, finish)
347
375
  })
348
376
  }
349
377
 
@@ -419,15 +447,17 @@ describeLocal('gateway-proxy reconnection (local binary)', () => {
419
447
 
420
448
  // Kill proxy
421
449
  const firstProxy = proxyProcess
450
+ const gatewayUnavailable = waitForGatewayUnavailable({ client })
422
451
  await killProxy(proxyProcess)
423
- await new Promise((r) => { setTimeout(r, 1000) })
452
+ await gatewayUnavailable
424
453
 
425
454
  // Restart proxy on same port
455
+ const reconnection = waitForReconnection({ client, events, label: 'local' })
426
456
  proxyProcess = startProxy(proxyOpts)
427
457
  await waitForProxyReady({ port: proxyPort })
428
458
  console.log('[local] Proxy restarted')
429
459
 
430
- const reconnected = await waitForReconnection({ client, events, label: 'local' })
460
+ const reconnected = await reconnection
431
461
  console.log('[local] All events:', events)
432
462
  if (!reconnected) {
433
463
  dumpProxyLogs(firstProxy)
@@ -39,6 +39,7 @@ import {
39
39
  chooseLockPort,
40
40
  cleanupTestSessions,
41
41
  initTestGitRepo,
42
+ getMessageVisibleText,
42
43
  waitForFooterMessage,
43
44
  } from './test-utils.js'
44
45
  import { stopOpencodeServer } from './opencode.js'
@@ -419,12 +420,12 @@ describeIf('gateway-proxy e2e', () => {
419
420
  "--- from: user (proxy-tester)
420
421
  hello from gateway proxy test
421
422
  --- from: assistant (TestBot)
422
- *using deterministic-provider/deterministic-v2*
423
- ⬥ gateway-proxy-reply
424
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
423
+ > *using deterministic-provider/deterministic-v2*
424
+ > gateway-proxy-reply
425
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>"
425
426
  `)
426
427
  expect(reply).toBeDefined()
427
- expect(reply.content.trim().length).toBeGreaterThan(0)
428
+ expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0)
428
429
  },
429
430
  15_000,
430
431
  )
@@ -455,17 +456,17 @@ describeIf('gateway-proxy e2e', () => {
455
456
  "--- from: user (proxy-tester)
456
457
  hello from gateway proxy test
457
458
  --- from: assistant (TestBot)
458
- *using deterministic-provider/deterministic-v2*
459
- ⬥ gateway-proxy-reply
460
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
459
+ > *using deterministic-provider/deterministic-v2*
460
+ > gateway-proxy-reply
461
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
461
462
  --- from: user (proxy-tester)
462
463
  follow up through proxy
463
464
  --- from: assistant (TestBot)
464
- ⬥ gateway-proxy-reply
465
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
465
+ > gateway-proxy-reply
466
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>"
466
467
  `)
467
468
  expect(reply).toBeDefined()
468
- expect(reply.content.trim().length).toBeGreaterThan(0)
469
+ expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0)
469
470
  },
470
471
  15_000,
471
472
  )
@@ -496,14 +497,14 @@ describeIf('gateway-proxy e2e', () => {
496
497
  "--- from: user (proxy-tester)
497
498
  hello from gateway proxy test
498
499
  --- from: assistant (TestBot)
499
- *using deterministic-provider/deterministic-v2*
500
- ⬥ gateway-proxy-reply
501
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
500
+ > *using deterministic-provider/deterministic-v2*
501
+ > gateway-proxy-reply
502
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
502
503
  --- from: user (proxy-tester)
503
504
  follow up through proxy
504
505
  --- from: assistant (TestBot)
505
- ⬥ gateway-proxy-reply
506
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
506
+ > gateway-proxy-reply
507
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@900000000000000001>
507
508
  --- from: user (proxy-tester)
508
509
  !echo proxy-shell-test
509
510
  --- from: assistant (TestBot)
@@ -541,10 +542,10 @@ describeIf('gateway-proxy e2e', () => {
541
542
  "--- from: user (proxy-tester)
542
543
  second message through proxy
543
544
  --- from: assistant (TestBot)
544
- *using deterministic-provider/deterministic-v2*"
545
+ > *using deterministic-provider/deterministic-v2*"
545
546
  `)
546
547
  expect(reply).toBeDefined()
547
- expect(reply.content.trim().length).toBeGreaterThan(0)
548
+ expect(getMessageVisibleText(reply).trim().length).toBeGreaterThan(0)
548
549
  },
549
550
  15_000,
550
551
  )
@@ -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)
@@ -8,6 +8,7 @@
8
8
 
9
9
  import fs from 'node:fs'
10
10
  import path from 'node:path'
11
+ import * as errore from 'errore'
11
12
  import { execAsync } from './exec-async.js'
12
13
 
13
14
  const SUBMODULE_INIT_TIMEOUT_MS = 20 * 60_000
@@ -27,6 +28,13 @@ export type WorktreeLog = {
27
28
  error(message: string): void
28
29
  }
29
30
 
31
+ export const KIMAKI_WORKTREE_ADAPTER_TYPE = 'kimaki-worktree'
32
+
33
+ export class WorktreeIdentityError extends errore.createTaggedError({
34
+ name: 'WorktreeIdentityError',
35
+ message: 'Created worktree does not match the requested checkout: $reason',
36
+ }) {}
37
+
30
38
  const silentLog: WorktreeLog = {
31
39
  info() {},
32
40
  warn() {},
@@ -136,7 +144,15 @@ async function hasSubmoduleGitMetadata(directory: string): Promise<boolean> {
136
144
  * Remove broken submodule stubs created by `git worktree add`.
137
145
  * git worktree creates .git files pointing to incomplete gitdirs (missing HEAD).
138
146
  */
139
- async function removeBrokenSubmoduleStubs(directory: string, log: WorktreeLog): Promise<void> {
147
+ async function removeBrokenSubmoduleStubs({
148
+ directory,
149
+ expectedCommonGitDirectory,
150
+ log,
151
+ }: {
152
+ directory: string
153
+ expectedCommonGitDirectory: string
154
+ log: WorktreeLog
155
+ }): Promise<void> {
140
156
  const submodulePaths = await getSubmodulePaths(directory, log)
141
157
  for (const subPath of submodulePaths) {
142
158
  const fullPath = path.join(directory, subPath)
@@ -150,7 +166,10 @@ async function removeBrokenSubmoduleStubs(directory: string, log: WorktreeLog):
150
166
  const gitdir = path.resolve(fullPath, match[1].trim())
151
167
  const headFile = path.join(gitdir, 'HEAD')
152
168
  const headExists = await fs.promises.access(headFile).then(() => true).catch(() => false)
153
- if (!headExists) {
169
+ const relativeGitdir = path.relative(expectedCommonGitDirectory, gitdir)
170
+ const pointsOutsideExpectedRepository =
171
+ relativeGitdir === '..' || relativeGitdir.startsWith(`..${path.sep}`)
172
+ if (!headExists || pointsOutsideExpectedRepository) {
154
173
  log.info(`Removing broken submodule stub: ${subPath}`)
155
174
  await fs.promises.rm(fullPath, { recursive: true, force: true })
156
175
  }
@@ -235,17 +254,36 @@ async function initializeSubmodulesWithLocalReferences({
235
254
  }
236
255
  }
237
256
 
238
- async function validateSubmodulePointers(directory: string, log: WorktreeLog): Promise<void> {
239
- const submodulePaths = await getSubmodulePaths(directory, log)
257
+ async function validateSubmodulePointers({
258
+ directory,
259
+ expectedCommonGitDirectory,
260
+ log,
261
+ }: {
262
+ directory: string
263
+ expectedCommonGitDirectory: string
264
+ log: WorktreeLog
265
+ }): Promise<void | Error> {
266
+ const submoduleConfigs = await readSubmoduleConfigs(directory)
267
+ if (submoduleConfigs instanceof Error) {
268
+ log.warn(submoduleConfigs.message)
269
+ return
270
+ }
271
+ const submodulePaths = submoduleConfigs.map(({ path: submodulePath }) => {
272
+ return submodulePath
273
+ })
240
274
  if (submodulePaths.length === 0) return
241
275
 
242
276
  const issues: string[] = []
277
+ const foreignGitdirs: string[] = []
243
278
  await Promise.all(
244
279
  submodulePaths.map(async (subPath) => {
245
280
  const gitFile = path.join(directory, subPath, '.git')
246
281
  try {
247
282
  const stat = await fs.promises.stat(gitFile)
248
- if (!stat.isFile()) return
283
+ if (!stat.isFile()) {
284
+ issues.push(`${subPath}: .git is not a file`)
285
+ return
286
+ }
249
287
  const content = await fs.promises.readFile(gitFile, 'utf-8')
250
288
  const match = content.match(/^gitdir:\s*(.+)$/m)
251
289
  const gitdir = match?.[1]?.trim()
@@ -254,19 +292,30 @@ async function validateSubmodulePointers(directory: string, log: WorktreeLog): P
254
292
  return
255
293
  }
256
294
  const resolvedGitdir = path.resolve(path.join(directory, subPath), gitdir)
295
+ const relativeGitdir = path.relative(expectedCommonGitDirectory, resolvedGitdir)
296
+ if (relativeGitdir === '..' || relativeGitdir.startsWith(`..${path.sep}`)) {
297
+ foreignGitdirs.push(`${subPath}: ${resolvedGitdir}`)
298
+ return
299
+ }
257
300
  const headExists = await fs.promises.access(path.join(resolvedGitdir, 'HEAD')).then(() => true).catch(() => false)
258
301
  if (!headExists) {
259
302
  issues.push(`${subPath}: gitdir missing HEAD (${resolvedGitdir})`)
303
+ return
260
304
  }
261
- } catch {
262
- // skip
305
+ } catch (e) {
306
+ issues.push(
307
+ `${subPath}: failed to validate .git (${e instanceof Error ? e.message : String(e)})`,
308
+ )
263
309
  }
264
310
  }),
265
311
  )
266
312
 
267
- if (issues.length > 0) {
268
- log.warn(`Submodule validation issues: ${issues.join('; ')}`)
313
+ if (foreignGitdirs.length > 0) {
314
+ return new Error(
315
+ `Submodule gitdir belongs to another repository: ${foreignGitdirs.join('; ')}`,
316
+ )
269
317
  }
318
+ if (issues.length > 0) log.warn(`Submodule validation issues: ${issues.join('; ')}`)
270
319
  }
271
320
 
272
321
  function detectInstallCommand(directory: string): string | null {
@@ -297,6 +346,120 @@ export type WorktreeResult = {
297
346
  branch: string
298
347
  }
299
348
 
349
+ async function canonicalizePath(value: string) {
350
+ return await fs.promises.realpath(value).catch(() => path.resolve(value))
351
+ }
352
+
353
+ export async function resolveGitCommit({
354
+ directory,
355
+ ref,
356
+ }: {
357
+ directory: string
358
+ ref: string
359
+ }): Promise<string | Error> {
360
+ const result = await execAsync(`git rev-parse --verify ${JSON.stringify(`${ref}^{commit}`)}`, {
361
+ cwd: directory,
362
+ timeout: 10_000,
363
+ }).catch((e) => new Error(`Failed to resolve Git commit ${ref}`, { cause: e }))
364
+ if (result instanceof Error) return result
365
+ const commit = result.stdout.trim()
366
+ if (!/^[0-9a-f]{40}$/i.test(commit)) {
367
+ return new Error(`Git returned an invalid commit for ${ref}: ${commit}`)
368
+ }
369
+ return commit
370
+ }
371
+
372
+ export async function resolveGitCommonDirectory({
373
+ directory,
374
+ }: {
375
+ directory: string
376
+ }): Promise<string | Error> {
377
+ const result = await execAsync('git rev-parse --git-common-dir', {
378
+ cwd: directory,
379
+ timeout: 10_000,
380
+ }).catch(
381
+ (e) => new Error(`Failed to resolve Git common directory for ${directory}`, { cause: e }),
382
+ )
383
+ if (result instanceof Error) return result
384
+ const commonDirectory = path.isAbsolute(result.stdout.trim())
385
+ ? result.stdout.trim()
386
+ : path.resolve(directory, result.stdout.trim())
387
+ return canonicalizePath(commonDirectory)
388
+ }
389
+
390
+ async function resolveGitDirectory({
391
+ directory,
392
+ }: {
393
+ directory: string
394
+ }): Promise<string | Error> {
395
+ const result = await execAsync('git rev-parse --absolute-git-dir', {
396
+ cwd: directory,
397
+ timeout: 10_000,
398
+ }).catch(
399
+ (e) => new Error(`Failed to resolve Git directory for ${directory}`, { cause: e }),
400
+ )
401
+ if (result instanceof Error) return result
402
+ return canonicalizePath(result.stdout.trim())
403
+ }
404
+
405
+ export async function validateWorktreeIdentity({
406
+ projectDirectory,
407
+ worktreeDirectory,
408
+ baseCommit,
409
+ }: {
410
+ projectDirectory: string
411
+ baseCommit: string
412
+ worktreeDirectory: string
413
+ }): Promise<{ commonGitDirectory: string; headCommit: string } | WorktreeIdentityError> {
414
+ const [sourceCommonGitDirectory, commonGitDirectory, gitDirectory, headCommit] =
415
+ await Promise.all([
416
+ resolveGitCommonDirectory({ directory: projectDirectory }),
417
+ resolveGitCommonDirectory({ directory: worktreeDirectory }),
418
+ resolveGitDirectory({ directory: worktreeDirectory }),
419
+ resolveGitCommit({ directory: worktreeDirectory, ref: 'HEAD' }),
420
+ ])
421
+ if (sourceCommonGitDirectory instanceof Error) {
422
+ return new WorktreeIdentityError({
423
+ reason: sourceCommonGitDirectory.message,
424
+ cause: sourceCommonGitDirectory,
425
+ })
426
+ }
427
+ if (commonGitDirectory instanceof Error) {
428
+ return new WorktreeIdentityError({
429
+ reason: commonGitDirectory.message,
430
+ cause: commonGitDirectory,
431
+ })
432
+ }
433
+ if (headCommit instanceof Error) {
434
+ return new WorktreeIdentityError({
435
+ reason: headCommit.message,
436
+ cause: headCommit,
437
+ })
438
+ }
439
+ if (gitDirectory instanceof Error) {
440
+ return new WorktreeIdentityError({
441
+ reason: gitDirectory.message,
442
+ cause: gitDirectory,
443
+ })
444
+ }
445
+ if (commonGitDirectory !== sourceCommonGitDirectory) {
446
+ return new WorktreeIdentityError({
447
+ reason: `expected common directory ${sourceCommonGitDirectory}, received ${commonGitDirectory}`,
448
+ })
449
+ }
450
+ if (gitDirectory === commonGitDirectory) {
451
+ return new WorktreeIdentityError({
452
+ reason: `${worktreeDirectory} is not a linked worktree`,
453
+ })
454
+ }
455
+ if (headCommit !== baseCommit) {
456
+ return new WorktreeIdentityError({
457
+ reason: `expected HEAD ${baseCommit}, received ${headCommit}`,
458
+ })
459
+ }
460
+ return { commonGitDirectory, headCommit }
461
+ }
462
+
300
463
  /**
301
464
  * Create a git worktree with full submodule initialization and dependency install.
302
465
  * Plugin-safe: no config.ts or logger.ts imports. Logging is done via the `log`
@@ -306,24 +469,27 @@ export async function createWorktreeCore({
306
469
  projectDirectory,
307
470
  targetDirectory,
308
471
  branchName,
309
- baseBranch,
472
+ baseCommit,
310
473
  onProgress,
311
474
  log = silentLog,
312
475
  }: {
313
476
  projectDirectory: string
314
477
  targetDirectory: string
315
478
  branchName: string
316
- baseBranch?: string
479
+ baseCommit: string
317
480
  onProgress?: (phase: string) => void
318
481
  log?: WorktreeLog
319
482
  }): Promise<WorktreeResult | Error> {
320
483
  if (fs.existsSync(targetDirectory)) {
321
484
  return new Error(`Worktree directory already exists: ${targetDirectory}`)
322
485
  }
486
+ const commonGitDirectory = await resolveGitCommonDirectory({
487
+ directory: projectDirectory,
488
+ })
489
+ if (commonGitDirectory instanceof Error) return commonGitDirectory
323
490
  await fs.promises.mkdir(path.dirname(targetDirectory), { recursive: true })
324
491
 
325
- const targetRef = baseBranch || 'HEAD'
326
- const createCmd = `git worktree add ${JSON.stringify(targetDirectory)} -B ${JSON.stringify(branchName)} ${JSON.stringify(targetRef)}`
492
+ const createCmd = `git worktree add ${JSON.stringify(targetDirectory)} -B ${JSON.stringify(branchName)} ${JSON.stringify(baseCommit)}`
327
493
  const createResult = await execAsync(createCmd, {
328
494
  cwd: projectDirectory,
329
495
  timeout: SUBMODULE_INIT_TIMEOUT_MS,
@@ -333,7 +499,11 @@ export async function createWorktreeCore({
333
499
  if (createResult instanceof Error) return createResult
334
500
 
335
501
  // Remove broken submodule stubs before init
336
- await removeBrokenSubmoduleStubs(targetDirectory, log)
502
+ await removeBrokenSubmoduleStubs({
503
+ directory: targetDirectory,
504
+ expectedCommonGitDirectory: commonGitDirectory,
505
+ log,
506
+ })
337
507
 
338
508
  // Init submodules with local --reference directories
339
509
  log.info(`Initializing submodules in ${targetDirectory}`)
@@ -348,8 +518,25 @@ export async function createWorktreeCore({
348
518
  log.info(`Submodules initialized in ${targetDirectory}`)
349
519
  }
350
520
 
351
- // Validate submodule pointers (non-fatal)
352
- await validateSubmodulePointers(targetDirectory, log)
521
+ // Invalid submodule pointers can route later Git commands into another clone.
522
+ const submoduleValidation = await validateSubmodulePointers({
523
+ directory: targetDirectory,
524
+ expectedCommonGitDirectory: commonGitDirectory,
525
+ log,
526
+ })
527
+ if (submoduleValidation instanceof Error) {
528
+ const cleanupResult = await removeWorktreeCore({
529
+ projectDirectory,
530
+ worktreeDirectory: targetDirectory,
531
+ branchName,
532
+ })
533
+ if (cleanupResult instanceof Error) {
534
+ return new Error(`${submoduleValidation.message}; cleanup failed: ${cleanupResult.message}`, {
535
+ cause: submoduleValidation,
536
+ })
537
+ }
538
+ return submoduleValidation
539
+ }
353
540
 
354
541
  // Dependency install (non-fatal)
355
542
  onProgress?.('Installing dependencies...')
@@ -381,9 +568,49 @@ export async function removeWorktreeCore({
381
568
  if (removeResult instanceof Error) return removeResult
382
569
 
383
570
  if (branchName) {
384
- await execAsync(
385
- `git branch -D ${JSON.stringify(branchName)}`,
571
+ const listResult = await execAsync(
572
+ `git branch --list --format=${JSON.stringify('%(refname:short)')} ${JSON.stringify(branchName)}`,
386
573
  { cwd: projectDirectory, timeout: 10_000 },
387
- ).catch(() => {/* branch may not exist */})
574
+ ).catch((e) =>
575
+ new Error(`git branch list failed: ${formatCommandError(e)}`, { cause: e }),
576
+ )
577
+ if (listResult instanceof Error) return listResult
578
+ if (listResult.stdout.trim() === branchName) {
579
+ const deleteResult = await execAsync(
580
+ `git branch -D ${JSON.stringify(branchName)}`,
581
+ { cwd: projectDirectory, timeout: 10_000 },
582
+ ).catch((e) =>
583
+ new Error(`git branch delete failed: ${formatCommandError(e)}`, { cause: e }),
584
+ )
585
+ if (deleteResult instanceof Error) return deleteResult
586
+ }
388
587
  }
389
588
  }
589
+
590
+ export async function removeWorktreeFromOwnRepository({
591
+ worktreeDirectory,
592
+ branchName,
593
+ }: {
594
+ worktreeDirectory: string
595
+ branchName: string
596
+ }): Promise<void | Error> {
597
+ if (!fs.existsSync(worktreeDirectory)) return
598
+ const listResult = await execAsync('git worktree list --porcelain', {
599
+ cwd: worktreeDirectory,
600
+ timeout: 10_000,
601
+ }).catch(
602
+ (e) => new Error('Failed to locate the worktree repository', { cause: e }),
603
+ )
604
+ if (listResult instanceof Error) return listResult
605
+ const mainWorktreeLine = listResult.stdout
606
+ .split('\n')
607
+ .find((line) => line.startsWith('worktree '))
608
+ if (!mainWorktreeLine) {
609
+ return new Error('Git returned no main worktree during cleanup')
610
+ }
611
+ return removeWorktreeCore({
612
+ projectDirectory: mainWorktreeLine.slice('worktree '.length),
613
+ worktreeDirectory,
614
+ branchName,
615
+ })
616
+ }