kimaki 0.28.0 → 0.30.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 (179) hide show
  1. package/dist/agent-model.e2e.test.js +33 -33
  2. package/dist/anthropic-account-identity.js +1 -1
  3. package/dist/anthropic-auth-plugin.js +5 -5
  4. package/dist/anthropic-auth-plugin.test.js +30 -2
  5. package/dist/anthropic-auth-state.js +1 -1
  6. package/dist/bash-tool-schema-plugin.js +18 -0
  7. package/dist/bash-tool-schema-plugin.test.js +20 -1
  8. package/dist/bin.js +7 -3
  9. package/dist/bot-lifecycle.test.js +206 -0
  10. package/dist/cache-drift-plugin.js +25 -18
  11. package/dist/cache-rewrite.js +39 -0
  12. package/dist/cache-rewrite.test.js +41 -0
  13. package/dist/channel-management.js +113 -51
  14. package/dist/cli-commands/misc.js +4 -1
  15. package/dist/cli-commands/project.js +7 -4
  16. package/dist/cli-commands/session.js +118 -36
  17. package/dist/cli-commands/thread.js +63 -0
  18. package/dist/cli-parsing.test.js +3 -3
  19. package/dist/cli-runner.js +11 -1
  20. package/dist/cli-send-thread.e2e.test.js +2 -2
  21. package/dist/cli.js +6 -4
  22. package/dist/commands/ask-question.js +72 -23
  23. package/dist/commands/queue.js +84 -36
  24. package/dist/commands/queue.test.js +15 -0
  25. package/dist/commands/restart-opencode-server.js +7 -1
  26. package/dist/commands/session-id.js +5 -5
  27. package/dist/database.js +149 -6
  28. package/dist/db.js +34 -0
  29. package/dist/db.test.js +199 -1
  30. package/dist/default-channel-provisioning.test.js +2 -1
  31. package/dist/discord-bot.js +220 -146
  32. package/dist/discord-command-registration.js +1 -1
  33. package/dist/external-directory-permissions.e2e.test.js +3 -3
  34. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  35. package/dist/forum-sync/watchers.js +15 -0
  36. package/dist/gateway-proxy.e2e.test.js +9 -9
  37. package/dist/guild-categories.test.js +225 -0
  38. package/dist/hrana-server.js +68 -14
  39. package/dist/interaction-handler.js +5 -1
  40. package/dist/kimaki-opencode-plugin.js +2 -0
  41. package/dist/kimaki-opencode-plugin.test.js +28 -0
  42. package/dist/list-channel-threads.js +163 -0
  43. package/dist/list-channel-threads.test.js +80 -0
  44. package/dist/markdown.js +164 -102
  45. package/dist/markdown.test.js +88 -20
  46. package/dist/message-formatting.js +29 -18
  47. package/dist/message-formatting.test.js +111 -3
  48. package/dist/opencode.js +27 -12
  49. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  50. package/dist/question-abort-resume.e2e.test.js +100 -0
  51. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  52. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  53. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  54. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  55. package/dist/queue-advanced-e2e-setup.js +63 -0
  56. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  57. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  58. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  59. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  60. package/dist/queue-delete-message.e2e.test.js +4 -4
  61. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  62. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  63. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  64. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  65. package/dist/schema.js +21 -0
  66. package/dist/session-handler/event-stream-state.js +240 -6
  67. package/dist/session-handler/event-stream-state.test.js +1116 -130
  68. package/dist/session-handler/thread-runtime-state.js +4 -0
  69. package/dist/session-handler/thread-session-runtime.js +499 -89
  70. package/dist/sleep.e2e.test.js +7 -7
  71. package/dist/store.js +1 -1
  72. package/dist/system-message.js +64 -23
  73. package/dist/system-message.test.js +69 -23
  74. package/dist/task-id-plugin.js +16 -0
  75. package/dist/task-runner.js +7 -0
  76. package/dist/task-schedule.test.js +1 -1
  77. package/dist/test-utils.js +5 -3
  78. package/dist/thread-message-queue.e2e.test.js +30 -31
  79. package/dist/undo-redo.e2e.test.js +3 -3
  80. package/dist/voice-message.e2e.test.js +64 -26
  81. package/dist/voice-routing-input.e2e.test.js +4 -4
  82. package/dist/voice-routing-question.e2e.test.js +4 -4
  83. package/dist/voice.js +107 -21
  84. package/dist/voice.test.js +52 -1
  85. package/dist/wait-session.js +24 -2
  86. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  87. package/package.json +7 -7
  88. package/skills/egaki/SKILL.md +14 -0
  89. package/skills/holocron/SKILL.md +125 -42
  90. package/skills/opensrc/SKILL.md +20 -3
  91. package/skills/spiceflow/SKILL.md +53 -172
  92. package/skills/strada/SKILL.md +61 -232
  93. package/src/agent-model.e2e.test.ts +33 -33
  94. package/src/anthropic-account-identity.ts +1 -1
  95. package/src/anthropic-auth-plugin.test.ts +37 -2
  96. package/src/anthropic-auth-plugin.ts +9 -7
  97. package/src/anthropic-auth-state.ts +1 -0
  98. package/src/bash-tool-schema-plugin.test.ts +29 -0
  99. package/src/bash-tool-schema-plugin.ts +31 -0
  100. package/src/bin.ts +7 -3
  101. package/src/bot-lifecycle.test.ts +252 -0
  102. package/src/cache-drift-plugin.ts +34 -24
  103. package/src/cache-rewrite.test.ts +49 -0
  104. package/src/cache-rewrite.ts +96 -0
  105. package/src/channel-management.ts +144 -65
  106. package/src/cli-commands/misc.ts +4 -1
  107. package/src/cli-commands/project.ts +9 -4
  108. package/src/cli-commands/session.ts +167 -38
  109. package/src/cli-commands/thread.ts +86 -0
  110. package/src/cli-parsing.test.ts +3 -3
  111. package/src/cli-runner.ts +11 -0
  112. package/src/cli-send-thread.e2e.test.ts +2 -2
  113. package/src/cli.ts +8 -6
  114. package/src/commands/ask-question.ts +88 -25
  115. package/src/commands/queue.test.ts +19 -0
  116. package/src/commands/queue.ts +111 -45
  117. package/src/commands/restart-opencode-server.ts +12 -1
  118. package/src/commands/session-id.ts +5 -5
  119. package/src/database.ts +219 -6
  120. package/src/db.test.ts +236 -0
  121. package/src/db.ts +38 -0
  122. package/src/default-channel-provisioning.test.ts +2 -1
  123. package/src/discord-bot.ts +272 -185
  124. package/src/discord-command-registration.ts +3 -1
  125. package/src/external-directory-permissions.e2e.test.ts +3 -3
  126. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  127. package/src/forum-sync/watchers.ts +20 -0
  128. package/src/gateway-proxy.e2e.test.ts +9 -9
  129. package/src/guild-categories.test.ts +260 -0
  130. package/src/hrana-server.ts +96 -16
  131. package/src/interaction-handler.ts +7 -0
  132. package/src/kimaki-opencode-plugin.test.ts +34 -0
  133. package/src/kimaki-opencode-plugin.ts +2 -0
  134. package/src/list-channel-threads.test.ts +90 -0
  135. package/src/list-channel-threads.ts +252 -0
  136. package/src/markdown.test.ts +108 -22
  137. package/src/markdown.ts +216 -103
  138. package/src/message-formatting.test.ts +129 -2
  139. package/src/message-formatting.ts +28 -14
  140. package/src/opencode.ts +28 -13
  141. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  142. package/src/question-abort-resume.e2e.test.ts +121 -0
  143. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  144. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  145. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  146. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  147. package/src/queue-advanced-e2e-setup.ts +65 -0
  148. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  149. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  150. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  151. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  152. package/src/queue-delete-message.e2e.test.ts +4 -4
  153. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  154. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  155. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  156. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  157. package/src/schema.sql +16 -0
  158. package/src/schema.ts +23 -0
  159. package/src/session-handler/event-stream-state.test.ts +1184 -133
  160. package/src/session-handler/event-stream-state.ts +322 -8
  161. package/src/session-handler/thread-runtime-state.ts +5 -0
  162. package/src/session-handler/thread-session-runtime.ts +597 -95
  163. package/src/sleep.e2e.test.ts +7 -7
  164. package/src/store.ts +2 -2
  165. package/src/system-message.test.ts +70 -23
  166. package/src/system-message.ts +67 -22
  167. package/src/task-id-plugin.ts +17 -0
  168. package/src/task-runner.ts +8 -0
  169. package/src/task-schedule.test.ts +1 -1
  170. package/src/test-utils.ts +5 -3
  171. package/src/thread-message-queue.e2e.test.ts +30 -31
  172. package/src/undo-redo.e2e.test.ts +3 -3
  173. package/src/voice-message.e2e.test.ts +70 -26
  174. package/src/voice-routing-input.e2e.test.ts +4 -4
  175. package/src/voice-routing-question.e2e.test.ts +4 -4
  176. package/src/voice.test.ts +56 -0
  177. package/src/voice.ts +135 -25
  178. package/src/wait-session.ts +25 -2
  179. package/src/worktree-lifecycle.e2e.test.ts +8 -8
