kimaki 0.25.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 (223) hide show
  1. package/dist/agent-model.e2e.test.js +157 -122
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/anthropic-account-identity.js +1 -0
  4. package/dist/anthropic-auth-plugin.js +1 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bin.js +73 -19
  7. package/dist/cli-commands/maintenance.js +46 -7
  8. package/dist/cli-commands/send.js +37 -3
  9. package/dist/cli-commands/session.js +183 -8
  10. package/dist/cli-commands/task.js +39 -8
  11. package/dist/cli-parsing.test.js +59 -3
  12. package/dist/cli-runner.js +94 -74
  13. package/dist/cli-runner.test.js +37 -1
  14. package/dist/cli-send-thread.e2e.test.js +1 -1
  15. package/dist/cli.js +32 -0
  16. package/dist/commands/abort.js +5 -1
  17. package/dist/commands/agent.js +93 -82
  18. package/dist/commands/ask-question.js +21 -32
  19. package/dist/commands/ask-question.test.js +43 -1
  20. package/dist/commands/login.js +6 -0
  21. package/dist/commands/merge-worktree.js +36 -10
  22. package/dist/commands/model-variant.js +7 -1
  23. package/dist/commands/model.js +32 -8
  24. package/dist/commands/multioauth.js +170 -1
  25. package/dist/commands/new-worktree.js +170 -102
  26. package/dist/commands/tasks.js +6 -0
  27. package/dist/commands/unset-model.js +5 -20
  28. package/dist/commands/user-command.js +6 -8
  29. package/dist/commands/worktrees.js +5 -13
  30. package/dist/config.js +16 -1
  31. package/dist/database.js +189 -3
  32. package/dist/db.js +22 -0
  33. package/dist/db.test.js +167 -1
  34. package/dist/discord-bot.js +41 -33
  35. package/dist/discord-command-registration.js +9 -1
  36. package/dist/discord-utils.js +13 -5
  37. package/dist/discord-utils.test.js +17 -1
  38. package/dist/errors.js +5 -0
  39. package/dist/exec-async.js +17 -5
  40. package/dist/external-directory-permissions.e2e.test.js +2 -2
  41. package/dist/file-edit-log.js +197 -0
  42. package/dist/file-edit-log.test.js +258 -0
  43. package/dist/format-tables.js +52 -0
  44. package/dist/format-tables.test.js +93 -0
  45. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  46. package/dist/gateway-proxy.e2e.test.js +5 -5
  47. package/dist/git-worktree-core.js +182 -17
  48. package/dist/ipc-tools-plugin.js +67 -6
  49. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  50. package/dist/kimaki-opencode-plugin.js +9 -3
  51. package/dist/kimaki-workspace-adaptor.js +33 -10
  52. package/dist/logger.js +10 -1
  53. package/dist/markdown.js +3 -1
  54. package/dist/markdown.test.js +11 -3
  55. package/dist/message-formatting.js +95 -5
  56. package/dist/message-formatting.test.js +207 -2
  57. package/dist/oauth-rotation-shared.js +17 -1
  58. package/dist/openai-auth-plugin.js +1 -0
  59. package/dist/openai-auth-state.js +21 -0
  60. package/dist/opencode-command.test.js +60 -0
  61. package/dist/opencode.js +53 -12
  62. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  63. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  64. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  65. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  66. package/dist/queue-advanced-e2e-setup.js +78 -5
  67. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  68. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  69. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  70. package/dist/queue-advanced-question.e2e.test.js +31 -26
  71. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  72. package/dist/queue-delete-message.e2e.test.js +1 -1
  73. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  74. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  75. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  76. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  77. package/dist/schema.js +53 -0
  78. package/dist/session-handler/event-stream-state.js +364 -0
  79. package/dist/session-handler/event-stream-state.test.js +844 -1
  80. package/dist/session-handler/model-utils.js +159 -5
  81. package/dist/session-handler/model-utils.test.js +345 -0
  82. package/dist/session-handler/thread-session-runtime.js +288 -165
  83. package/dist/session-sleep.test.js +224 -0
  84. package/dist/session-title-rename.test.js +7 -39
  85. package/dist/sleep.e2e.test.js +491 -0
  86. package/dist/startup-time.e2e.test.js +2 -1
  87. package/dist/store.js +3 -0
  88. package/dist/system-message.js +76 -9
  89. package/dist/system-message.test.js +103 -9
  90. package/dist/task-runner.js +235 -14
  91. package/dist/task-schedule.js +68 -0
  92. package/dist/task-schedule.test.js +219 -2
  93. package/dist/thread-message-queue.e2e.test.js +10 -169
  94. package/dist/undo-redo.e2e.test.js +2 -2
  95. package/dist/voice-attachment.js +23 -1
  96. package/dist/voice-message.e2e.test.js +10 -10
  97. package/dist/voice.test.js +55 -29
  98. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  99. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  100. package/dist/worktrees.js +226 -443
  101. package/dist/worktrees.test.js +435 -58
  102. package/dist/xai-auth-plugin.js +238 -0
  103. package/dist/xai-auth-state.js +234 -0
  104. package/dist/xai-auth-state.test.js +183 -0
  105. package/package.json +9 -7
  106. package/skills/errore/SKILL.md +18 -0
  107. package/skills/goke/SKILL.md +150 -10
  108. package/skills/holocron/SKILL.md +37 -0
  109. package/skills/npm-package/SKILL.md +55 -35
  110. package/skills/profano/SKILL.md +20 -3
  111. package/skills/spiceflow/SKILL.md +63 -4
  112. package/skills/strada/SKILL.md +39 -0
  113. package/skills/zele/SKILL.md +19 -0
  114. package/src/agent-model.e2e.test.ts +194 -152
  115. package/src/analytics.test.ts +30 -1
  116. package/src/analytics.ts +1 -0
  117. package/src/anthropic-account-identity.ts +1 -0
  118. package/src/anthropic-auth-plugin.ts +1 -0
  119. package/src/anthropic-auth-state.ts +1 -0
  120. package/src/bin.ts +78 -26
  121. package/src/cli-commands/maintenance.ts +59 -13
  122. package/src/cli-commands/send.ts +47 -3
  123. package/src/cli-commands/session.ts +203 -9
  124. package/src/cli-commands/task.ts +45 -20
  125. package/src/cli-parsing.test.ts +69 -3
  126. package/src/cli-runner.test.ts +65 -1
  127. package/src/cli-runner.ts +116 -93
  128. package/src/cli-send-thread.e2e.test.ts +1 -1
  129. package/src/cli.ts +53 -0
  130. package/src/commands/abort.ts +6 -1
  131. package/src/commands/agent.ts +126 -90
  132. package/src/commands/ask-question.test.ts +48 -1
  133. package/src/commands/ask-question.ts +21 -32
  134. package/src/commands/login.ts +6 -0
  135. package/src/commands/merge-worktree.ts +43 -12
  136. package/src/commands/model-variant.ts +8 -1
  137. package/src/commands/model.ts +43 -8
  138. package/src/commands/multioauth.ts +196 -2
  139. package/src/commands/new-worktree.ts +138 -55
  140. package/src/commands/tasks.ts +6 -0
  141. package/src/commands/unset-model.ts +5 -21
  142. package/src/commands/user-command.ts +6 -6
  143. package/src/commands/worktrees.ts +5 -12
  144. package/src/config.ts +18 -1
  145. package/src/database.ts +319 -3
  146. package/src/db.test.ts +188 -0
  147. package/src/db.ts +26 -0
  148. package/src/discord-bot.ts +42 -30
  149. package/src/discord-command-registration.ts +15 -1
  150. package/src/discord-utils.test.ts +19 -0
  151. package/src/discord-utils.ts +21 -6
  152. package/src/errors.ts +5 -0
  153. package/src/exec-async.ts +20 -11
  154. package/src/external-directory-permissions.e2e.test.ts +2 -2
  155. package/src/file-edit-log.test.ts +287 -0
  156. package/src/file-edit-log.ts +283 -0
  157. package/src/format-tables.test.ts +101 -0
  158. package/src/format-tables.ts +75 -0
  159. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  160. package/src/gateway-proxy.e2e.test.ts +5 -5
  161. package/src/git-worktree-core.ts +247 -20
  162. package/src/ipc-tools-plugin.ts +79 -8
  163. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  164. package/src/kimaki-opencode-plugin.ts +9 -3
  165. package/src/kimaki-workspace-adaptor.ts +40 -11
  166. package/src/logger.ts +13 -4
  167. package/src/markdown.test.ts +13 -3
  168. package/src/markdown.ts +4 -1
  169. package/src/message-formatting.test.ts +271 -2
  170. package/src/message-formatting.ts +110 -5
  171. package/src/oauth-rotation-shared.ts +16 -1
  172. package/src/openai-auth-plugin.ts +1 -0
  173. package/src/openai-auth-state.ts +20 -0
  174. package/src/opencode-command.test.ts +81 -0
  175. package/src/opencode.ts +71 -11
  176. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  177. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  178. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  179. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  180. package/src/queue-advanced-e2e-setup.ts +83 -4
  181. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  182. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  183. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  184. package/src/queue-advanced-question.e2e.test.ts +35 -29
  185. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  186. package/src/queue-delete-message.e2e.test.ts +1 -1
  187. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  188. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  189. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  190. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  191. package/src/schema.sql +29 -1
  192. package/src/schema.ts +56 -0
  193. package/src/session-handler/event-stream-state.test.ts +920 -0
  194. package/src/session-handler/event-stream-state.ts +546 -0
  195. package/src/session-handler/model-utils.test.ts +415 -0
  196. package/src/session-handler/model-utils.ts +250 -5
  197. package/src/session-handler/thread-session-runtime.ts +346 -200
  198. package/src/session-sleep.test.ts +294 -0
  199. package/src/session-title-rename.test.ts +9 -48
  200. package/src/sleep.e2e.test.ts +562 -0
  201. package/src/startup-time.e2e.test.ts +5 -1
  202. package/src/store.ts +21 -0
  203. package/src/system-message.test.ts +114 -9
  204. package/src/system-message.ts +85 -10
  205. package/src/task-runner.ts +304 -13
  206. package/src/task-schedule.test.ts +248 -2
  207. package/src/task-schedule.ts +118 -2
  208. package/src/thread-message-queue.e2e.test.ts +10 -202
  209. package/src/undo-redo.e2e.test.ts +2 -2
  210. package/src/voice-attachment.ts +29 -1
  211. package/src/voice-message.e2e.test.ts +10 -10
  212. package/src/voice.test.ts +63 -29
  213. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  214. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  215. package/src/worktrees.test.ts +505 -64
  216. package/src/worktrees.ts +257 -603
  217. package/src/xai-auth-plugin.ts +303 -0
  218. package/src/xai-auth-state.test.ts +224 -0
  219. package/src/xai-auth-state.ts +294 -0
  220. package/dist/message-finish-field.e2e.test.js +0 -169
  221. package/dist/subagent-rate-limit-plugin.js +0 -173
  222. package/src/message-finish-field.e2e.test.ts +0 -196
  223. package/src/subagent-rate-limit-plugin.ts +0 -218
