kimaki 0.27.0 → 0.29.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 (246) hide show
  1. package/dist/agent-model.e2e.test.js +247 -64
  2. package/dist/anthropic-account-identity.js +8 -1
  3. package/dist/anthropic-auth-plugin.js +9 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/bash-tool-schema-plugin.js +50 -0
  6. package/dist/bash-tool-schema-plugin.test.js +68 -0
  7. package/dist/channel-management.js +113 -51
  8. package/dist/cli-commands/misc.js +4 -1
  9. package/dist/cli-commands/project.js +17 -30
  10. package/dist/cli-commands/send.js +6 -1
  11. package/dist/cli-commands/session.js +223 -115
  12. package/dist/cli-commands/task.js +21 -14
  13. package/dist/cli-commands/thread.js +63 -0
  14. package/dist/cli-parsing.test.js +24 -1
  15. package/dist/cli-runner.js +16 -4
  16. package/dist/cli-send-thread.e2e.test.js +9 -9
  17. package/dist/cli.js +2 -0
  18. package/dist/commands/action-buttons.js +2 -1
  19. package/dist/commands/add-project.js +4 -7
  20. package/dist/commands/agent.js +158 -24
  21. package/dist/commands/ask-question.js +2 -1
  22. package/dist/commands/btw.js +28 -6
  23. package/dist/commands/file-upload.js +2 -1
  24. package/dist/commands/fork-subagent.js +2 -4
  25. package/dist/commands/fork.js +4 -4
  26. package/dist/commands/last-sessions.js +17 -10
  27. package/dist/commands/merge-worktree.js +13 -6
  28. package/dist/commands/model-variant.js +3 -2
  29. package/dist/commands/model.js +4 -3
  30. package/dist/commands/queue.js +82 -33
  31. package/dist/commands/queue.test.js +15 -0
  32. package/dist/commands/restart-opencode-server.js +7 -1
  33. package/dist/commands/resume.js +4 -4
  34. package/dist/commands/session-id.js +5 -5
  35. package/dist/commands/session.js +78 -63
  36. package/dist/cpu-profiler.js +211 -0
  37. package/dist/cpu-profiler.test.js +160 -0
  38. package/dist/database.js +104 -0
  39. package/dist/db.js +45 -0
  40. package/dist/db.test.js +196 -1
  41. package/dist/default-channel-provisioning.test.js +149 -0
  42. package/dist/discord-bot.js +509 -476
  43. package/dist/discord-command-registration.js +17 -10
  44. package/dist/discord-command-registration.test.js +17 -0
  45. package/dist/discord-utils.js +84 -0
  46. package/dist/discord-utils.test.js +135 -1
  47. package/dist/errors.js +5 -0
  48. package/dist/external-directory-permissions.e2e.test.js +11 -7
  49. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  50. package/dist/external-opencode-sync.js +94 -17
  51. package/dist/external-opencode-sync.test.js +206 -0
  52. package/dist/extract-sdk-error-message.test.js +13 -0
  53. package/dist/gateway-proxy.e2e.test.js +18 -18
  54. package/dist/guild-categories.test.js +225 -0
  55. package/dist/interaction-handler.js +421 -399
  56. package/dist/ipc-tools-plugin.js +12 -7
  57. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +2 -2
  58. package/dist/kimaki-opencode-plugin.js +4 -0
  59. package/dist/kimaki-opencode-plugin.test.js +28 -0
  60. package/dist/list-channel-threads.js +163 -0
  61. package/dist/list-channel-threads.test.js +80 -0
  62. package/dist/logger.js +1 -0
  63. package/dist/message-formatting.js +156 -44
  64. package/dist/message-formatting.test.js +562 -13
  65. package/dist/message-preprocessing.js +105 -1
  66. package/dist/opencode-command.js +28 -0
  67. package/dist/opencode-command.test.js +49 -1
  68. package/dist/opencode-interrupt-plugin.js +4 -0
  69. package/dist/opencode-interrupt-plugin.test.js +24 -0
  70. package/dist/opencode.js +29 -3
  71. package/dist/project-add.test.js +62 -0
  72. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  73. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  74. package/dist/question-text-before-dropdown.e2e.test.js +5 -5
  75. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  76. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  77. package/dist/queue-advanced-footer.e2e.test.js +29 -32
  78. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  79. package/dist/queue-advanced-permissions-typing.e2e.test.js +9 -7
  80. package/dist/queue-advanced-question.e2e.test.js +2 -2
  81. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  82. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  83. package/dist/queue-delete-message.e2e.test.js +3 -3
  84. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  85. package/dist/queue-interrupt-drain.e2e.test.js +20 -20
  86. package/dist/queue-question-select-drain.e2e.test.js +7 -7
  87. package/dist/runtime-lifecycle.e2e.test.js +16 -19
  88. package/dist/schema.js +22 -1
  89. package/dist/session-handler/event-stream-state.js +176 -4
  90. package/dist/session-handler/event-stream-state.test.js +545 -1
  91. package/dist/session-handler/model-utils.js +16 -3
  92. package/dist/session-handler/model-utils.test.js +103 -3
  93. package/dist/session-handler/opencode-session-event-log.js +30 -1
  94. package/dist/session-handler/thread-ingress.test.js +41 -0
  95. package/dist/session-handler/thread-runtime-state.js +6 -0
  96. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  97. package/dist/session-handler/thread-session-runtime.js +642 -196
  98. package/dist/session-search.js +123 -4
  99. package/dist/session-search.test.js +248 -1
  100. package/dist/session-title-rename.test.js +30 -0
  101. package/dist/sleep.e2e.test.js +14 -11
  102. package/dist/system-message.js +100 -25
  103. package/dist/system-message.test.js +167 -23
  104. package/dist/task-id-plugin.js +16 -0
  105. package/dist/task-runner.js +2 -1
  106. package/dist/task-schedule.js +32 -2
  107. package/dist/task-schedule.test.js +79 -1
  108. package/dist/test-utils.js +17 -9
  109. package/dist/thinking-utils.js +10 -0
  110. package/dist/thinking-utils.test.js +35 -0
  111. package/dist/thread-message-queue.e2e.test.js +65 -54
  112. package/dist/undo-redo.e2e.test.js +17 -9
  113. package/dist/voice-handler.js +21 -4
  114. package/dist/voice-message.e2e.test.js +144 -30
  115. package/dist/voice-routing-input.e2e.test.js +195 -0
  116. package/dist/voice-routing-question.e2e.test.js +125 -0
  117. package/dist/voice.js +28 -8
  118. package/dist/voice.test.js +41 -1
  119. package/dist/worktree-lifecycle.e2e.test.js +30 -30
  120. package/package.json +9 -9
  121. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  122. package/skills/critique/SKILL.md +4 -4
  123. package/skills/errore/SKILL.md +56 -232
  124. package/skills/holocron/SKILL.md +117 -17
  125. package/skills/strada/SKILL.md +61 -232
  126. package/src/agent-model.e2e.test.ts +293 -66
  127. package/src/anthropic-account-identity.ts +19 -1
  128. package/src/anthropic-auth-plugin.test.ts +19 -0
  129. package/src/anthropic-auth-plugin.ts +11 -16
  130. package/src/bash-tool-schema-plugin.test.ts +84 -0
  131. package/src/bash-tool-schema-plugin.ts +77 -0
  132. package/src/channel-management.ts +144 -65
  133. package/src/cli-commands/misc.ts +4 -1
  134. package/src/cli-commands/project.ts +22 -38
  135. package/src/cli-commands/send.ts +6 -1
  136. package/src/cli-commands/session.ts +294 -139
  137. package/src/cli-commands/task.ts +22 -15
  138. package/src/cli-commands/thread.ts +86 -0
  139. package/src/cli-parsing.test.ts +34 -1
  140. package/src/cli-runner.ts +16 -3
  141. package/src/cli-send-thread.e2e.test.ts +9 -11
  142. package/src/cli.ts +2 -0
  143. package/src/commands/action-buttons.ts +2 -1
  144. package/src/commands/add-project.ts +4 -8
  145. package/src/commands/agent.ts +199 -23
  146. package/src/commands/ask-question.ts +2 -1
  147. package/src/commands/btw.ts +40 -5
  148. package/src/commands/file-upload.ts +2 -1
  149. package/src/commands/fork-subagent.ts +2 -3
  150. package/src/commands/fork.ts +4 -3
  151. package/src/commands/last-sessions.ts +10 -4
  152. package/src/commands/merge-worktree.ts +13 -6
  153. package/src/commands/model-variant.ts +3 -2
  154. package/src/commands/model.ts +4 -3
  155. package/src/commands/queue.test.ts +19 -0
  156. package/src/commands/queue.ts +109 -42
  157. package/src/commands/restart-opencode-server.ts +12 -1
  158. package/src/commands/resume.ts +4 -3
  159. package/src/commands/session-id.ts +5 -5
  160. package/src/commands/session.ts +94 -70
  161. package/src/cpu-profiler.test.ts +183 -0
  162. package/src/cpu-profiler.ts +293 -0
  163. package/src/database.ts +154 -0
  164. package/src/db.test.ts +217 -0
  165. package/src/db.ts +50 -0
  166. package/src/default-channel-provisioning.test.ts +156 -0
  167. package/src/discord-bot.ts +54 -9
  168. package/src/discord-command-registration.test.ts +20 -0
  169. package/src/discord-command-registration.ts +30 -12
  170. package/src/discord-utils.test.ts +157 -0
  171. package/src/discord-utils.ts +131 -1
  172. package/src/errors.ts +7 -0
  173. package/src/external-directory-permissions.e2e.test.ts +11 -7
  174. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  175. package/src/external-opencode-sync.test.ts +248 -0
  176. package/src/external-opencode-sync.ts +122 -19
  177. package/src/extract-sdk-error-message.test.ts +18 -0
  178. package/src/gateway-proxy.e2e.test.ts +18 -17
  179. package/src/guild-categories.test.ts +260 -0
  180. package/src/interaction-handler.ts +43 -13
  181. package/src/ipc-tools-plugin.ts +16 -9
  182. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +5 -5
  183. package/src/kimaki-opencode-plugin.test.ts +34 -0
  184. package/src/kimaki-opencode-plugin.ts +4 -0
  185. package/src/list-channel-threads.test.ts +90 -0
  186. package/src/list-channel-threads.ts +252 -0
  187. package/src/logger.ts +1 -0
  188. package/src/message-formatting.test.ts +690 -13
  189. package/src/message-formatting.ts +218 -46
  190. package/src/message-preprocessing.ts +116 -1
  191. package/src/opencode-command.test.ts +59 -0
  192. package/src/opencode-command.ts +38 -0
  193. package/src/opencode-interrupt-plugin.test.ts +32 -0
  194. package/src/opencode-interrupt-plugin.ts +3 -0
  195. package/src/opencode.ts +44 -4
  196. package/src/project-add.test.ts +80 -0
  197. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  198. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  199. package/src/question-text-before-dropdown.e2e.test.ts +5 -5
  200. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  201. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  202. package/src/queue-advanced-footer.e2e.test.ts +30 -31
  203. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  204. package/src/queue-advanced-permissions-typing.e2e.test.ts +9 -7
  205. package/src/queue-advanced-question.e2e.test.ts +2 -2
  206. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  207. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  208. package/src/queue-delete-message.e2e.test.ts +3 -3
  209. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  210. package/src/queue-interrupt-drain.e2e.test.ts +20 -20
  211. package/src/queue-question-select-drain.e2e.test.ts +7 -7
  212. package/src/runtime-lifecycle.e2e.test.ts +16 -18
  213. package/src/schema.sql +17 -1
  214. package/src/schema.ts +24 -1
  215. package/src/session-handler/event-stream-state.test.ts +579 -0
  216. package/src/session-handler/event-stream-state.ts +235 -4
  217. package/src/session-handler/model-utils.test.ts +125 -3
  218. package/src/session-handler/model-utils.ts +36 -4
  219. package/src/session-handler/opencode-session-event-log.ts +31 -1
  220. package/src/session-handler/thread-ingress.test.ts +54 -0
  221. package/src/session-handler/thread-runtime-state.ts +8 -0
  222. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  223. package/src/session-handler/thread-session-runtime.ts +797 -229
  224. package/src/session-search.test.ts +281 -0
  225. package/src/session-search.ts +241 -7
  226. package/src/session-title-rename.test.ts +45 -0
  227. package/src/sleep.e2e.test.ts +14 -11
  228. package/src/store.ts +1 -0
  229. package/src/system-message.test.ts +191 -23
  230. package/src/system-message.ts +124 -23
  231. package/src/task-id-plugin.ts +17 -0
  232. package/src/task-runner.ts +2 -1
  233. package/src/task-schedule.test.ts +94 -0
  234. package/src/task-schedule.ts +47 -2
  235. package/src/test-utils.ts +21 -10
  236. package/src/thinking-utils.test.ts +46 -0
  237. package/src/thinking-utils.ts +21 -0
  238. package/src/thread-message-queue.e2e.test.ts +65 -53
  239. package/src/undo-redo.e2e.test.ts +17 -9
  240. package/src/voice-handler.ts +23 -4
  241. package/src/voice-message.e2e.test.ts +149 -29
  242. package/src/voice-routing-input.e2e.test.ts +203 -0
  243. package/src/voice-routing-question.e2e.test.ts +131 -0
  244. package/src/voice.test.ts +44 -0
  245. package/src/voice.ts +34 -6
  246. package/src/worktree-lifecycle.e2e.test.ts +30 -30