@@ -399,9 +399,9 @@ describe('agent model resolution', () => {
399
399
  "--- from: user (agent-model-tester)
400
400
  Reply with exactly: agent-model-check
401
401
  --- from: assistant (TestBot)
402
- > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
402
+ -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
403
403
  > ok
404
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
404
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
405
405
  `);
406
406
  expect(footerMessage).toBeDefined();
407
407
  if (!footerMessage) {
@@ -443,9 +443,9 @@ describe('agent model resolution', () => {
443
443
  "--- from: user (agent-model-tester)
444
444
  Reply with exactly: system-context-check
445
445
  --- from: assistant (TestBot)
446
- > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
446
+ -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
447
447
  > system-context-ok
448
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
448
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
449
449
  `);
450
450
  }, 15_000);
451
451
  test('session.command path includes kimaki system prompt on first message', async () => {
@@ -549,9 +549,9 @@ describe('agent model resolution', () => {
549
549
  "--- from: user (agent-model-tester)
550
550
  Reply with exactly: channel-model-check
551
551
  --- from: assistant (TestBot)
552
- > *using deterministic-provider/channel-model-v2*
552
+ -# *using deterministic-provider/channel-model-v2*
553
553
  > ok
554
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>"
554
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
555
555
  `);
556
556
  expect(footerMessage).toBeDefined();
557
557
  if (!footerMessage) {
@@ -593,9 +593,9 @@ describe('agent model resolution', () => {
593
593
  "--- from: user (agent-model-tester)
594
594
  Reply with exactly: variant-check
595
595
  --- from: assistant (TestBot)
596
- > *using deterministic-provider/channel-model-v2*
596
+ -# *using deterministic-provider/channel-model-v2*
597
597
  > ok
598
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>"
598
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
599
599
  `);
600
600
  expect(footer?.content).toContain(CHANNEL_MODEL);
601
601
  expect(footer?.content).not.toContain(DEFAULT_MODEL);
@@ -662,7 +662,7 @@ describe('agent model resolution', () => {
662
662
  Reusing context from <#SOURCE_THREAD> to answer prompt...
663
663
  Reply with exactly: btw-model-check
664
664
  > ok
665
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2* <@200000000000000920>"
665
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
666
666
  `);
667
667
  expect(forkedSessionModel).toMatchInlineSnapshot(`
668
668
  {
@@ -725,14 +725,14 @@ describe('agent model resolution', () => {
725
725
  "--- from: user (agent-model-tester)
726
726
  Reply with exactly: first-thread-msg
727
727
  --- from: assistant (TestBot)
728
- > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
728
+ -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
729
729
  > ok
730
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
730
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
731
731
  --- from: user (agent-model-tester)
732
732
  Reply with exactly: second-thread-msg
733
733
  --- from: assistant (TestBot)
734
734
  > ok
735
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
735
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
736
736
  `);
737
737
  const secondMessages = await discord.thread(thread.id).getMessages();
738
738
  const secondFooter = [...secondMessages]
@@ -779,9 +779,9 @@ describe('agent model resolution', () => {
779
779
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
780
780
  "--- from: assistant (TestBot)
781
781
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
782
- > *using deterministic-provider/plan-model-v2 ⋅ plan*
782
+ -# *using deterministic-provider/plan-model-v2 ⋅ plan*
783
783
  > ok
784
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
784
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
785
785
  `);
786
786
  }, 20_000);
787
787
  test('/plan-agent with prompt persists a supported thinking variant', async () => {
@@ -815,9 +815,9 @@ describe('agent model resolution', () => {
815
815
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
816
816
  "--- from: assistant (TestBot)
817
817
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-variant-msg
818
- > *using deterministic-provider/plan-model-v2 ⋅ plan*
818
+ -# *using deterministic-provider/plan-model-v2 ⋅ plan*
819
819
  > ok
820
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
820
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
821
821
  `);
822
822
  expect(sessionId ? await getSessionModel(sessionId) : undefined).toMatchInlineSnapshot(`
823
823
  {
@@ -908,12 +908,12 @@ describe('agent model resolution', () => {
908
908
  "--- from: user (agent-model-tester)
909
909
  Reply with exactly: inline-existing-first-msg
910
910
  --- from: assistant (TestBot)
911
- > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
911
+ -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
912
912
  > ok
913
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
913
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
914
914
  » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
915
915
  > ok
916
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
916
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
917
917
  `);
918
918
  }, 20_000);
919
919
  test('/plan-agent inside a thread switches the model for that thread', async () => {
@@ -962,9 +962,9 @@ describe('agent model resolution', () => {
962
962
  "--- from: user (agent-model-tester)
963
963
  Reply with exactly: switch-in-thread-msg
964
964
  --- from: assistant (TestBot)
965
- > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
965
+ -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
966
966
  > ok
967
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
967
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
968
968
  Switched to **plan** agent for this session (was **test-agent**)
969
969
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
970
970
  The agent will change on the next message.
@@ -972,7 +972,7 @@ describe('agent model resolution', () => {
972
972
  Reply with exactly: after-switch-msg
973
973
  --- from: assistant (TestBot)
974
974
  > ok
975
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
975
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
976
976
  `);
977
977
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
978
978
  .reverse()
@@ -1021,9 +1021,9 @@ describe('agent model resolution', () => {
1021
1021
  "--- from: user (agent-model-tester)
1022
1022
  Reply with exactly: race-switch-first-msg
1023
1023
  --- from: assistant (TestBot)
1024
- > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1024
+ -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1025
1025
  > ok
1026
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
1026
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1027
1027
  --- from: user (agent-model-tester)
1028
1028
  Reply with exactly: race-switch-follow-up
1029
1029
  --- from: assistant (TestBot)
@@ -1031,7 +1031,7 @@ describe('agent model resolution', () => {
1031
1031
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1032
1032
  The agent will change on the next message.
1033
1033
  > ok
1034
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
1034
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
1035
1035
  `);
1036
1036
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
1037
1037
  .reverse()
@@ -1071,9 +1071,9 @@ describe('agent model resolution', () => {
1071
1071
  "--- from: user (agent-model-tester)
1072
1072
  Reply with exactly: race-channel-follow-up
1073
1073
  --- from: assistant (TestBot)
1074
- > *using deterministic-provider/plan-model-v2 ⋅ plan*
1074
+ -# *using deterministic-provider/plan-model-v2 ⋅ plan*
1075
1075
  > ok
1076
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
1076
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
1077
1077
  `);
1078
1078
  const footer = [...(await discord.thread(thread.id).getMessages())]
1079
1079
  .reverse()
@@ -1119,9 +1119,9 @@ describe('agent model resolution', () => {
1119
1119
  "--- from: user (agent-model-tester)
1120
1120
  Reply with exactly: refresh-agent-model-msg
1121
1121
  --- from: assistant (TestBot)
1122
- > *using deterministic-provider/plan-model-v2 ⋅ plan*
1122
+ -# *using deterministic-provider/plan-model-v2 ⋅ plan*
1123
1123
  > ok
1124
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>
1124
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***
1125
1125
  Using **plan** agent for this session
1126
1126
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1127
1127
  The agent will change on the next message."
@@ -1161,9 +1161,9 @@ describe('agent model resolution', () => {
1161
1161
  "--- from: user (agent-model-tester)
1162
1162
  Reply with exactly: channel-vs-agent-msg
1163
1163
  --- from: assistant (TestBot)
1164
- > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1164
+ -# *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1165
1165
  > ok
1166
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
1166
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1167
1167
  Switched to **plan** agent for this session (was **test-agent**)
1168
1168
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1169
1169
  The agent will change on the next message."
@@ -1203,9 +1203,9 @@ describe('agent model resolution', () => {
1203
1203
  "--- from: user (agent-model-tester)
1204
1204
  Reply with exactly: plain-agent-override-msg
1205
1205
  --- from: assistant (TestBot)
1206
- > *using deterministic-provider/channel-model-v2*
1206
+ -# *using deterministic-provider/channel-model-v2*
1207
1207
  > ok
1208
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>
1208
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*
1209
1209
  Switched to **plain** agent for this session
1210
1210
  Model: *deterministic-provider/channel-model-v2* (channel override)
1211
1211
  This model comes from a channel override. Use /model and press Clear override if you want this agent's model.
@@ -1,6 +1,6 @@
1
1
  // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
2
2
  // Helpers for Anthropic OAuth account and Claude Code client identity.
3
- const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.257 (external, cli)';
3
+ const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.280 (external, cli)';
4
4
  export function applyClaudeCodeRequestIdentity({ headers, accessToken, }) {
5
5
  headers.set('authorization', `Bearer ${accessToken}`);
6
6
  headers.set('user-agent', process.env.OPENCODE_ANTHROPIC_USER_AGENT || CLAUDE_CODE_USER_AGENT);
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { appendToastSessionMarker } from "./plugin-logger.js";
27
27
  import { createPluginClient } from "./plugin-opencode-client.js";
28
- import { isPermanentOAuthRefreshFailure, loadAccountStore, rememberAnthropicOAuth, removeAccountByAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
28
+ import { isPermanentOAuthRefreshFailure, loadAccountStore, rememberAnthropicOAuth, removeAccountByAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, isOAuthStored, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
29
29
  import { applyClaudeCodeRequestIdentity, extractAnthropicAccountIdentity, } from "./anthropic-account-identity.js";
30
30
  // PKCE (Proof Key for Code Exchange) using Web Crypto API.
31
31
  // Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
@@ -717,9 +717,6 @@ function mergeBetas(existing, required) {
717
717
  ].join(",");
718
718
  }
719
719
  // --- Token refresh with dedup ---
720
- function isOAuthStored(auth) {
721
- return auth.type === "oauth";
722
- }
723
720
  /**
724
721
  * Refresh Anthropic OAuth. On permanent refresh failure (invalid_grant),
725
722
  * remove that account from the pool and retry with the next one.
@@ -727,6 +724,9 @@ function isOAuthStored(auth) {
727
724
  */
728
725
  async function getFreshOAuth(getAuth, client, options) {
729
726
  const auth = await getAuth();
727
+ if (auth == null) {
728
+ throw new Error("Anthropic OAuth credentials are missing. Re-login with /login and pick Claude Pro/Max.");
729
+ }
730
730
  if (!isOAuthStored(auth))
731
731
  return undefined;
732
732
  if (auth.access && auth.expires > Date.now())
@@ -820,7 +820,7 @@ const AnthropicAuthPlugin = async ({ serverUrl, directory }) => {
820
820
  provider: "anthropic",
821
821
  async loader(getAuth, provider) {
822
822
  const auth = await getAuth();
823
- if (auth.type !== "oauth")
823
+ if (!auth || auth.type !== "oauth")
824
824
  return {};
825
825
  // Zero out costs for OAuth users (Claude Pro/Max subscription)
826
826
  for (const model of Object.values(provider.models)) {
@@ -1,7 +1,7 @@
1
1
  // Tests Anthropic system prompt rewriting so project instructions survive OpenCode prompt layout changes.
2
2
  import { describe, expect, test } from 'vitest';
3
3
  import { applyClaudeCodeRequestIdentity } from './anthropic-account-identity.js';
4
- import { replacer } from './anthropic-auth-plugin.js';
4
+ import { anthropicAuthPlugin, replacer } from './anthropic-auth-plugin.js';
5
5
  async function transformSystem(systemText) {
6
6
  const plugin = await replacer({});
7
7
  const transform = plugin['experimental.chat.system.transform'];
@@ -13,6 +13,34 @@ async function transformSystem(systemText) {
13
13
  }, output);
14
14
  return output.system.join('\n');
15
15
  }
16
+ describe('Anthropic OAuth loader', () => {
17
+ test('missing auth does not crash on auth.type', async () => {
18
+ const plugin = await anthropicAuthPlugin({
19
+ serverUrl: new URL('http://127.0.0.1:9'),
20
+ directory: '/tmp',
21
+ });
22
+ const loader = plugin.auth?.loader;
23
+ if (!loader)
24
+ throw new Error('missing loader');
25
+ let reads = 0;
26
+ const options = await loader((async () => {
27
+ reads += 1;
28
+ if (reads === 1) {
29
+ return {
30
+ type: 'oauth',
31
+ refresh: 'refresh-token',
32
+ access: 'access-token',
33
+ expires: Date.now() + 60_000,
34
+ };
35
+ }
36
+ return undefined;
37
+ }), { models: {} });
38
+ await expect(options.fetch?.('https://api.anthropic.com/v1/messages', {
39
+ method: 'POST',
40
+ body: '{}',
41
+ })).rejects.toThrow('Anthropic OAuth credentials are missing');
42
+ });
43
+ });
16
44
  describe('Anthropic OAuth request identity', () => {
17
45
  test('applies the current external Claude Code client headers', () => {
18
46
  const headers = applyClaudeCodeRequestIdentity({
@@ -23,7 +51,7 @@ describe('Anthropic OAuth request identity', () => {
23
51
  {
24
52
  "accept": "application/json",
25
53
  "authorization": "Bearer access-token",
26
- "user-agent": "claude-cli/2.1.257 (external, cli)",
54
+ "user-agent": "claude-cli/2.1.280 (external, cli)",
27
55
  "x-app": "cli",
28
56
  }
29
57
  `);
@@ -9,7 +9,7 @@ import { homedir } from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { normalizeAnthropicAccountIdentity, } from './anthropic-account-identity.js';
11
11
  import { accountLabel, authFilePath, findCurrentAccountIndex, isOAuthStored, isPermanentOAuthRefreshFailure, normalizeAccountStore, readJson, upsertAccount as sharedUpsertAccount, withAuthStateLock, writeJson, shouldRotateAuth, } from './oauth-rotation-shared.js';
12
- export { accountLabel, authFilePath, withAuthStateLock, shouldRotateAuth, isPermanentOAuthRefreshFailure, };
12
+ export { accountLabel, authFilePath, isOAuthStored, withAuthStateLock, shouldRotateAuth, isPermanentOAuthRefreshFailure, };
13
13
  // --- Store file path ---
14
14
  export function accountsFilePath() {
15
15
  if (process.env.XDG_DATA_HOME) {
@@ -3,7 +3,22 @@
3
3
  // Assign jsonSchema only. Leave parameters as the Effect Schema used to decode
4
4
  // args before execute. Extra fields stay in the model-facing schema and in
5
5
  // Discord input; they never reach the shell.
6
+ //
7
+ // Also injects KIMAKI_SESSION_ID into bash env. /btw and /fork clone parent
8
+ // history, so the copied system prompt still says --session <parent>. Upload
9
+ // then prefers this live env over that stale flag.
6
10
  import { z } from 'zod';
11
+ export const KIMAKI_SESSION_ID_ENV = 'KIMAKI_SESSION_ID';
12
+ export function injectKimakiSessionEnv({ sessionID, env, }) {
13
+ if (!sessionID)
14
+ return;
15
+ env[KIMAKI_SESSION_ID_ENV] = sessionID;
16
+ }
17
+ export function resolveUploadToDiscordSessionId({ flagSessionId, envSessionId = process.env[KIMAKI_SESSION_ID_ENV], } = {}) {
18
+ if (envSessionId)
19
+ return envSessionId;
20
+ return flagSessionId;
21
+ }
7
22
  export const bashParameters = z.object({
8
23
  command: z.string().describe('The command to execute'),
9
24
  description: z
@@ -28,5 +43,8 @@ export const bashToolSchemaPlugin = async () => {
28
43
  return;
29
44
  extendBashToolDefinition(output);
30
45
  },
46
+ 'shell.env': async (input, output) => {
47
+ injectKimakiSessionEnv({ sessionID: input.sessionID, env: output.env });
48
+ },
31
49
  };
32
50
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'vitest';
2
- import { extendBashToolDefinition, } from './bash-tool-schema-plugin.js';
2
+ import { extendBashToolDefinition, injectKimakiSessionEnv, KIMAKI_SESSION_ID_ENV, resolveUploadToDiscordSessionId, } from './bash-tool-schema-plugin.js';
3
3
  describe('extendBashToolDefinition', () => {
4
4
  test('keeps parameters and assigns jsonSchema from bashParameters', () => {
5
5
  const parameters = { keep: true };
@@ -47,3 +47,22 @@ describe('extendBashToolDefinition', () => {
47
47
  `);
48
48
  });
49
49
  });
50
+ describe('upload-to-discord session targeting', () => {
51
+ test('bash env uses the live OpenCode session, not a copied --session flag', () => {
52
+ const env = {};
53
+ injectKimakiSessionEnv({ sessionID: 'ses_child', env });
54
+ expect(env).toEqual({ [KIMAKI_SESSION_ID_ENV]: 'ses_child' });
55
+ expect(resolveUploadToDiscordSessionId({
56
+ flagSessionId: 'ses_parent',
57
+ envSessionId: env[KIMAKI_SESSION_ID_ENV],
58
+ })).toBe('ses_child');
59
+ });
60
+ test('falls back to --session when bash is not inside an OpenCode session', () => {
61
+ expect(resolveUploadToDiscordSessionId({
62
+ flagSessionId: 'ses_parent',
63
+ })).toBe('ses_parent');
64
+ });
65
+ test('returns undefined when neither live session nor flag is set', () => {
66
+ expect(resolveUploadToDiscordSessionId({})).toBeUndefined();
67
+ });
68
+ });
package/dist/bin.js CHANGED
@@ -91,8 +91,11 @@ else {
91
91
  `--diagnostic-dir=${HEAP_SNAPSHOT_DIR}`,
92
92
  ];
93
93
  const args = [...heapArgs, ...process.execArgv, ...process.argv.slice(1)];
94
+ // The IPC channel is only a liveness link: when this wrapper dies (even by
95
+ // SIGKILL) the child sees 'disconnect' and shuts down instead of staying
96
+ // orphaned on the lock port. See registerBotLifecycleHandlers.
94
97
  const currentChild = spawn(process.argv[0], args, {
95
- stdio: 'inherit',
98
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
96
99
  env: { ...process.env, __KIMAKI_CHILD: '1' },
97
100
  });
98
101
  child = currentChild;
@@ -130,8 +133,9 @@ else {
130
133
  });
131
134
  }
132
135
  // Forward signals to child so graceful shutdown and heap snapshots work.
133
- // SIGTERM/SIGINT mark shutdownRequested so we don't restart after graceful exit.
134
- for (const sig of ['SIGTERM', 'SIGINT']) {
136
+ // Termination signals mark shutdownRequested so we don't restart after
137
+ // graceful exit. SIGHUP comes from closing the terminal.
138
+ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
135
139
  process.on(sig, () => {
136
140
  shutdownRequested = true;
137
141
  killChild(sig);
@@ -0,0 +1,206 @@
1
+ // Real-process tests for bot shutdown and lock-port recovery.
2
+ // Regression: Ctrl+C during startup left the bot alive on the hrana lock port
3
+ // (29988), and every next start failed with "still in use after eviction".
4
+ import { spawn } from 'node:child_process';
5
+ import net from 'node:net';
6
+ import path from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import dedent from 'string-dedent';
9
+ import * as errore from 'errore';
10
+ import { afterEach, describe, expect, test } from 'vitest';
11
+ import { evictExistingInstance } from './hrana-server.js';
12
+ const srcDir = path.dirname(fileURLToPath(import.meta.url));
13
+ const hranaModule = pathToFileURL(path.join(srcDir, 'hrana-server.ts')).href;
14
+ const botModule = pathToFileURL(path.join(srcDir, 'discord-bot.ts')).href;
15
+ const children = [];
16
+ afterEach(() => {
17
+ for (const child of children.splice(0)) {
18
+ if (child.exitCode === null && child.signalCode === null) {
19
+ child.kill('SIGKILL');
20
+ }
21
+ }
22
+ });
23
+ async function getFreePort() {
24
+ return new Promise((resolve, reject) => {
25
+ const srv = net.createServer();
26
+ srv.once('error', reject);
27
+ srv.listen(0, '127.0.0.1', () => {
28
+ const address = srv.address();
29
+ if (!address || typeof address === 'string') {
30
+ reject(new Error('No port assigned'));
31
+ return;
32
+ }
33
+ srv.close(() => resolve(address.port));
34
+ });
35
+ });
36
+ }
37
+ async function isPortFree(port) {
38
+ return new Promise((resolve) => {
39
+ const srv = net.createServer();
40
+ srv.once('error', () => resolve(false));
41
+ srv.listen(port, '127.0.0.1', () => {
42
+ srv.close(() => resolve(true));
43
+ });
44
+ });
45
+ }
46
+ // Runs an ESM script through tsx so it can import the real .ts sources.
47
+ function startFixture({ script, port, ipc = false, env = {}, }) {
48
+ const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', script], {
49
+ cwd: path.dirname(srcDir),
50
+ env: {
51
+ ...process.env,
52
+ KIMAKI_LOCK_PORT: String(port),
53
+ __KIMAKI_CHILD: ipc ? '1' : undefined,
54
+ ...env,
55
+ },
56
+ stdio: ipc ? ['ignore', 'pipe', 'pipe', 'ipc'] : ['ignore', 'pipe', 'pipe'],
57
+ });
58
+ children.push(child);
59
+ let output = '';
60
+ const ready = new Promise((resolve, reject) => {
61
+ child.stdout.on('data', (chunk) => {
62
+ output += chunk.toString();
63
+ if (output.includes('FIXTURE_READY'))
64
+ resolve();
65
+ });
66
+ child.stderr.on('data', (chunk) => {
67
+ output += chunk.toString();
68
+ });
69
+ child.once('exit', (code, signal) => {
70
+ reject(new Error(`fixture exited early (${code ?? signal}): ${output}`));
71
+ });
72
+ });
73
+ const exited = new Promise((resolve) => {
74
+ child.once('exit', (code, signal) => resolve({ code, signal }));
75
+ });
76
+ return { child, ready, exited, output: () => output };
77
+ }
78
+ function hranaScript({ extra = '', afterReady = '', }) {
79
+ return dedent `
80
+ import os from 'node:os'
81
+ import path from 'node:path'
82
+ import fs from 'node:fs'
83
+ import { startHranaServer } from '${hranaModule}'
84
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-lifecycle-'))
85
+ const result = await startHranaServer({ dbPath: path.join(dir, 'db.sqlite') })
86
+ if (result instanceof Error) throw result
87
+ ${extra}
88
+ // Same situation as the opencode.ts fallback: a listener that never exits.
89
+ process.on('SIGINT', () => {})
90
+ process.on('SIGTERM', () => {})
91
+ console.log('FIXTURE_READY')
92
+ ${afterReady}
93
+ setInterval(() => {}, 1000)
94
+ `;
95
+ }
96
+ // Minimal stand-in for bin.ts: IPC child, respawn on crash, no respawn after
97
+ // SIGTERM. Enough to prove eviction targets the wrapper, not just the child.
98
+ const WRAPPER_SCRIPT = dedent `
99
+ import { spawn } from 'node:child_process'
100
+ let stopping = false
101
+ let child
102
+ process.on('SIGTERM', () => {
103
+ stopping = true
104
+ child.kill('SIGTERM')
105
+ })
106
+ function start() {
107
+ child = spawn(
108
+ process.execPath,
109
+ ['--import', 'tsx', '--input-type=module', '-e', process.env.CHILD_SCRIPT],
110
+ { stdio: ['ignore', 'inherit', 'inherit', 'ipc'], env: { ...process.env, __KIMAKI_CHILD: '1' } },
111
+ )
112
+ console.log('CHILD_PID=' + child.pid)
113
+ child.on('exit', () => {
114
+ if (stopping) process.exit(0)
115
+ console.log('RESPAWNED')
116
+ start()
117
+ })
118
+ }
119
+ start()
120
+ `;
121
+ function isPidAlive(pid) {
122
+ const result = errore.try(() => {
123
+ process.kill(pid, 0);
124
+ });
125
+ return !(result instanceof Error);
126
+ }
127
+ describe('bot lifecycle', () => {
128
+ test('eviction SIGKILLs an old instance that ignores SIGTERM', async () => {
129
+ const port = await getFreePort();
130
+ const fixture = startFixture({ script: hranaScript({ extra: '' }), port });
131
+ await fixture.ready;
132
+ expect(await isPortFree(port)).toBe(false);
133
+ await evictExistingInstance({ port, gracePeriodMs: 500 });
134
+ expect(await fixture.exited).toEqual({ code: null, signal: 'SIGKILL' });
135
+ expect(await isPortFree(port)).toBe(true);
136
+ }, 20_000);
137
+ test('eviction stops the old wrapper so it cannot respawn the child', async () => {
138
+ const port = await getFreePort();
139
+ const wrapper = startFixture({
140
+ script: WRAPPER_SCRIPT,
141
+ port,
142
+ env: { CHILD_SCRIPT: hranaScript({}) },
143
+ });
144
+ await wrapper.ready;
145
+ const childPid = Number(/CHILD_PID=(\d+)/.exec(wrapper.output())?.[1]);
146
+ // The child ignores SIGTERM, so the wrapper's forwarded SIGTERM is not
147
+ // enough and eviction must SIGKILL both, wrapper first.
148
+ await evictExistingInstance({ port, gracePeriodMs: 500 });
149
+ expect(await wrapper.exited).toEqual({ code: null, signal: 'SIGKILL' });
150
+ expect(isPidAlive(childPid)).toBe(false);
151
+ expect(wrapper.output()).not.toContain('RESPAWNED');
152
+ expect(await isPortFree(port)).toBe(true);
153
+ }, 20_000);
154
+ test('SIGINT during startup releases the lock port', async () => {
155
+ const port = await getFreePort();
156
+ const fixture = startFixture({
157
+ script: hranaScript({
158
+ extra: dedent `
159
+ const { registerBotLifecycleHandlers } = await import('${botModule}')
160
+ registerBotLifecycleHandlers()
161
+ `,
162
+ }),
163
+ port,
164
+ });
165
+ await fixture.ready;
166
+ fixture.child.kill('SIGINT');
167
+ expect(await fixture.exited).toEqual({ code: 0, signal: null });
168
+ expect(await isPortFree(port)).toBe(true);
169
+ }, 30_000);
170
+ test('child shuts down when the wrapper IPC channel closes', async () => {
171
+ const port = await getFreePort();
172
+ const fixture = startFixture({
173
+ script: hranaScript({
174
+ extra: dedent `
175
+ const { registerBotLifecycleHandlers } = await import('${botModule}')
176
+ registerBotLifecycleHandlers()
177
+ `,
178
+ }),
179
+ port,
180
+ ipc: true,
181
+ });
182
+ await fixture.ready;
183
+ // Same as the wrapper dying: the OS closes the IPC pipe.
184
+ fixture.child.disconnect();
185
+ expect(await fixture.exited).toEqual({ code: 0, signal: null });
186
+ expect(await isPortFree(port)).toBe(true);
187
+ }, 30_000);
188
+ test('child shuts down if the wrapper died before handlers were registered', async () => {
189
+ const port = await getFreePort();
190
+ const fixture = startFixture({
191
+ script: hranaScript({
192
+ afterReady: dedent `
193
+ await new Promise((resolve) => process.once('disconnect', resolve))
194
+ const { registerBotLifecycleHandlers } = await import('${botModule}')
195
+ registerBotLifecycleHandlers()
196
+ `,
197
+ }),
198
+ port,
199
+ ipc: true,
200
+ });
201
+ await fixture.ready;
202
+ fixture.child.disconnect();
203
+ expect(await fixture.exited).toEqual({ code: 0, signal: null });
204
+ expect(await isPortFree(port)).toBe(true);
205
+ }, 30_000);
206
+ });