@@ -189,7 +189,7 @@ function createAgentFile({ projectDirectory, agentName, model, }) {
189
189
  fs.mkdirSync(agentDir, { recursive: true });
190
190
  const content = [
191
191
  '---',
192
- `model: ${model}`,
192
+ ...(model ? [`model: ${model}`] : []),
193
193
  'mode: primary',
194
194
  `description: Test agent with custom model`,
195
195
  '---',
@@ -289,6 +289,10 @@ describe('agent model resolution', () => {
289
289
  agentName: 'plan',
290
290
  model: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
291
291
  });
292
+ createAgentFile({
293
+ projectDirectory: directories.projectDirectory,
294
+ agentName: 'plain',
295
+ });
292
296
  const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
293
297
  const hranaResult = await startHranaServer({ dbPath });
294
298
  if (hranaResult instanceof Error) {
@@ -311,7 +315,7 @@ describe('agent model resolution', () => {
311
315
  });
312
316
  // Register quick agent slash commands so /plan-agent and /test-agent-agent
313
317
  // are resolvable by handleQuickAgentCommand via guild.commands.fetch().
314
- const agentCommands = ['test-agent', 'plan'].map((agentName) => {
318
+ const agentCommands = ['test-agent', 'plan', 'plain'].map((agentName) => {
315
319
  return new SlashCommandBuilder()
316
320
  .setName(`${agentName}-agent`)
317
321
  .setDescription(buildQuickAgentCommandDescription({
@@ -398,7 +402,7 @@ describe('agent model resolution', () => {
398
402
  --- from: assistant (TestBot)
399
403
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
400
404
  ⬥ ok
401
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
405
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
402
406
  `);
403
407
  expect(footerMessage).toBeDefined();
404
408
  if (!footerMessage) {
@@ -442,7 +446,7 @@ describe('agent model resolution', () => {
442
446
  --- from: assistant (TestBot)
443
447
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
444
448
  ⬥ system-context-ok
445
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
449
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
446
450
  `);
447
451
  }, 15_000);
448
452
  test('session.command path includes kimaki system prompt on first message', async () => {
@@ -548,7 +552,7 @@ describe('agent model resolution', () => {
548
552
  --- from: assistant (TestBot)
549
553
  *using deterministic-provider/channel-model-v2*
550
554
  ⬥ ok
551
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
555
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>"
552
556
  `);
553
557
  expect(footerMessage).toBeDefined();
554
558
  if (!footerMessage) {
@@ -561,40 +565,30 @@ describe('agent model resolution', () => {
561
565
  expect(footerMessage.content).toContain(CHANNEL_MODEL);
562
566
  expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
563
567
  }, 15_000);
564
- test('channel model with variant preference completes without error', async () => {
565
- // Clear channel agent so model resolution falls through to channel model
568
+ test('unsupported channel model variant falls back to the selected model', async () => {
566
569
  const db = await getDb();
567
570
  await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
568
- // Set channel model with a variant (thinking level)
569
- // The deterministic provider doesn't support thinking, so the variant
570
- // is resolved but silently dropped (no matching thinking values).
571
- // This test verifies the variant cascade code path runs without crashing
572
- // and the correct model still appears in the footer.
573
571
  await setChannelModel({
574
572
  channelId: TEXT_CHANNEL_ID,
575
573
  modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
576
574
  variant: 'high',
577
575
  });
576
+ const prompt = 'Reply with exactly: variant-check';
578
577
  await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
579
- content: 'Reply with exactly: variant-check',
578
+ content: prompt,
580
579
  });
581
580
  const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
582
581
  timeout: 4_000,
583
- predicate: (t) => {
584
- return t.name === 'Reply with exactly: variant-check';
585
- },
582
+ predicate: (candidate) => candidate.name === prompt,
586
583
  });
587
- await waitForBotMessageContaining({
584
+ const messages = await waitForFooterMessage({
588
585
  discord,
589
586
  threadId: thread.id,
590
- userId: TEST_USER_ID,
591
- text: '*project',
592
587
  timeout: 4_000,
593
588
  });
594
- const messages = await discord.thread(thread.id).getMessages();
595
- const footerMessage = messages.find((message) => {
596
- return (message.author.id === discord.botUserId &&
597
- message.content.startsWith('*'));
589
+ const footer = messages.find((message) => {
590
+ return message.author.id === discord.botUserId
591
+ && message.content.startsWith('*');
598
592
  });
599
593
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
600
594
  "--- from: user (agent-model-tester)
@@ -602,18 +596,10 @@ describe('agent model resolution', () => {
602
596
  --- from: assistant (TestBot)
603
597
  *using deterministic-provider/channel-model-v2*
604
598
  ⬥ ok
605
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
599
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>"
606
600
  `);
607
- expect(footerMessage).toBeDefined();
608
- if (!footerMessage) {
609
- throw new Error(`Expected footer message but none found. Bot messages: ${messages
610
- .filter((m) => m.author.id === discord.botUserId)
611
- .map((m) => m.content.slice(0, 150))
612
- .join(' | ')}`);
613
- }
614
- // Footer should still contain the channel model (variant doesn't crash)
615
- expect(footerMessage.content).toContain(CHANNEL_MODEL);
616
- expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
601
+ expect(footer?.content).toContain(CHANNEL_MODEL);
602
+ expect(footer?.content).not.toContain(DEFAULT_MODEL);
617
603
  }, 15_000);
618
604
  test('/btw fork keeps source session model when channel model differs', async () => {
619
605
  const db = await getDb();
@@ -677,7 +663,7 @@ describe('agent model resolution', () => {
677
663
  Reusing context from <#SOURCE_THREAD> to answer prompt...
678
664
  Reply with exactly: btw-model-check
679
665
  ⬥ ok
680
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
666
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2* <@200000000000000920>"
681
667
  `);
682
668
  expect(forkedSessionModel).toMatchInlineSnapshot(`
683
669
  {
@@ -742,12 +728,12 @@ describe('agent model resolution', () => {
742
728
  --- from: assistant (TestBot)
743
729
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
744
730
  ⬥ ok
745
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
731
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
746
732
  --- from: user (agent-model-tester)
747
733
  Reply with exactly: second-thread-msg
748
734
  --- from: assistant (TestBot)
749
735
  ⬥ ok
750
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
736
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
751
737
  `);
752
738
  const secondMessages = await discord.thread(thread.id).getMessages();
753
739
  const secondFooter = [...secondMessages]
@@ -761,85 +747,6 @@ describe('agent model resolution', () => {
761
747
  expect(secondFooter.content).toContain(AGENT_MODEL);
762
748
  expect(secondFooter.content).not.toContain(PLAN_AGENT_MODEL);
763
749
  }, 20_000);
764
- test('thread created with no agent keeps default model after channel agent is set', async () => {
765
- // Clear any channel agent — thread starts with default (no agent)
766
- const db = await getDb();
767
- await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
768
- // Also clear channel model so we get the pure default
769
- await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
770
- // 1. Send a message to create a thread (no channel agent set)
771
- await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
772
- content: 'Reply with exactly: default-thread-msg',
773
- });
774
- const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
775
- timeout: 4_000,
776
- predicate: (t) => {
777
- return t.name === 'Reply with exactly: default-thread-msg';
778
- },
779
- });
780
- // Wait for footer — should show the default model
781
- await waitForFooterMessage({
782
- discord,
783
- threadId: thread.id,
784
- timeout: 4_000,
785
- afterMessageIncludes: 'ok',
786
- afterAuthorId: discord.botUserId,
787
- });
788
- const firstMessages = await discord.thread(thread.id).getMessages();
789
- const firstFooter = firstMessages.find((m) => {
790
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
791
- });
792
- expect(firstFooter).toBeDefined();
793
- // First run uses the default model (no agent set)
794
- expect(firstFooter.content).toContain(DEFAULT_MODEL);
795
- expect(firstFooter.content).not.toContain(AGENT_MODEL);
796
- // 2. Set channel agent to test-agent via /test-agent-agent in the CHANNEL
797
- const { id: interactionId } = await discord
798
- .channel(TEXT_CHANNEL_ID)
799
- .user(TEST_USER_ID)
800
- .runSlashCommand({ name: 'test-agent-agent' });
801
- await discord
802
- .channel(TEXT_CHANNEL_ID)
803
- .waitForInteractionAck({ interactionId, timeout: 4_000 });
804
- // 3. Send a second message in the EXISTING thread
805
- await discord
806
- .thread(thread.id)
807
- .user(TEST_USER_ID)
808
- .sendMessage({
809
- content: 'Reply with exactly: default-second-msg',
810
- });
811
- await waitForFooterMessage({
812
- discord,
813
- threadId: thread.id,
814
- timeout: 4_000,
815
- afterMessageIncludes: 'default-second-msg',
816
- afterAuthorId: TEST_USER_ID,
817
- });
818
- expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
819
- "--- from: user (agent-model-tester)
820
- Reply with exactly: default-thread-msg
821
- --- from: assistant (TestBot)
822
- *using deterministic-provider/deterministic-v2*
823
- ⬥ ok
824
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
825
- --- from: user (agent-model-tester)
826
- Reply with exactly: default-second-msg
827
- --- from: assistant (TestBot)
828
- ⬥ ok
829
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
830
- `);
831
- const secondMessages = await discord.thread(thread.id).getMessages();
832
- const secondFooter = [...secondMessages]
833
- .reverse()
834
- .find((m) => {
835
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
836
- });
837
- expect(secondFooter).toBeDefined();
838
- // The existing thread should still use the DEFAULT model,
839
- // NOT the test-agent's model (AGENT_MODEL)
840
- expect(secondFooter.content).toContain(DEFAULT_MODEL);
841
- expect(secondFooter.content).not.toContain(AGENT_MODEL);
842
- }, 20_000);
843
750
  test('/plan-agent with prompt starts a session with the plan agent', async () => {
844
751
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
845
752
  const prompt = 'Reply with exactly: inline-plan-agent-msg';
@@ -875,7 +782,7 @@ describe('agent model resolution', () => {
875
782
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
876
783
  *using deterministic-provider/plan-model-v2 ⋅ plan*
877
784
  ⬥ ok
878
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
785
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
879
786
  `);
880
787
  }, 20_000);
881
788
  test('/plan-agent with prompt in an existing thread changes the session agent', async () => {
@@ -920,10 +827,10 @@ describe('agent model resolution', () => {
920
827
  --- from: assistant (TestBot)
921
828
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
922
829
  ⬥ ok
923
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
830
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
924
831
  » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
925
832
  ⬥ ok
926
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
833
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
927
834
  `);
928
835
  }, 20_000);
929
836
  test('/plan-agent inside a thread switches the model for that thread', async () => {
@@ -974,15 +881,15 @@ describe('agent model resolution', () => {
974
881
  --- from: assistant (TestBot)
975
882
  *using deterministic-provider/agent-model-v2 ⋅ test-agent*
976
883
  ⬥ ok
977
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
884
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
978
885
  Switched to **plan** agent for this session (was **test-agent**)
979
- Model: *deterministic-provider/plan-model-v2*
886
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
980
887
  The agent will change on the next message.
981
888
  --- from: user (agent-model-tester)
982
889
  Reply with exactly: after-switch-msg
983
890
  --- from: assistant (TestBot)
984
891
  ⬥ ok
985
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
892
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
986
893
  `);
987
894
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
988
895
  .reverse()
@@ -994,4 +901,132 @@ describe('agent model resolution', () => {
994
901
  expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
995
902
  expect(secondFooter.content).not.toContain(AGENT_MODEL);
996
903
  }, 20_000);
904
+ test('/plan-agent on the same agent refreshes a stale session model', async () => {
905
+ await setChannelAgent(TEXT_CHANNEL_ID, 'plan');
906
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
907
+ content: 'Reply with exactly: refresh-agent-model-msg',
908
+ });
909
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
910
+ timeout: 4_000,
911
+ predicate: (t) => {
912
+ return t.name === 'Reply with exactly: refresh-agent-model-msg';
913
+ },
914
+ });
915
+ await waitForFooterMessage({
916
+ discord,
917
+ threadId: thread.id,
918
+ timeout: 4_000,
919
+ afterMessageIncludes: 'ok',
920
+ afterAuthorId: discord.botUserId,
921
+ });
922
+ const sessionId = await getThreadSession(thread.id);
923
+ expect(sessionId).toBeDefined();
924
+ if (!sessionId)
925
+ throw new Error('Expected session');
926
+ await setSessionModel({
927
+ sessionId,
928
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
929
+ });
930
+ const th = discord.thread(thread.id);
931
+ const { id: interactionId } = await th
932
+ .user(TEST_USER_ID)
933
+ .runSlashCommand({ name: 'plan-agent' });
934
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
935
+ expect(await th.text()).toMatchInlineSnapshot(`
936
+ "--- from: user (agent-model-tester)
937
+ Reply with exactly: refresh-agent-model-msg
938
+ --- from: assistant (TestBot)
939
+ *using deterministic-provider/plan-model-v2 ⋅ plan*
940
+ ⬥ ok
941
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>
942
+ Using **plan** agent for this session
943
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
944
+ The agent will change on the next message."
945
+ `);
946
+ expect(await th.text()).not.toContain('Already using');
947
+ expect(await getSessionModel(sessionId)).toBeUndefined();
948
+ }, 20_000);
949
+ test('/plan-agent shows the agent model when a channel model is also set', async () => {
950
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
951
+ await setChannelModel({
952
+ channelId: TEXT_CHANNEL_ID,
953
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
954
+ });
955
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
956
+ content: 'Reply with exactly: channel-vs-agent-msg',
957
+ });
958
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
959
+ timeout: 4_000,
960
+ predicate: (t) => {
961
+ return t.name === 'Reply with exactly: channel-vs-agent-msg';
962
+ },
963
+ });
964
+ await waitForFooterMessage({
965
+ discord,
966
+ threadId: thread.id,
967
+ timeout: 4_000,
968
+ afterMessageIncludes: 'ok',
969
+ afterAuthorId: discord.botUserId,
970
+ });
971
+ const th = discord.thread(thread.id);
972
+ const { id: interactionId } = await th
973
+ .user(TEST_USER_ID)
974
+ .runSlashCommand({ name: 'plan-agent' });
975
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
976
+ const threadText = await th.text();
977
+ expect(threadText).toMatchInlineSnapshot(`
978
+ "--- from: user (agent-model-tester)
979
+ Reply with exactly: channel-vs-agent-msg
980
+ --- from: assistant (TestBot)
981
+ *using deterministic-provider/agent-model-v2 ⋅ test-agent*
982
+ ⬥ ok
983
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
984
+ Switched to **plan** agent for this session (was **test-agent**)
985
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
986
+ The agent will change on the next message."
987
+ `);
988
+ expect(threadText).toContain(`Model: *${PROVIDER_NAME}/${PLAN_AGENT_MODEL}* (agent "plan")`);
989
+ expect(threadText).not.toContain('channel override');
990
+ }, 20_000);
991
+ test('/plain-agent tells the user to clear a channel model override', async () => {
992
+ const db = await getDb();
993
+ await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
994
+ await setChannelModel({
995
+ channelId: TEXT_CHANNEL_ID,
996
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
997
+ });
998
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
999
+ content: 'Reply with exactly: plain-agent-override-msg',
1000
+ });
1001
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1002
+ timeout: 4_000,
1003
+ predicate: (t) => {
1004
+ return t.name === 'Reply with exactly: plain-agent-override-msg';
1005
+ },
1006
+ });
1007
+ await waitForFooterMessage({
1008
+ discord,
1009
+ threadId: thread.id,
1010
+ timeout: 4_000,
1011
+ afterMessageIncludes: 'ok',
1012
+ afterAuthorId: discord.botUserId,
1013
+ });
1014
+ const th = discord.thread(thread.id);
1015
+ const { id: interactionId } = await th
1016
+ .user(TEST_USER_ID)
1017
+ .runSlashCommand({ name: 'plain-agent' });
1018
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
1019
+ expect(await th.text()).toMatchInlineSnapshot(`
1020
+ "--- from: user (agent-model-tester)
1021
+ Reply with exactly: plain-agent-override-msg
1022
+ --- from: assistant (TestBot)
1023
+ *using deterministic-provider/channel-model-v2*
1024
+ ⬥ ok
1025
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>
1026
+ Switched to **plain** agent for this session
1027
+ Model: *deterministic-provider/channel-model-v2* (channel override)
1028
+ This model comes from a channel override. Use /model and press Clear override if you want this agent's model.
1029
+ The agent will change on the next message."
1030
+ `);
1031
+ }, 20_000);
997
1032
  });
@@ -90,7 +90,7 @@ describe('analytics', () => {
90
90
  bot_mode: 'gateway',
91
91
  });
92
92
  });
93
- it('emits project_registered session_created turn_completed shapes', () => {
93
+ it('emits project_registered session_created turn_completed tokens_used shapes', () => {
94
94
  const captured = _enableAnalyticsTestCapture({
95
95
  installId: '33333333-3333-4333-8333-333333333333',
96
96
  botMode: 'self_hosted',
@@ -107,10 +107,24 @@ describe('analytics', () => {
107
107
  trackEvent('turn_completed', {
108
108
  duration_sec: 42,
109
109
  });
110
+ trackEvent('tokens_used', {
111
+ tokens_input: 100,
112
+ tokens_output: 20,
113
+ tokens_reasoning: 5,
114
+ tokens_cache_read: 10,
115
+ tokens_cache_write: 2,
116
+ tokens_total: 137,
117
+ cost: 0,
118
+ assistant_message_count: 2,
119
+ is_subagent: false,
120
+ model: 'gpt-5.3-codex',
121
+ provider: 'openai',
122
+ });
110
123
  expect(captured.map((e) => e.name)).toEqual([
111
124
  'project_registered',
112
125
  'session_created',
113
126
  'turn_completed',
127
+ 'tokens_used',
114
128
  ]);
115
129
  expect(captured[0].properties).toMatchObject({
116
130
  project_kind: 'user',
@@ -126,5 +140,20 @@ describe('analytics', () => {
126
140
  expect(captured[2].properties).toMatchObject({
127
141
  duration_sec: 42,
128
142
  });
143
+ expect(captured[3].properties).toMatchObject({
144
+ tokens_input: 100,
145
+ tokens_output: 20,
146
+ tokens_reasoning: 5,
147
+ tokens_cache_read: 10,
148
+ tokens_cache_write: 2,
149
+ tokens_total: 137,
150
+ cost: 0,
151
+ assistant_message_count: 2,
152
+ is_subagent: false,
153
+ model: 'gpt-5.3-codex',
154
+ provider: 'openai',
155
+ install_id: '33333333-3333-4333-8333-333333333333',
156
+ schema_version: 1,
157
+ });
129
158
  });
130
159
  });
@@ -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
  }