@@ -13,20 +13,21 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import url from 'node:url';
15
15
  import { describe, beforeAll, afterAll, test, expect, } from 'vitest';
16
- import { ChannelType, Client, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder } from 'discord.js';
16
+ import { ChannelType, Client, GatewayIntentBits, Partials, REST, Routes } from 'discord.js';
17
17
  import { DigitalDiscord } from 'discord-digital-twin/src';
18
18
  import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
19
19
  import { setDataDir } from './config.js';
20
20
  import { store } from './store.js';
21
21
  import { startDiscordBot } from './discord-bot.js';
22
- import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionModel, getSessionAgent, getChannelAgent, setSessionModel, } from './database.js';
22
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionModel, getSessionAgent, getChannelAgent, getChannelModel, setSessionModel, } from './database.js';
23
23
  import { getDb } from './db.js';
24
24
  import * as orm from 'drizzle-orm';
25
25
  import * as schema from './schema.js';
26
26
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
27
27
  import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
28
- import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
28
+ import { chooseLockPort, cleanupTestSessions, initTestGitRepo, isFooterMessage, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
29
29
  import { buildQuickAgentCommandDescription } from './commands/agent.js';
30
+ import { buildQuickAgentSlashCommand } from './discord-command-registration.js';
30
31
  const TEST_USER_ID = '200000000000000920';
31
32
  const TEXT_CHANNEL_ID = '200000000000000921';
32
33
  const AGENT_MODEL = 'agent-model-v2';
@@ -265,6 +266,9 @@ describe('agent model resolution', () => {
265
266
  }
266
267
  providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
267
268
  providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL };
269
+ Object.assign(providerConfig.models[PLAN_AGENT_MODEL], {
270
+ variants: { high: {}, max: {} },
271
+ });
268
272
  providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
269
273
  fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
270
274
  // Leading /command detection only rewrites when registeredUserCommands is set
@@ -316,18 +320,13 @@ describe('agent model resolution', () => {
316
320
  // Register quick agent slash commands so /plan-agent and /test-agent-agent
317
321
  // are resolvable by handleQuickAgentCommand via guild.commands.fetch().
318
322
  const agentCommands = ['test-agent', 'plan', 'plain'].map((agentName) => {
319
- return new SlashCommandBuilder()
320
- .setName(`${agentName}-agent`)
321
- .setDescription(buildQuickAgentCommandDescription({
322
- agentName,
323
- description: `Switch to ${agentName} agent`,
324
- }))
325
- .setDMPermission(false)
326
- .addStringOption((opt) => opt
327
- .setName('prompt')
328
- .setDescription('Send a prompt with this agent')
329
- .setRequired(false))
330
- .toJSON();
323
+ return buildQuickAgentSlashCommand({
324
+ commandName: `${agentName}-agent`,
325
+ description: buildQuickAgentCommandDescription({
326
+ agentName,
327
+ description: `Switch to ${agentName} agent`,
328
+ }),
329
+ }).toJSON();
331
330
  });
332
331
  const rest = new REST({ version: '10', api: discord.restUrl }).setToken(discord.botToken);
333
332
  await rest.put(Routes.applicationGuildCommands(discord.botUserId, discord.guildId), { body: agentCommands });
@@ -394,15 +393,15 @@ describe('agent model resolution', () => {
394
393
  // Find the footer message (starts with * italic)
395
394
  const footerMessage = messages.find((message) => {
396
395
  return (message.author.id === discord.botUserId &&
397
- message.content.startsWith('*'));
396
+ isFooterMessage({ message, botUserId: discord.botUserId }));
398
397
  });
399
398
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
400
399
  "--- from: user (agent-model-tester)
401
400
  Reply with exactly: agent-model-check
402
401
  --- from: assistant (TestBot)
403
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
404
- ⬥ ok
405
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
402
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
403
+ > ok
404
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
406
405
  `);
407
406
  expect(footerMessage).toBeDefined();
408
407
  if (!footerMessage) {
@@ -444,9 +443,9 @@ describe('agent model resolution', () => {
444
443
  "--- from: user (agent-model-tester)
445
444
  Reply with exactly: system-context-check
446
445
  --- from: assistant (TestBot)
447
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
448
- ⬥ system-context-ok
449
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
446
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
447
+ > system-context-ok
448
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
450
449
  `);
451
450
  }, 15_000);
452
451
  test('session.command path includes kimaki system prompt on first message', async () => {
@@ -514,7 +513,7 @@ describe('agent model resolution', () => {
514
513
  const threadText = await discord.thread(thread.id).text();
515
514
  expect(threadText).toContain('first message in thread');
516
515
  expect(threadText).toContain('Reply with exactly: reply-context-check');
517
- expect(threadText).toContain('⬥ ok');
516
+ expect(threadText).toContain('ok');
518
517
  }, 15_000);
519
518
  test('new thread uses channel model when channel model preference is set', async () => {
520
519
  // Clear channel agent so model resolution falls through to channel model
@@ -544,15 +543,15 @@ describe('agent model resolution', () => {
544
543
  const messages = await discord.thread(thread.id).getMessages();
545
544
  const footerMessage = messages.find((message) => {
546
545
  return (message.author.id === discord.botUserId &&
547
- message.content.startsWith('*'));
546
+ isFooterMessage({ message, botUserId: discord.botUserId }));
548
547
  });
549
548
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
550
549
  "--- from: user (agent-model-tester)
551
550
  Reply with exactly: channel-model-check
552
551
  --- from: assistant (TestBot)
553
- *using deterministic-provider/channel-model-v2*
554
- ⬥ ok
555
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>"
552
+ > *using deterministic-provider/channel-model-v2*
553
+ > ok
554
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>"
556
555
  `);
557
556
  expect(footerMessage).toBeDefined();
558
557
  if (!footerMessage) {
@@ -588,15 +587,15 @@ describe('agent model resolution', () => {
588
587
  });
589
588
  const footer = messages.find((message) => {
590
589
  return message.author.id === discord.botUserId
591
- && message.content.startsWith('*');
590
+ && isFooterMessage({ message, botUserId: discord.botUserId });
592
591
  });
593
592
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
594
593
  "--- from: user (agent-model-tester)
595
594
  Reply with exactly: variant-check
596
595
  --- from: assistant (TestBot)
597
- *using deterministic-provider/channel-model-v2*
598
- ⬥ ok
599
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>"
596
+ > *using deterministic-provider/channel-model-v2*
597
+ > ok
598
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>"
600
599
  `);
601
600
  expect(footer?.content).toContain(CHANNEL_MODEL);
602
601
  expect(footer?.content).not.toContain(DEFAULT_MODEL);
@@ -662,8 +661,8 @@ describe('agent model resolution', () => {
662
661
  "--- from: assistant (TestBot)
663
662
  Reusing context from <#SOURCE_THREAD> to answer prompt...
664
663
  Reply with exactly: btw-model-check
665
- ⬥ ok
666
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2* <@200000000000000920>"
664
+ > ok
665
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2* <@200000000000000920>"
667
666
  `);
668
667
  expect(forkedSessionModel).toMatchInlineSnapshot(`
669
668
  {
@@ -696,7 +695,7 @@ describe('agent model resolution', () => {
696
695
  });
697
696
  const firstMessages = await discord.thread(thread.id).getMessages();
698
697
  const firstFooter = firstMessages.find((m) => {
699
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
698
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
700
699
  });
701
700
  expect(firstFooter).toBeDefined();
702
701
  // Verify the first run used test-agent's model
@@ -726,20 +725,20 @@ describe('agent model resolution', () => {
726
725
  "--- from: user (agent-model-tester)
727
726
  Reply with exactly: first-thread-msg
728
727
  --- from: assistant (TestBot)
729
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
730
- ⬥ ok
731
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
728
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
729
+ > ok
730
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
732
731
  --- from: user (agent-model-tester)
733
732
  Reply with exactly: second-thread-msg
734
733
  --- from: assistant (TestBot)
735
- ⬥ ok
736
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
734
+ > ok
735
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
737
736
  `);
738
737
  const secondMessages = await discord.thread(thread.id).getMessages();
739
738
  const secondFooter = [...secondMessages]
740
739
  .reverse()
741
740
  .find((m) => {
742
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
741
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
743
742
  });
744
743
  expect(secondFooter).toBeDefined();
745
744
  // The existing thread should still use test-agent's model (AGENT_MODEL),
@@ -780,10 +779,94 @@ describe('agent model resolution', () => {
780
779
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
781
780
  "--- from: assistant (TestBot)
782
781
  » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
783
- *using deterministic-provider/plan-model-v2 ⋅ plan*
784
- ⬥ ok
785
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
782
+ > *using deterministic-provider/plan-model-v2 ⋅ plan*
783
+ > ok
784
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
785
+ `);
786
+ }, 20_000);
787
+ test('/plan-agent with prompt persists a supported thinking variant', async () => {
788
+ const prompt = 'Reply with exactly: inline-plan-agent-variant-msg';
789
+ const { id: interactionId } = await discord
790
+ .channel(TEXT_CHANNEL_ID)
791
+ .user(TEST_USER_ID)
792
+ .runSlashCommand({
793
+ name: 'plan-agent',
794
+ options: [
795
+ { name: 'prompt', type: 3, value: prompt },
796
+ { name: 'variant', type: 3, value: 'high' },
797
+ ],
798
+ });
799
+ await discord
800
+ .channel(TEXT_CHANNEL_ID)
801
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
802
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
803
+ timeout: 4_000,
804
+ predicate: (t) => t.name === prompt,
805
+ });
806
+ await waitForFooterMessage({
807
+ discord,
808
+ threadId: thread.id,
809
+ timeout: 4_000,
810
+ afterMessageIncludes: 'ok',
811
+ afterAuthorId: discord.botUserId,
812
+ });
813
+ const sessionId = await getThreadSession(thread.id);
814
+ expect(sessionId).toBeDefined();
815
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
816
+ "--- from: assistant (TestBot)
817
+ » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-variant-msg
818
+ > *using deterministic-provider/plan-model-v2 ⋅ plan*
819
+ > ok
820
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
821
+ `);
822
+ expect(sessionId ? await getSessionModel(sessionId) : undefined).toMatchInlineSnapshot(`
823
+ {
824
+ "modelId": "deterministic-provider/plan-model-v2",
825
+ "variant": "high",
826
+ }
827
+ `);
828
+ }, 20_000);
829
+ test('/plan-agent variant persists on the channel without a prompt', async () => {
830
+ const db = await getDb();
831
+ await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
832
+ const { id: interactionId } = await discord
833
+ .channel(TEXT_CHANNEL_ID)
834
+ .user(TEST_USER_ID)
835
+ .runSlashCommand({
836
+ name: 'plan-agent',
837
+ options: [{ name: 'variant', type: 3, value: 'max' }],
838
+ });
839
+ await discord
840
+ .channel(TEXT_CHANNEL_ID)
841
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
842
+ const start = Date.now();
843
+ let confirmation = '';
844
+ while (Date.now() - start < 4_000) {
845
+ const messages = await discord.channel(TEXT_CHANNEL_ID).getMessages();
846
+ const match = [...messages].reverse().find((message) => {
847
+ return (message.author.id === discord.botUserId &&
848
+ message.content.includes('Variant: **max**'));
849
+ });
850
+ if (match) {
851
+ confirmation = match.content;
852
+ break;
853
+ }
854
+ await new Promise((resolve) => setTimeout(resolve, 100));
855
+ }
856
+ expect(confirmation).toMatchInlineSnapshot(`
857
+ "Switched to **plan** agent for this channel (was **test-agent**)
858
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
859
+ Variant: **max**
860
+ All new sessions will use this agent."
861
+ `);
862
+ expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('plan');
863
+ expect(await getChannelModel(TEXT_CHANNEL_ID)).toMatchInlineSnapshot(`
864
+ {
865
+ "modelId": "deterministic-provider/plan-model-v2",
866
+ "variant": "max",
867
+ }
786
868
  `);
869
+ await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
787
870
  }, 20_000);
788
871
  test('/plan-agent with prompt in an existing thread changes the session agent', async () => {
789
872
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
@@ -825,12 +908,12 @@ describe('agent model resolution', () => {
825
908
  "--- from: user (agent-model-tester)
826
909
  Reply with exactly: inline-existing-first-msg
827
910
  --- from: assistant (TestBot)
828
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
829
- ⬥ ok
830
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
911
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
912
+ > ok
913
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
831
914
  » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
832
- ⬥ ok
833
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
915
+ > ok
916
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
834
917
  `);
835
918
  }, 20_000);
836
919
  test('/plan-agent inside a thread switches the model for that thread', async () => {
@@ -854,7 +937,7 @@ describe('agent model resolution', () => {
854
937
  afterAuthorId: discord.botUserId,
855
938
  });
856
939
  const firstFooter = (await discord.thread(thread.id).getMessages()).find((m) => {
857
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
940
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
858
941
  });
859
942
  expect(firstFooter).toBeDefined();
860
943
  expect(firstFooter.content).toContain(AGENT_MODEL);
@@ -879,28 +962,128 @@ describe('agent model resolution', () => {
879
962
  "--- from: user (agent-model-tester)
880
963
  Reply with exactly: switch-in-thread-msg
881
964
  --- from: assistant (TestBot)
882
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
883
- ⬥ ok
884
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
965
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
966
+ > ok
967
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
885
968
  Switched to **plan** agent for this session (was **test-agent**)
886
969
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
887
970
  The agent will change on the next message.
888
971
  --- from: user (agent-model-tester)
889
972
  Reply with exactly: after-switch-msg
890
973
  --- from: assistant (TestBot)
891
- ⬥ ok
892
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
974
+ > ok
975
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
893
976
  `);
894
977
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
895
978
  .reverse()
896
979
  .find((m) => {
897
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
980
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
898
981
  });
899
982
  expect(secondFooter).toBeDefined();
900
983
  // After /plan-agent in the thread, model should switch to plan's model
901
984
  expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
902
985
  expect(secondFooter.content).not.toContain(AGENT_MODEL);
903
986
  }, 20_000);
987
+ test('/plan-agent then an immediate follow-up uses the new agent', async () => {
988
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
989
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
990
+ content: 'Reply with exactly: race-switch-first-msg',
991
+ });
992
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
993
+ timeout: 4_000,
994
+ predicate: (t) => {
995
+ return t.name === 'Reply with exactly: race-switch-first-msg';
996
+ },
997
+ });
998
+ await waitForFooterMessage({
999
+ discord,
1000
+ threadId: thread.id,
1001
+ timeout: 4_000,
1002
+ afterMessageIncludes: 'ok',
1003
+ afterAuthorId: discord.botUserId,
1004
+ });
1005
+ const th = discord.thread(thread.id);
1006
+ const { id: interactionId } = await th
1007
+ .user(TEST_USER_ID)
1008
+ .runSlashCommand({ name: 'plan-agent' });
1009
+ await th.user(TEST_USER_ID).sendMessage({
1010
+ content: 'Reply with exactly: race-switch-follow-up',
1011
+ });
1012
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
1013
+ await waitForFooterMessage({
1014
+ discord,
1015
+ threadId: thread.id,
1016
+ timeout: 4_000,
1017
+ afterMessageIncludes: 'race-switch-follow-up',
1018
+ afterAuthorId: TEST_USER_ID,
1019
+ });
1020
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
1021
+ "--- from: user (agent-model-tester)
1022
+ Reply with exactly: race-switch-first-msg
1023
+ --- from: assistant (TestBot)
1024
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1025
+ > ok
1026
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
1027
+ --- from: user (agent-model-tester)
1028
+ Reply with exactly: race-switch-follow-up
1029
+ --- from: assistant (TestBot)
1030
+ Switched to **plan** agent for this session (was **test-agent**)
1031
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
1032
+ The agent will change on the next message.
1033
+ > ok
1034
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
1035
+ `);
1036
+ const secondFooter = [...(await discord.thread(thread.id).getMessages())]
1037
+ .reverse()
1038
+ .find((m) => {
1039
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
1040
+ });
1041
+ expect(secondFooter).toBeDefined();
1042
+ expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
1043
+ expect(secondFooter.content).not.toContain(AGENT_MODEL);
1044
+ }, 20_000);
1045
+ test('/plan-agent in a channel then an immediate message uses the new agent', async () => {
1046
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
1047
+ const { id: interactionId } = await discord
1048
+ .channel(TEXT_CHANNEL_ID)
1049
+ .user(TEST_USER_ID)
1050
+ .runSlashCommand({ name: 'plan-agent' });
1051
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1052
+ content: 'Reply with exactly: race-channel-follow-up',
1053
+ });
1054
+ await discord
1055
+ .channel(TEXT_CHANNEL_ID)
1056
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
1057
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1058
+ timeout: 4_000,
1059
+ predicate: (t) => {
1060
+ return t.name === 'Reply with exactly: race-channel-follow-up';
1061
+ },
1062
+ });
1063
+ await waitForFooterMessage({
1064
+ discord,
1065
+ threadId: thread.id,
1066
+ timeout: 4_000,
1067
+ afterMessageIncludes: 'ok',
1068
+ afterAuthorId: discord.botUserId,
1069
+ });
1070
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
1071
+ "--- from: user (agent-model-tester)
1072
+ Reply with exactly: race-channel-follow-up
1073
+ --- from: assistant (TestBot)
1074
+ > *using deterministic-provider/plan-model-v2 ⋅ plan*
1075
+ > ok
1076
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
1077
+ `);
1078
+ const footer = [...(await discord.thread(thread.id).getMessages())]
1079
+ .reverse()
1080
+ .find((m) => {
1081
+ return isFooterMessage({ message: m, botUserId: discord.botUserId });
1082
+ });
1083
+ expect(footer).toBeDefined();
1084
+ expect(footer.content).toContain(PLAN_AGENT_MODEL);
1085
+ expect(footer.content).not.toContain(AGENT_MODEL);
1086
+ }, 20_000);
904
1087
  test('/plan-agent on the same agent refreshes a stale session model', async () => {
905
1088
  await setChannelAgent(TEXT_CHANNEL_ID, 'plan');
906
1089
  await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
@@ -936,9 +1119,9 @@ describe('agent model resolution', () => {
936
1119
  "--- from: user (agent-model-tester)
937
1120
  Reply with exactly: refresh-agent-model-msg
938
1121
  --- from: assistant (TestBot)
939
- *using deterministic-provider/plan-model-v2 ⋅ plan*
940
- ⬥ ok
941
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>
1122
+ > *using deterministic-provider/plan-model-v2 ⋅ plan*
1123
+ > ok
1124
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>
942
1125
  Using **plan** agent for this session
943
1126
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
944
1127
  The agent will change on the next message."
@@ -978,9 +1161,9 @@ describe('agent model resolution', () => {
978
1161
  "--- from: user (agent-model-tester)
979
1162
  Reply with exactly: channel-vs-agent-msg
980
1163
  --- 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>
1164
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1165
+ > ok
1166
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
984
1167
  Switched to **plan** agent for this session (was **test-agent**)
985
1168
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
986
1169
  The agent will change on the next message."
@@ -1020,9 +1203,9 @@ describe('agent model resolution', () => {
1020
1203
  "--- from: user (agent-model-tester)
1021
1204
  Reply with exactly: plain-agent-override-msg
1022
1205
  --- from: assistant (TestBot)
1023
- *using deterministic-provider/channel-model-v2*
1024
- ⬥ ok
1025
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2* <@200000000000000920>
1206
+ > *using deterministic-provider/channel-model-v2*
1207
+ > ok
1208
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>
1026
1209
  Switched to **plain** agent for this session
1027
1210
  Model: *deterministic-provider/channel-model-v2* (channel override)
1028
1211
  This model comes from a channel override. Use /model and press Clear override if you want this agent's model.
@@ -1,5 +1,12 @@
1
1
  // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
2
- // Helpers for extracting and normalizing Anthropic OAuth account identity.
2
+ // Helpers for Anthropic OAuth account and Claude Code client identity.
3
+ const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.257 (external, cli)';
4
+ export function applyClaudeCodeRequestIdentity({ headers, accessToken, }) {
5
+ headers.set('authorization', `Bearer ${accessToken}`);
6
+ headers.set('user-agent', process.env.OPENCODE_ANTHROPIC_USER_AGENT || CLAUDE_CODE_USER_AGENT);
7
+ headers.set('x-app', 'cli');
8
+ return headers;
9
+ }
3
10
  const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer']);
4
11
  const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub'];
5
12
  export function normalizeAnthropicAccountIdentity(identity) {
@@ -26,7 +26,7 @@
26
26
  import { appendToastSessionMarker } from "./plugin-logger.js";
27
27
  import { createPluginClient } from "./plugin-opencode-client.js";
28
28
  import { isPermanentOAuthRefreshFailure, loadAccountStore, rememberAnthropicOAuth, removeAccountByAuth, rotateAnthropicAccount, saveAccountStore, setAnthropicAuth, shouldRotateAuth, upsertAccount, withAuthStateLock, } from "./anthropic-auth-state.js";
29
- import { extractAnthropicAccountIdentity, } from "./anthropic-account-identity.js";
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
32
32
  function base64urlEncode(bytes) {
@@ -63,7 +63,6 @@ const CALLBACK_PATH = "/callback";
63
63
  const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
64
64
  const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
65
65
  const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
66
- const CLAUDE_CODE_VERSION = "2.1.75";
67
66
  const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
68
67
  const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
69
68
  // Subagent prompts don't contain OPENCODE_IDENTITY; opencode appends this
@@ -243,13 +242,10 @@ async function fetchAnthropicAccountIdentity(accessToken) {
243
242
  for (const url of urls) {
244
243
  const responseText = await requestText(url, {
245
244
  method: "GET",
246
- headers: {
247
- Accept: "application/json",
248
- authorization: `Bearer ${accessToken}`,
249
- "user-agent": process.env.OPENCODE_ANTHROPIC_USER_AGENT ||
250
- `claude-cli/${CLAUDE_CODE_VERSION}`,
251
- "x-app": "cli",
252
- },
245
+ headers: Object.fromEntries(applyClaudeCodeRequestIdentity({
246
+ headers: new Headers({ Accept: "application/json" }),
247
+ accessToken,
248
+ }).entries()),
253
249
  }).catch(() => {
254
250
  return undefined;
255
251
  });
@@ -877,10 +873,10 @@ const AnthropicAuthPlugin = async ({ serverUrl, directory }) => {
877
873
  requestHeaders.set("accept", "application/json");
878
874
  requestHeaders.set("anthropic-beta", mergeBetas(requestHeaders.get("anthropic-beta"), betas));
879
875
  requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");
880
- requestHeaders.set("authorization", `Bearer ${auth.access}`);
881
- requestHeaders.set("user-agent", process.env.OPENCODE_ANTHROPIC_USER_AGENT ||
882
- `claude-cli/${CLAUDE_CODE_VERSION}`);
883
- requestHeaders.set("x-app", "cli");
876
+ applyClaudeCodeRequestIdentity({
877
+ headers: requestHeaders,
878
+ accessToken: auth.access,
879
+ });
884
880
  requestHeaders.delete("x-api-key");
885
881
  return fetch(input, {
886
882
  ...(init ?? {}),
@@ -1,5 +1,6 @@
1
1
  // Tests Anthropic system prompt rewriting so project instructions survive OpenCode prompt layout changes.
2
2
  import { describe, expect, test } from 'vitest';
3
+ import { applyClaudeCodeRequestIdentity } from './anthropic-account-identity.js';
3
4
  import { replacer } from './anthropic-auth-plugin.js';
4
5
  async function transformSystem(systemText) {
5
6
  const plugin = await replacer({});
@@ -12,6 +13,22 @@ async function transformSystem(systemText) {
12
13
  }, output);
13
14
  return output.system.join('\n');
14
15
  }
16
+ describe('Anthropic OAuth request identity', () => {
17
+ test('applies the current external Claude Code client headers', () => {
18
+ const headers = applyClaudeCodeRequestIdentity({
19
+ headers: new Headers({ Accept: 'application/json' }),
20
+ accessToken: 'access-token',
21
+ });
22
+ expect(Object.fromEntries(headers.entries())).toMatchInlineSnapshot(`
23
+ {
24
+ "accept": "application/json",
25
+ "authorization": "Bearer access-token",
26
+ "user-agent": "claude-cli/2.1.257 (external, cli)",
27
+ "x-app": "cli",
28
+ }
29
+ `);
30
+ });
31
+ });
15
32
  describe('Anthropic system prompt rewriting', () => {
16
33
  test('preserves instructions when OpenCode places them before skills', async () => {
17
34
  const transformed = await transformSystem(`You are OpenCode, the best coding agent on the planet.
@@ -0,0 +1,50 @@
1
+ // Adds description and hasSideEffect to the built-in bash tool schema.
2
+ // Uses tool.definition so OpenCode keeps the real bash execute.
3
+ // Assign jsonSchema only. Leave parameters as the Effect Schema used to decode
4
+ // args before execute. Extra fields stay in the model-facing schema and in
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.
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
+ }
22
+ export const bashParameters = z.object({
23
+ command: z.string().describe('The command to execute'),
24
+ description: z
25
+ .string()
26
+ .describe('Short 5-10 word summary shown in Discord when the command is longer than 50 characters'),
27
+ hasSideEffect: z
28
+ .boolean()
29
+ .describe('True if the command writes files, modifies state, installs packages, or triggers external effects'),
30
+ workdir: z
31
+ .string()
32
+ .optional()
33
+ .describe('Working directory. Defaults to the current directory.'),
34
+ timeout: z.number().int().optional().describe('Optional timeout in milliseconds'),
35
+ });
36
+ export function extendBashToolDefinition(output) {
37
+ output.jsonSchema = z.toJSONSchema(bashParameters);
38
+ }
39
+ export const bashToolSchemaPlugin = async () => {
40
+ return {
41
+ 'tool.definition': async (input, output) => {
42
+ if (input.toolID !== 'bash')
43
+ return;
44
+ extendBashToolDefinition(output);
45
+ },
46
+ 'shell.env': async (input, output) => {
47
+ injectKimakiSessionEnv({ sessionID: input.sessionID, env: output.env });
48
+ },
49
+ };
50
+ };