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
@@ -402,7 +402,7 @@ describe('agent model resolution', () => {
402
402
  --- from: assistant (TestBot)
403
403
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
404
404
  ⬥ ok
405
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
405
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
406
406
  `);
407
407
  expect(footerMessage).toBeDefined();
408
408
  if (!footerMessage) {
@@ -446,7 +446,7 @@ describe('agent model resolution', () => {
446
446
  --- from: assistant (TestBot)
447
447
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
448
448
  ⬥ system-context-ok
449
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
449
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
450
450
  `);
451
451
  }, 15_000);
452
452
  test('session.command path includes kimaki system prompt on first message', async () => {
@@ -552,7 +552,7 @@ describe('agent model resolution', () => {
552
552
  --- from: assistant (TestBot)
553
553
  *using deterministic-provider/channel-model-v2*
554
554
  ⬥ ok
555
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
555
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>"
556
556
  `);
557
557
  expect(footerMessage).toBeDefined();
558
558
  if (!footerMessage) {
@@ -565,40 +565,30 @@ describe('agent model resolution', () => {
565
565
  expect(footerMessage.content).toContain(CHANNEL_MODEL);
566
566
  expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
567
567
  }, 15_000);
568
- test('channel model with variant preference completes without error', async () => {
569
- // Clear channel agent so model resolution falls through to channel model
568
+ test('unsupported channel model variant falls back to the selected model', async () => {
570
569
  const db = await getDb();
571
570
  await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
572
- // Set channel model with a variant (thinking level)
573
- // The deterministic provider doesn't support thinking, so the variant
574
- // is resolved but silently dropped (no matching thinking values).
575
- // This test verifies the variant cascade code path runs without crashing
576
- // and the correct model still appears in the footer.
577
571
  await setChannelModel({
578
572
  channelId: TEXT_CHANNEL_ID,
579
573
  modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
580
574
  variant: 'high',
581
575
  });
576
+ const prompt = 'Reply with exactly: variant-check';
582
577
  await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
583
- content: 'Reply with exactly: variant-check',
578
+ content: prompt,
584
579
  });
585
580
  const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
586
581
  timeout: 4_000,
587
- predicate: (t) => {
588
- return t.name === 'Reply with exactly: variant-check';
589
- },
582
+ predicate: (candidate) => candidate.name === prompt,
590
583
  });
591
- await waitForBotMessageContaining({
584
+ const messages = await waitForFooterMessage({
592
585
  discord,
593
586
  threadId: thread.id,
594
- userId: TEST_USER_ID,
595
- text: '*project',
596
587
  timeout: 4_000,
597
588
  });
598
- const messages = await discord.thread(thread.id).getMessages();
599
- const footerMessage = messages.find((message) => {
600
- return (message.author.id === discord.botUserId &&
601
- message.content.startsWith('*'));
589
+ const footer = messages.find((message) => {
590
+ return message.author.id === discord.botUserId
591
+ && message.content.startsWith('*');
602
592
  });
603
593
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
604
594
  "--- from: user (agent-model-tester)
@@ -606,18 +596,10 @@ describe('agent model resolution', () => {
606
596
  --- from: assistant (TestBot)
607
597
  *using deterministic-provider/channel-model-v2*
608
598
  ⬥ ok
609
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
599
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>"
610
600
  `);
611
- expect(footerMessage).toBeDefined();
612
- if (!footerMessage) {
613
- throw new Error(`Expected footer message but none found. Bot messages: ${messages
614
- .filter((m) => m.author.id === discord.botUserId)
615
- .map((m) => m.content.slice(0, 150))
616
- .join(' | ')}`);
617
- }
618
- // Footer should still contain the channel model (variant doesn't crash)
619
- expect(footerMessage.content).toContain(CHANNEL_MODEL);
620
- expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
601
+ expect(footer?.content).toContain(CHANNEL_MODEL);
602
+ expect(footer?.content).not.toContain(DEFAULT_MODEL);
621
603
  }, 15_000);
622
604
  test('/btw fork keeps source session model when channel model differs', async () => {
623
605
  const db = await getDb();
@@ -681,7 +663,7 @@ describe('agent model resolution', () => {
681
663
  Reusing context from <#SOURCE_THREAD> to answer prompt...
682
664
  Reply with exactly: btw-model-check
683
665
  ⬥ ok
684
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
666
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2* <@200000000000000920>"
685
667
  `);
686
668
  expect(forkedSessionModel).toMatchInlineSnapshot(`
687
669
  {
@@ -746,12 +728,12 @@ describe('agent model resolution', () => {
746
728
  --- from: assistant (TestBot)
747
729
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
748
730
  ⬥ ok
749
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
731
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
750
732
  --- from: user (agent-model-tester)
751
733
  Reply with exactly: second-thread-msg
752
734
  --- from: assistant (TestBot)
753
735
  ⬥ ok
754
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
736
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
755
737
  `);
756
738
  const secondMessages = await discord.thread(thread.id).getMessages();
757
739
  const secondFooter = [...secondMessages]
@@ -765,85 +747,6 @@ describe('agent model resolution', () => {
765
747
  expect(secondFooter.content).toContain(AGENT_MODEL);
766
748
  expect(secondFooter.content).not.toContain(PLAN_AGENT_MODEL);
767
749
  }, 20_000);
768
- test('thread created with no agent keeps default model after channel agent is set', async () => {
769
- // Clear any channel agent — thread starts with default (no agent)
770
- const db = await getDb();
771
- await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
772
- // Also clear channel model so we get the pure default
773
- await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
774
- // 1. Send a message to create a thread (no channel agent set)
775
- await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
776
- content: 'Reply with exactly: default-thread-msg',
777
- });
778
- const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
779
- timeout: 4_000,
780
- predicate: (t) => {
781
- return t.name === 'Reply with exactly: default-thread-msg';
782
- },
783
- });
784
- // Wait for footer — should show the default model
785
- await waitForFooterMessage({
786
- discord,
787
- threadId: thread.id,
788
- timeout: 4_000,
789
- afterMessageIncludes: 'ok',
790
- afterAuthorId: discord.botUserId,
791
- });
792
- const firstMessages = await discord.thread(thread.id).getMessages();
793
- const firstFooter = firstMessages.find((m) => {
794
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
795
- });
796
- expect(firstFooter).toBeDefined();
797
- // First run uses the default model (no agent set)
798
- expect(firstFooter.content).toContain(DEFAULT_MODEL);
799
- expect(firstFooter.content).not.toContain(AGENT_MODEL);
800
- // 2. Set channel agent to test-agent via /test-agent-agent in the CHANNEL
801
- const { id: interactionId } = await discord
802
- .channel(TEXT_CHANNEL_ID)
803
- .user(TEST_USER_ID)
804
- .runSlashCommand({ name: 'test-agent-agent' });
805
- await discord
806
- .channel(TEXT_CHANNEL_ID)
807
- .waitForInteractionAck({ interactionId, timeout: 4_000 });
808
- // 3. Send a second message in the EXISTING thread
809
- await discord
810
- .thread(thread.id)
811
- .user(TEST_USER_ID)
812
- .sendMessage({
813
- content: 'Reply with exactly: default-second-msg',
814
- });
815
- await waitForFooterMessage({
816
- discord,
817
- threadId: thread.id,
818
- timeout: 4_000,
819
- afterMessageIncludes: 'default-second-msg',
820
- afterAuthorId: TEST_USER_ID,
821
- });
822
- expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
823
- "--- from: user (agent-model-tester)
824
- Reply with exactly: default-thread-msg
825
- --- from: assistant (TestBot)
826
- *using deterministic-provider/deterministic-v2*
827
- ⬥ ok
828
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
829
- --- from: user (agent-model-tester)
830
- Reply with exactly: default-second-msg
831
- --- from: assistant (TestBot)
832
- ⬥ ok
833
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
834
- `);
835
- const secondMessages = await discord.thread(thread.id).getMessages();
836
- const secondFooter = [...secondMessages]
837
- .reverse()
838
- .find((m) => {
839
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
840
- });
841
- expect(secondFooter).toBeDefined();
842
- // The existing thread should still use the DEFAULT model,
843
- // NOT the test-agent's model (AGENT_MODEL)
844
- expect(secondFooter.content).toContain(DEFAULT_MODEL);
845
- expect(secondFooter.content).not.toContain(AGENT_MODEL);
846
- }, 20_000);
847
750
  test('/plan-agent with prompt starts a session with the plan agent', async () => {
848
751
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
849
752
  const prompt = 'Reply with exactly: inline-plan-agent-msg';
@@ -879,7 +782,7 @@ describe('agent model resolution', () => {
879
782
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
880
783
  *using deterministic-provider/plan-model-v2 ⋅ plan*
881
784
  ⬥ ok
882
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
785
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
883
786
  `);
884
787
  }, 20_000);
885
788
  test('/plan-agent with prompt in an existing thread changes the session agent', async () => {
@@ -924,10 +827,10 @@ describe('agent model resolution', () => {
924
827
  --- from: assistant (TestBot)
925
828
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
926
829
  ⬥ ok
927
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
830
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
928
831
  » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
929
832
  ⬥ ok
930
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
833
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
931
834
  `);
932
835
  }, 20_000);
933
836
  test('/plan-agent inside a thread switches the model for that thread', async () => {
@@ -978,7 +881,7 @@ describe('agent model resolution', () => {
978
881
  --- from: assistant (TestBot)
979
882
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
980
883
  ⬥ ok
981
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
884
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
982
885
  Switched to **plan** agent for this session (was **test-agent**)
983
886
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
984
887
  The agent will change on the next message.
@@ -986,7 +889,7 @@ describe('agent model resolution', () => {
986
889
  Reply with exactly: after-switch-msg
987
890
  --- from: assistant (TestBot)
988
891
  ⬥ ok
989
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
892
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
990
893
  `);
991
894
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
992
895
  .reverse()
@@ -1035,7 +938,7 @@ describe('agent model resolution', () => {
1035
938
  --- from: assistant (TestBot)
1036
939
  *using deterministic-provider/plan-model-v2 ⋅ plan*
1037
940
  ⬥ ok
1038
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***
941
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>
1039
942
  Using **plan** agent for this session
1040
943
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1041
944
  The agent will change on the next message."
@@ -1077,7 +980,7 @@ describe('agent model resolution', () => {
1077
980
  --- from: assistant (TestBot)
1078
981
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1079
982
  ⬥ ok
1080
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
983
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
1081
984
  Switched to **plan** agent for this session (was **test-agent**)
1082
985
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1083
986
  The agent will change on the next message."
@@ -1119,7 +1022,7 @@ describe('agent model resolution', () => {
1119
1022
  --- from: assistant (TestBot)
1120
1023
  *using deterministic-provider/channel-model-v2*
1121
1024
  ⬥ ok
1122
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*
1025
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>
1123
1026
  Switched to **plain** agent for this session
1124
1027
  Model: *deterministic-provider/channel-model-v2* (channel override)
1125
1028
  This model comes from a channel override. Use /model and press Clear override if you want this agent's model.
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  // Helpers for extracting and normalizing Anthropic OAuth account identity.
2
3
  const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer']);
3
4
  const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub'];
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * Anthropic OAuth authentication plugin for OpenCode.
3
4
  *
@@ -1,3 +1,4 @@
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
1
2
  /**
2
3
  * Anthropic OAuth account store and rotation.
3
4
  * Uses shared utilities from oauth-rotation-shared.ts for file locking,
package/dist/bin.js CHANGED
@@ -32,14 +32,57 @@ else {
32
32
  console.error('no subcommand detected. kimaki will automatically restart on crash');
33
33
  console.error();
34
34
  const EXIT_NO_RESTART = 64;
35
+ // Keep in sync with EXIT_TEMPFAIL in cli-runner.ts. Network-down login
36
+ // failures use this so a long outage cannot trip the crash-loop detector.
37
+ const EXIT_TEMPFAIL = 75;
35
38
  const MAX_RAPID_RESTARTS = 5;
36
39
  const RAPID_RESTART_WINDOW_MS = 60_000;
37
40
  const RESTART_DELAY_MS = 2_000;
41
+ const CHILD_EXIT_DEADLINE_MS = 15_000;
38
42
  const restartTimestamps = [];
43
+ let tempFailAttempts = 0;
39
44
  let child = null;
40
45
  // Track when we forwarded a termination signal so we don't restart after graceful shutdown
41
46
  let shutdownRequested = false;
47
+ let forceKillTimer = null;
48
+ let scheduledRestart = null;
49
+ function clearForceKillTimer() {
50
+ if (!forceKillTimer)
51
+ return;
52
+ clearTimeout(forceKillTimer);
53
+ forceKillTimer = null;
54
+ }
55
+ function clearScheduledRestart() {
56
+ if (!scheduledRestart)
57
+ return;
58
+ clearTimeout(scheduledRestart);
59
+ scheduledRestart = null;
60
+ }
61
+ function killChild(signal) {
62
+ const target = child;
63
+ if (!target) {
64
+ if (shutdownRequested) {
65
+ clearScheduledRestart();
66
+ process.exit(0);
67
+ }
68
+ return;
69
+ }
70
+ const sent = target.kill(signal);
71
+ if (!sent || forceKillTimer)
72
+ return;
73
+ forceKillTimer = setTimeout(() => {
74
+ forceKillTimer = null;
75
+ if (child !== target)
76
+ return;
77
+ console.error(`[kimaki] Child did not exit within ${CHILD_EXIT_DEADLINE_MS / 1000}s, force-killing it`);
78
+ target.kill('SIGKILL');
79
+ }, CHILD_EXIT_DEADLINE_MS);
80
+ forceKillTimer.unref();
81
+ }
42
82
  function start() {
83
+ if (shutdownRequested)
84
+ return;
85
+ scheduledRestart = null;
43
86
  if (!fs.existsSync(HEAP_SNAPSHOT_DIR)) {
44
87
  fs.mkdirSync(HEAP_SNAPSHOT_DIR, { recursive: true });
45
88
  }
@@ -48,32 +91,42 @@ else {
48
91
  `--diagnostic-dir=${HEAP_SNAPSHOT_DIR}`,
49
92
  ];
50
93
  const args = [...heapArgs, ...process.execArgv, ...process.argv.slice(1)];
51
- child = spawn(process.argv[0], args, {
94
+ const currentChild = spawn(process.argv[0], args, {
52
95
  stdio: 'inherit',
53
96
  env: { ...process.env, __KIMAKI_CHILD: '1' },
54
97
  });
55
- child.on('exit', (code, signal) => {
98
+ child = currentChild;
99
+ currentChild.on('exit', (code, signal) => {
100
+ if (child === currentChild)
101
+ child = null;
102
+ clearForceKillTimer();
56
103
  if (code === 0 || code === EXIT_NO_RESTART || shutdownRequested) {
57
104
  process.exit(code ?? 0);
58
105
  return;
59
106
  }
60
107
  const now = Date.now();
61
- restartTimestamps.push(now);
62
- while (restartTimestamps.length > 0 &&
63
- restartTimestamps[0] < now - RAPID_RESTART_WINDOW_MS) {
64
- restartTimestamps.shift();
65
- }
66
- if (restartTimestamps.length > MAX_RAPID_RESTARTS) {
67
- console.error(`[kimaki] Crash loop detected (${MAX_RAPID_RESTARTS} crashes in ${RAPID_RESTART_WINDOW_MS / 1000}s), exiting`);
68
- process.exit(1);
69
- return;
108
+ const isTempFail = code === EXIT_TEMPFAIL;
109
+ if (!isTempFail) {
110
+ restartTimestamps.push(now);
111
+ while (restartTimestamps.length > 0 &&
112
+ restartTimestamps[0] < now - RAPID_RESTART_WINDOW_MS) {
113
+ restartTimestamps.shift();
114
+ }
115
+ if (restartTimestamps.length > MAX_RAPID_RESTARTS) {
116
+ console.error(`[kimaki] Crash loop detected (${MAX_RAPID_RESTARTS} crashes in ${RAPID_RESTART_WINDOW_MS / 1000}s), exiting`);
117
+ process.exit(1);
118
+ return;
119
+ }
70
120
  }
71
121
  const reason = signal ? `signal ${signal}` : `code ${code}`;
72
122
  // Progressive backoff: 2s, 4s, 8s, 16s, capped at 30s.
73
123
  // Prevents hammering DNS/gateway during sustained network outages.
74
- const delay = Math.min(RESTART_DELAY_MS * 2 ** (restartTimestamps.length - 1), 30_000);
124
+ const backoffStep = isTempFail
125
+ ? Math.min(tempFailAttempts++, 4)
126
+ : restartTimestamps.length - 1;
127
+ const delay = Math.min(RESTART_DELAY_MS * 2 ** backoffStep, 30_000);
75
128
  console.error(`[kimaki] Process exited with ${reason}, restarting in ${(delay / 1000).toFixed(0)}s...`);
76
- setTimeout(start, delay);
129
+ scheduledRestart = setTimeout(start, delay);
77
130
  });
78
131
  }
79
132
  // Forward signals to child so graceful shutdown and heap snapshots work.
@@ -81,13 +134,14 @@ else {
81
134
  for (const sig of ['SIGTERM', 'SIGINT']) {
82
135
  process.on(sig, () => {
83
136
  shutdownRequested = true;
84
- child?.kill(sig);
85
- });
86
- }
87
- for (const sig of ['SIGUSR1', 'SIGUSR2']) {
88
- process.on(sig, () => {
89
- child?.kill(sig);
137
+ killChild(sig);
90
138
  });
91
139
  }
140
+ process.on('SIGUSR1', () => {
141
+ child?.kill('SIGUSR1');
142
+ });
143
+ process.on('SIGUSR2', () => {
144
+ killChild('SIGUSR2');
145
+ });
92
146
  start();
93
147
  }
@@ -60,13 +60,21 @@ cli
60
60
  }
61
61
  });
62
62
  cli
63
- .command('worktree merge', 'Merge worktree branch into default branch using worktrunk-style pipeline')
63
+ .command('merge-worktree', 'Merge the current worktree into a target branch')
64
64
  .option('-d, --directory <path>', 'Worktree directory (defaults to cwd)')
65
65
  .option('-m, --main-repo <path>', 'Main repository directory (auto-detected from worktree)')
66
66
  .option('-n, --name <name>', 'Worktree/branch name (auto-detected from branch)')
67
+ .option('--strategy [strategy]', z.enum(['rebase', 'squash'])
68
+ .default('rebase')
69
+ .describe('Keep commits or squash them into one'))
70
+ .option('--target-branch [branch]', z.string()
71
+ .optional()
72
+ .describe('Branch to merge into (defaults to origin/HEAD or main)'))
73
+ .option('--thread [threadId]', z.string().optional().describe('Discord thread to mark as merged'))
74
+ .option('--thread-name [name]', z.string().optional().describe('Discord thread name after merging'))
67
75
  .action(async (options) => {
68
76
  try {
69
- const { mergeWorktree } = await import('../worktrees.js');
77
+ const { formatMergeWorktreeError, mergeWorktree } = await import('../worktrees.js');
70
78
  const worktreeDir = path.resolve(options.directory || '.');
71
79
  // Auto-detect main repo: find the main worktree's toplevel.
72
80
  // For linked worktrees, --git-common-dir points to the shared .git,
@@ -77,14 +85,20 @@ cli
77
85
  if (!mainRepoDir) {
78
86
  try {
79
87
  // `git worktree list --porcelain` first line is always the main worktree
80
- const { stdout } = await execAsync(`git -C "${worktreeDir}" worktree list --porcelain`);
88
+ const { stdout } = await execAsync({
89
+ command: 'git',
90
+ args: ['-C', worktreeDir, 'worktree', 'list', '--porcelain'],
91
+ });
81
92
  const firstLine = stdout.split('\n')[0] || '';
82
93
  // Format: "worktree /path/to/main"
83
94
  mainRepoDir = firstLine.replace(/^worktree\s+/, '').trim();
84
95
  }
85
96
  catch {
86
97
  // Fallback: derive from git common dir
87
- const { stdout: commonDir } = await execAsync(`git -C "${worktreeDir}" rev-parse --git-common-dir`);
98
+ const { stdout: commonDir } = await execAsync({
99
+ command: 'git',
100
+ args: ['-C', worktreeDir, 'rev-parse', '--git-common-dir'],
101
+ });
88
102
  const resolved = path.isAbsolute(commonDir.trim())
89
103
  ? commonDir.trim()
90
104
  : path.resolve(worktreeDir, commonDir.trim());
@@ -95,7 +109,10 @@ cli
95
109
  let worktreeName = options.name;
96
110
  if (!worktreeName) {
97
111
  try {
98
- const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
112
+ const { stdout } = await execAsync({
113
+ command: 'git',
114
+ args: ['-C', worktreeDir, 'symbolic-ref', '--short', 'HEAD'],
115
+ });
99
116
  worktreeName = stdout.trim();
100
117
  }
101
118
  catch {
@@ -110,18 +127,40 @@ cli
110
127
  worktreeDir,
111
128
  mainRepoDir,
112
129
  worktreeName,
130
+ strategy: options.strategy,
131
+ targetBranch: options.targetBranch,
113
132
  onProgress: (msg) => {
114
133
  cliLogger.log(msg);
115
134
  },
116
135
  });
117
136
  if (result instanceof Error) {
118
- cliLogger.error(`Merge failed: ${result.message}`);
137
+ cliLogger.error(formatMergeWorktreeError(result, { maxLength: 8000 }));
119
138
  if (result instanceof RebaseConflictError) {
120
139
  cliLogger.log('Resolve the rebase conflicts, then run this command again.');
121
140
  }
122
141
  process.exit(1);
123
142
  }
124
- cliLogger.log(`Merged ${result.branchName} into ${result.defaultBranch} @ ${result.shortSha} (${result.commitCount} commit${result.commitCount === 1 ? '' : 's'})`);
143
+ const action = options.strategy === 'squash' ? 'Squashed' : 'Merged';
144
+ cliLogger.log(`${action} ${result.branchName} into ${result.defaultBranch} @ ${result.shortSha} (${result.commitCount} source commit${result.commitCount === 1 ? '' : 's'})`);
145
+ if (options.thread && options.threadName) {
146
+ const credentials = await resolveBotCredentials().catch((e) => {
147
+ return new Error('Failed to resolve Discord credentials', { cause: e });
148
+ });
149
+ if (credentials instanceof Error) {
150
+ cliLogger.warn(`Could not update the thread title: ${credentials.message}`);
151
+ }
152
+ else {
153
+ const rest = createDiscordRest(credentials.token);
154
+ const renameResult = await rest
155
+ .patch(Routes.channel(options.thread), {
156
+ body: { name: options.threadName },
157
+ })
158
+ .catch((e) => new Error('Discord rejected the thread rename', { cause: e }));
159
+ if (renameResult instanceof Error) {
160
+ cliLogger.warn(`Could not update the thread title: ${renameResult.message}`);
161
+ }
162
+ }
163
+ }
125
164
  process.exit(0);
126
165
  }
127
166
  catch (error) {
@@ -29,6 +29,7 @@ import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '
29
29
  import { upgrade, getCurrentVersion } from '../upgrade.js';
30
30
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
31
31
  import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
32
+ import { validateCliModelOption } from '../session-handler/model-utils.js';
32
33
  const cliLogger = createLogger(LogPrefix.CLI);
33
34
  const cli = goke();
34
35
  cli
@@ -89,6 +90,13 @@ cli
89
90
  cliLogger.error('Prompt is required. Use --prompt <prompt>');
90
91
  process.exit(EXIT_NO_RESTART);
91
92
  }
93
+ const earlyModelCheck = await validateCliModelOption({
94
+ model: options.model,
95
+ });
96
+ if (earlyModelCheck instanceof Error) {
97
+ cliLogger.error(earlyModelCheck.message);
98
+ process.exit(EXIT_NO_RESTART);
99
+ }
92
100
  const filePaths = options.file?.length
93
101
  ? options.file.map((f) => path.resolve(f))
94
102
  : undefined;
@@ -335,6 +343,14 @@ cli
335
343
  // We only require it for features that genuinely need a local directory
336
344
  // (scheduled tasks and --wait).
337
345
  const channelConfig = await getChannelDirectory(threadData.parent_id);
346
+ const threadModelCheck = await validateCliModelOption({
347
+ model: options.model,
348
+ directory: channelConfig?.directory,
349
+ });
350
+ if (threadModelCheck instanceof Error) {
351
+ cliLogger.error(threadModelCheck.message);
352
+ process.exit(EXIT_NO_RESTART);
353
+ }
338
354
  // Guard early: fail before sending the message if a feature that
339
355
  // needs local project directory mapping is requested.
340
356
  if (!channelConfig && (parsedSchedule || options.wait)) {
@@ -446,6 +462,14 @@ cli
446
462
  // (--send-at, --wait, --cwd).
447
463
  const channelConfig = await getChannelDirectory(channelData.id);
448
464
  const projectDirectory = channelConfig?.directory;
465
+ const channelModelCheck = await validateCliModelOption({
466
+ model: options.model,
467
+ directory: projectDirectory,
468
+ });
469
+ if (channelModelCheck instanceof Error) {
470
+ cliLogger.error(channelModelCheck.message);
471
+ process.exit(EXIT_NO_RESTART);
472
+ }
449
473
  // Features that require a local project directory mapping
450
474
  const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd);
451
475
  if (!channelConfig && needsProjectDirectory) {