kimaki 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) 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 +32 -0
  6. package/dist/bash-tool-schema-plugin.test.js +49 -0
  7. package/dist/cli-commands/project.js +11 -27
  8. package/dist/cli-commands/send.js +6 -1
  9. package/dist/cli-commands/session.js +139 -82
  10. package/dist/cli-commands/task.js +21 -14
  11. package/dist/cli-parsing.test.js +24 -1
  12. package/dist/cli-runner.js +16 -4
  13. package/dist/cli-send-thread.e2e.test.js +9 -9
  14. package/dist/commands/action-buttons.js +2 -1
  15. package/dist/commands/add-project.js +4 -7
  16. package/dist/commands/agent.js +158 -24
  17. package/dist/commands/ask-question.js +2 -1
  18. package/dist/commands/btw.js +28 -6
  19. package/dist/commands/file-upload.js +2 -1
  20. package/dist/commands/fork-subagent.js +2 -4
  21. package/dist/commands/fork.js +4 -4
  22. package/dist/commands/last-sessions.js +17 -10
  23. package/dist/commands/merge-worktree.js +13 -6
  24. package/dist/commands/model-variant.js +3 -2
  25. package/dist/commands/model.js +4 -3
  26. package/dist/commands/queue.js +3 -2
  27. package/dist/commands/resume.js +4 -4
  28. package/dist/commands/session.js +78 -63
  29. package/dist/cpu-profiler.js +211 -0
  30. package/dist/cpu-profiler.test.js +160 -0
  31. package/dist/database.js +28 -0
  32. package/dist/db.js +11 -0
  33. package/dist/db.test.js +100 -0
  34. package/dist/default-channel-provisioning.test.js +148 -0
  35. package/dist/discord-bot.js +501 -474
  36. package/dist/discord-command-registration.js +16 -9
  37. package/dist/discord-command-registration.test.js +17 -0
  38. package/dist/discord-utils.js +84 -0
  39. package/dist/discord-utils.test.js +135 -1
  40. package/dist/errors.js +5 -0
  41. package/dist/external-directory-permissions.e2e.test.js +11 -7
  42. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  43. package/dist/external-opencode-sync.js +94 -17
  44. package/dist/external-opencode-sync.test.js +206 -0
  45. package/dist/extract-sdk-error-message.test.js +13 -0
  46. package/dist/gateway-proxy.e2e.test.js +18 -18
  47. package/dist/interaction-handler.js +416 -398
  48. package/dist/ipc-tools-plugin.js +12 -7
  49. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +2 -2
  50. package/dist/kimaki-opencode-plugin.js +2 -0
  51. package/dist/logger.js +1 -0
  52. package/dist/message-formatting.js +163 -44
  53. package/dist/message-formatting.test.js +473 -13
  54. package/dist/message-preprocessing.js +105 -1
  55. package/dist/opencode-command.js +28 -0
  56. package/dist/opencode-command.test.js +49 -1
  57. package/dist/opencode-interrupt-plugin.js +4 -0
  58. package/dist/opencode-interrupt-plugin.test.js +24 -0
  59. package/dist/opencode.js +29 -3
  60. package/dist/project-add.test.js +62 -0
  61. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  62. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  63. package/dist/question-text-before-dropdown.e2e.test.js +5 -5
  64. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  65. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  66. package/dist/queue-advanced-footer.e2e.test.js +29 -32
  67. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  68. package/dist/queue-advanced-permissions-typing.e2e.test.js +9 -7
  69. package/dist/queue-advanced-question.e2e.test.js +2 -2
  70. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  71. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  72. package/dist/queue-delete-message.e2e.test.js +3 -3
  73. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  74. package/dist/queue-interrupt-drain.e2e.test.js +20 -20
  75. package/dist/queue-question-select-drain.e2e.test.js +7 -7
  76. package/dist/runtime-lifecycle.e2e.test.js +16 -19
  77. package/dist/schema.js +1 -1
  78. package/dist/session-handler/event-stream-state.js +124 -0
  79. package/dist/session-handler/event-stream-state.test.js +322 -1
  80. package/dist/session-handler/model-utils.js +16 -3
  81. package/dist/session-handler/model-utils.test.js +103 -3
  82. package/dist/session-handler/opencode-session-event-log.js +30 -1
  83. package/dist/session-handler/thread-ingress.test.js +41 -0
  84. package/dist/session-handler/thread-runtime-state.js +2 -0
  85. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  86. package/dist/session-handler/thread-session-runtime.js +340 -135
  87. package/dist/session-search.js +123 -4
  88. package/dist/session-search.test.js +248 -1
  89. package/dist/session-title-rename.test.js +30 -0
  90. package/dist/sleep.e2e.test.js +14 -11
  91. package/dist/system-message.js +45 -12
  92. package/dist/system-message.test.js +117 -10
  93. package/dist/task-runner.js +2 -1
  94. package/dist/task-schedule.js +32 -2
  95. package/dist/task-schedule.test.js +79 -1
  96. package/dist/test-utils.js +17 -9
  97. package/dist/thinking-utils.js +10 -0
  98. package/dist/thinking-utils.test.js +35 -0
  99. package/dist/thread-message-queue.e2e.test.js +65 -54
  100. package/dist/undo-redo.e2e.test.js +17 -9
  101. package/dist/voice-handler.js +21 -4
  102. package/dist/voice-message.e2e.test.js +144 -30
  103. package/dist/voice-routing-input.e2e.test.js +195 -0
  104. package/dist/voice-routing-question.e2e.test.js +125 -0
  105. package/dist/voice.js +28 -8
  106. package/dist/voice.test.js +41 -1
  107. package/dist/worktree-lifecycle.e2e.test.js +30 -30
  108. package/package.json +8 -8
  109. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  110. package/skills/critique/SKILL.md +4 -4
  111. package/skills/errore/SKILL.md +56 -232
  112. package/skills/holocron/SKILL.md +117 -17
  113. package/src/agent-model.e2e.test.ts +293 -66
  114. package/src/anthropic-account-identity.ts +19 -1
  115. package/src/anthropic-auth-plugin.test.ts +19 -0
  116. package/src/anthropic-auth-plugin.ts +11 -16
  117. package/src/bash-tool-schema-plugin.test.ts +55 -0
  118. package/src/bash-tool-schema-plugin.ts +46 -0
  119. package/src/cli-commands/project.ts +14 -35
  120. package/src/cli-commands/send.ts +6 -1
  121. package/src/cli-commands/session.ts +171 -105
  122. package/src/cli-commands/task.ts +22 -15
  123. package/src/cli-parsing.test.ts +34 -1
  124. package/src/cli-runner.ts +16 -3
  125. package/src/cli-send-thread.e2e.test.ts +9 -11
  126. package/src/commands/action-buttons.ts +2 -1
  127. package/src/commands/add-project.ts +4 -8
  128. package/src/commands/agent.ts +199 -23
  129. package/src/commands/ask-question.ts +2 -1
  130. package/src/commands/btw.ts +40 -5
  131. package/src/commands/file-upload.ts +2 -1
  132. package/src/commands/fork-subagent.ts +2 -3
  133. package/src/commands/fork.ts +4 -3
  134. package/src/commands/last-sessions.ts +10 -4
  135. package/src/commands/merge-worktree.ts +13 -6
  136. package/src/commands/model-variant.ts +3 -2
  137. package/src/commands/model.ts +4 -3
  138. package/src/commands/queue.ts +3 -2
  139. package/src/commands/resume.ts +4 -3
  140. package/src/commands/session.ts +94 -70
  141. package/src/cpu-profiler.test.ts +183 -0
  142. package/src/cpu-profiler.ts +293 -0
  143. package/src/database.ts +40 -0
  144. package/src/db.test.ts +105 -0
  145. package/src/db.ts +12 -0
  146. package/src/default-channel-provisioning.test.ts +155 -0
  147. package/src/discord-bot.ts +43 -7
  148. package/src/discord-command-registration.test.ts +20 -0
  149. package/src/discord-command-registration.ts +27 -11
  150. package/src/discord-utils.test.ts +157 -0
  151. package/src/discord-utils.ts +131 -1
  152. package/src/errors.ts +7 -0
  153. package/src/external-directory-permissions.e2e.test.ts +11 -7
  154. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  155. package/src/external-opencode-sync.test.ts +248 -0
  156. package/src/external-opencode-sync.ts +122 -19
  157. package/src/extract-sdk-error-message.test.ts +18 -0
  158. package/src/gateway-proxy.e2e.test.ts +18 -17
  159. package/src/interaction-handler.ts +36 -13
  160. package/src/ipc-tools-plugin.ts +16 -9
  161. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +5 -5
  162. package/src/kimaki-opencode-plugin.ts +2 -0
  163. package/src/logger.ts +1 -0
  164. package/src/message-formatting.test.ts +586 -13
  165. package/src/message-formatting.ts +223 -46
  166. package/src/message-preprocessing.ts +116 -1
  167. package/src/opencode-command.test.ts +59 -0
  168. package/src/opencode-command.ts +38 -0
  169. package/src/opencode-interrupt-plugin.test.ts +32 -0
  170. package/src/opencode-interrupt-plugin.ts +3 -0
  171. package/src/opencode.ts +44 -4
  172. package/src/project-add.test.ts +80 -0
  173. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  174. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  175. package/src/question-text-before-dropdown.e2e.test.ts +5 -5
  176. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  177. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  178. package/src/queue-advanced-footer.e2e.test.ts +30 -31
  179. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  180. package/src/queue-advanced-permissions-typing.e2e.test.ts +9 -7
  181. package/src/queue-advanced-question.e2e.test.ts +2 -2
  182. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  183. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  184. package/src/queue-delete-message.e2e.test.ts +3 -3
  185. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  186. package/src/queue-interrupt-drain.e2e.test.ts +20 -20
  187. package/src/queue-question-select-drain.e2e.test.ts +7 -7
  188. package/src/runtime-lifecycle.e2e.test.ts +16 -18
  189. package/src/schema.sql +1 -1
  190. package/src/schema.ts +1 -1
  191. package/src/session-handler/event-stream-state.test.ts +353 -0
  192. package/src/session-handler/event-stream-state.ts +179 -0
  193. package/src/session-handler/model-utils.test.ts +125 -3
  194. package/src/session-handler/model-utils.ts +36 -4
  195. package/src/session-handler/opencode-session-event-log.ts +31 -1
  196. package/src/session-handler/thread-ingress.test.ts +54 -0
  197. package/src/session-handler/thread-runtime-state.ts +3 -0
  198. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  199. package/src/session-handler/thread-session-runtime.ts +427 -158
  200. package/src/session-search.test.ts +281 -0
  201. package/src/session-search.ts +241 -7
  202. package/src/session-title-rename.test.ts +45 -0
  203. package/src/sleep.e2e.test.ts +14 -11
  204. package/src/store.ts +1 -0
  205. package/src/system-message.test.ts +141 -10
  206. package/src/system-message.ts +66 -11
  207. package/src/task-runner.ts +2 -1
  208. package/src/task-schedule.test.ts +94 -0
  209. package/src/task-schedule.ts +47 -2
  210. package/src/test-utils.ts +21 -10
  211. package/src/thinking-utils.test.ts +46 -0
  212. package/src/thinking-utils.ts +21 -0
  213. package/src/thread-message-queue.e2e.test.ts +65 -53
  214. package/src/undo-redo.e2e.test.ts +17 -9
  215. package/src/voice-handler.ts +23 -4
  216. package/src/voice-message.e2e.test.ts +149 -29
  217. package/src/voice-routing-input.e2e.test.ts +203 -0
  218. package/src/voice-routing-question.e2e.test.ts +131 -0
  219. package/src/voice.test.ts +44 -0
  220. package/src/voice.ts +34 -6
  221. package/src/worktree-lifecycle.e2e.test.ts +30 -30
@@ -34,6 +34,21 @@ async function clearGlobalCommands({ rest, appId, }) {
34
34
  function truncateCommandDescription(description) {
35
35
  return description.slice(0, 100);
36
36
  }
37
+ export function buildQuickAgentSlashCommand({ commandName, description, }) {
38
+ return new SlashCommandBuilder()
39
+ .setName(commandName)
40
+ .setDescription(truncateCommandDescription(description))
41
+ .setDMPermission(false)
42
+ .addStringOption((opt) => opt
43
+ .setName('prompt')
44
+ .setDescription('Send a prompt with this agent')
45
+ .setRequired(false))
46
+ .addStringOption((opt) => opt
47
+ .setName('variant')
48
+ .setDescription('Model thinking level for this agent')
49
+ .setRequired(false)
50
+ .setAutocomplete(true));
51
+ }
37
52
  export async function registerCommands({ token, appId, guildIds, userCommands = [], agents = [], }) {
38
53
  const commands = [
39
54
  new SlashCommandBuilder()
@@ -373,15 +388,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
373
388
  agentName: agent.name,
374
389
  description: agent.description,
375
390
  });
376
- commands.push(new SlashCommandBuilder()
377
- .setName(commandName)
378
- .setDescription(truncateCommandDescription(description))
379
- .setDMPermission(false)
380
- .addStringOption((opt) => opt
381
- .setName('prompt')
382
- .setDescription('Send a prompt with this agent')
383
- .setRequired(false))
384
- .toJSON());
391
+ commands.push(buildQuickAgentSlashCommand({ commandName, description }).toJSON());
385
392
  }
386
393
  // 2. User-defined commands, MCP prompts, and skills (ordered by priority)
387
394
  // Also populate registeredUserCommands in the store for /queue-command autocomplete
@@ -0,0 +1,17 @@
1
+ // Tests Discord slash command payloads for quick agent commands.
2
+ import { describe, expect, test } from 'vitest';
3
+ import { buildQuickAgentSlashCommand } from './discord-command-registration.js';
4
+ describe('buildQuickAgentSlashCommand', () => {
5
+ test('puts variant last after prompt', () => {
6
+ const command = buildQuickAgentSlashCommand({
7
+ commandName: 'plan-agent',
8
+ description: 'Switch to plan agent',
9
+ }).toJSON();
10
+ expect(command.options?.map((option) => option.name)).toMatchInlineSnapshot(`
11
+ [
12
+ "prompt",
13
+ "variant",
14
+ ]
15
+ `);
16
+ });
17
+ });
@@ -9,6 +9,7 @@ const { ChannelType, GuildMember, MessageFlags, PermissionsBitField, REST, Route
9
9
  import { discordApiUrl } from './discord-urls.js';
10
10
  import { Lexer } from 'marked';
11
11
  import { splitTablesFromMarkdown } from './format-tables.js';
12
+ import { sessionPartContent, shouldLeadWithBlankLine, } from './message-formatting.js';
12
13
  import { getChannelDirectory, getThreadWorktreeOrWorkspace } from './database.js';
13
14
  import { DiscordOperationError } from './errors.js';
14
15
  import { limitHeadingDepth } from './limit-heading-depth.js';
@@ -222,6 +223,68 @@ export async function ensureThreadMember({ rest, threadId, userId, }) {
222
223
  if (addMemberResult instanceof Error)
223
224
  return addMemberResult;
224
225
  }
226
+ export function resolveFooterMentionUserId({ sessionUserId, botUserId, threadOwnerId, members, }) {
227
+ if (sessionUserId && sessionUserId !== botUserId)
228
+ return sessionUserId;
229
+ if (!botUserId || threadOwnerId !== botUserId)
230
+ return undefined;
231
+ const humans = members
232
+ .filter((member) => member.id && member.id !== botUserId && member.bot === false)
233
+ .sort((a, b) => {
234
+ const aJoined = a.joinedTimestamp ?? Number.POSITIVE_INFINITY;
235
+ const bJoined = b.joinedTimestamp ?? Number.POSITIVE_INFINITY;
236
+ if (aJoined !== bJoined)
237
+ return aJoined - bJoined;
238
+ return a.id.localeCompare(b.id);
239
+ });
240
+ return humans[0]?.id;
241
+ }
242
+ function footerMentionMembersFromCache(thread) {
243
+ return [...thread.members.cache.values()].map((member) => ({
244
+ id: member.id,
245
+ bot: member.user?.bot,
246
+ joinedTimestamp: member.joinedTimestamp ?? null,
247
+ }));
248
+ }
249
+ export async function resolveThreadFooterMentionUserId({ sessionUserId, thread, }) {
250
+ const botUserId = thread.client.user?.id;
251
+ if (sessionUserId && sessionUserId !== botUserId)
252
+ return sessionUserId;
253
+ if (!botUserId || thread.ownerId !== botUserId)
254
+ return undefined;
255
+ const cached = footerMentionMembersFromCache(thread);
256
+ if (cached.some((member) => member.bot === false && member.id !== botUserId)) {
257
+ return resolveFooterMentionUserId({
258
+ sessionUserId,
259
+ botUserId,
260
+ threadOwnerId: thread.ownerId ?? undefined,
261
+ members: cached,
262
+ });
263
+ }
264
+ const fetched = await thread.members.fetch().catch((e) => {
265
+ return new DiscordOperationError({ operation: 'fetchThreadMembers', cause: e });
266
+ });
267
+ if (fetched instanceof Error) {
268
+ discordLogger.warn(`[FOOTER] Failed to fetch thread members: ${fetched.message}`);
269
+ return resolveFooterMentionUserId({
270
+ sessionUserId,
271
+ botUserId,
272
+ threadOwnerId: thread.ownerId ?? undefined,
273
+ members: cached,
274
+ });
275
+ }
276
+ const members = [...fetched.values()].map((member) => ({
277
+ id: member.id,
278
+ bot: member.user?.bot,
279
+ joinedTimestamp: member.joinedTimestamp ?? null,
280
+ }));
281
+ return resolveFooterMentionUserId({
282
+ sessionUserId,
283
+ botUserId,
284
+ threadOwnerId: thread.ownerId ?? undefined,
285
+ members,
286
+ });
287
+ }
225
288
  /** Remove Discord mentions from text so they don't appear in thread titles */
226
289
  export function stripMentions(text) {
227
290
  return text
@@ -517,6 +580,27 @@ export async function sendThreadMessage(thread, content, options) {
517
580
  }
518
581
  return firstMessage;
519
582
  }
583
+ export async function sendSessionPartMessage(thread, content, options) {
584
+ return sendThreadMessage(thread, sessionPartContent({
585
+ content,
586
+ leadWithBlankLine: options?.leadWithBlankLine === true,
587
+ }), { flags: options?.flags });
588
+ }
589
+ export async function sendSessionPartBatches({ thread, batches, }) {
590
+ const sent = [];
591
+ let previousKind;
592
+ for (const batch of batches) {
593
+ const message = await sendSessionPartMessage(thread, batch.content, {
594
+ leadWithBlankLine: shouldLeadWithBlankLine({
595
+ previousKind,
596
+ nextKind: batch.kind,
597
+ }),
598
+ });
599
+ previousKind = batch.kind;
600
+ sent.push({ partIds: batch.partIds, message });
601
+ }
602
+ return sent;
603
+ }
520
604
  export function isThreadChannelType(type) {
521
605
  return [
522
606
  ChannelType.PublicThread,
@@ -1,6 +1,6 @@
1
1
  import { PermissionsBitField } from 'discord.js';
2
2
  import { afterEach, describe, expect, test } from 'vitest';
3
- import { hasKimakiAdminPermission, hasKimakiBotPermission, raceDiscordRename, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
3
+ import { hasKimakiAdminPermission, hasKimakiBotPermission, raceDiscordRename, resolveFooterMentionUserId, resolveGuildMessageMember, resolveThreadFooterMentionUserId, splitMarkdownForDiscord, } from './discord-utils.js';
4
4
  import { store } from './store.js';
5
5
  describe('splitMarkdownForDiscord', () => {
6
6
  test('never returns chunks over the max length with code fences', () => {
@@ -248,6 +248,140 @@ describe('resolveGuildMessageMember', () => {
248
248
  await expect(resolveGuildMessageMember(message)).resolves.toBe(null);
249
249
  });
250
250
  });
251
+ describe('resolveFooterMentionUserId', () => {
252
+ test('uses the session user when it is not the bot', () => {
253
+ expect(resolveFooterMentionUserId({
254
+ sessionUserId: 'user-1',
255
+ botUserId: 'bot-1',
256
+ threadOwnerId: 'bot-1',
257
+ members: [
258
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
259
+ { id: 'user-2', bot: false, joinedTimestamp: 2 },
260
+ ],
261
+ })).toBe('user-1');
262
+ });
263
+ test('skips bots and uses the earliest human member when the bot created the thread', () => {
264
+ expect(resolveFooterMentionUserId({
265
+ sessionUserId: 'bot-1',
266
+ botUserId: 'bot-1',
267
+ threadOwnerId: 'bot-1',
268
+ members: [
269
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
270
+ { id: 'other-bot', bot: true, joinedTimestamp: 2 },
271
+ { id: 'user-3', bot: false, joinedTimestamp: 4 },
272
+ { id: 'user-2', bot: false, joinedTimestamp: 3 },
273
+ ],
274
+ })).toBe('user-2');
275
+ });
276
+ test('does not mention anyone when the bot created the thread and no human member exists', () => {
277
+ expect(resolveFooterMentionUserId({
278
+ sessionUserId: 'bot-1',
279
+ botUserId: 'bot-1',
280
+ threadOwnerId: 'bot-1',
281
+ members: [
282
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
283
+ { id: 'other-bot', bot: true, joinedTimestamp: 2 },
284
+ ],
285
+ })).toBeUndefined();
286
+ });
287
+ test('uses the first human member when the bot created the thread and no session user is set', () => {
288
+ expect(resolveFooterMentionUserId({
289
+ sessionUserId: undefined,
290
+ botUserId: 'bot-1',
291
+ threadOwnerId: 'bot-1',
292
+ members: [
293
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
294
+ { id: 'user-2', bot: false, joinedTimestamp: 2 },
295
+ ],
296
+ })).toBe('user-2');
297
+ });
298
+ test('does not mention a member whose bot flag is unknown', () => {
299
+ expect(resolveFooterMentionUserId({
300
+ sessionUserId: undefined,
301
+ botUserId: 'bot-1',
302
+ threadOwnerId: 'bot-1',
303
+ members: [
304
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
305
+ { id: 'maybe-user', joinedTimestamp: 2 },
306
+ ],
307
+ })).toBeUndefined();
308
+ });
309
+ });
310
+ function fakeThread({ ownerId, botUserId, members, onFetch, }) {
311
+ const cache = new Map(members.map((member) => [
312
+ member.id,
313
+ {
314
+ id: member.id,
315
+ user: member.bot === undefined ? undefined : { bot: member.bot },
316
+ joinedTimestamp: member.joinedTimestamp ?? null,
317
+ },
318
+ ]));
319
+ return {
320
+ ownerId,
321
+ client: { user: { id: botUserId } },
322
+ members: {
323
+ cache,
324
+ fetch: onFetch ?? (() => {
325
+ throw new Error('should not fetch thread members');
326
+ }),
327
+ },
328
+ };
329
+ }
330
+ describe('resolveThreadFooterMentionUserId', () => {
331
+ test('does not fetch when a human session user is already known', async () => {
332
+ await expect(resolveThreadFooterMentionUserId({
333
+ sessionUserId: 'user-1',
334
+ thread: fakeThread({
335
+ ownerId: 'bot-1',
336
+ botUserId: 'bot-1',
337
+ members: [{ id: 'bot-1', bot: true }],
338
+ }),
339
+ })).resolves.toBe('user-1');
340
+ });
341
+ test('does not fetch when the thread owner is not the bot', async () => {
342
+ await expect(resolveThreadFooterMentionUserId({
343
+ sessionUserId: undefined,
344
+ thread: fakeThread({
345
+ ownerId: 'user-1',
346
+ botUserId: 'bot-1',
347
+ members: [{ id: 'user-1', bot: false }],
348
+ }),
349
+ })).resolves.toBeUndefined();
350
+ });
351
+ test('uses cached humans without fetching', async () => {
352
+ await expect(resolveThreadFooterMentionUserId({
353
+ sessionUserId: undefined,
354
+ thread: fakeThread({
355
+ ownerId: 'bot-1',
356
+ botUserId: 'bot-1',
357
+ members: [
358
+ { id: 'bot-1', bot: true, joinedTimestamp: 1 },
359
+ { id: 'user-2', bot: false, joinedTimestamp: 2 },
360
+ ],
361
+ }),
362
+ })).resolves.toBe('user-2');
363
+ });
364
+ test('fetches only when the bot owns the thread and no human is cached', async () => {
365
+ let fetched = false;
366
+ const thread = fakeThread({
367
+ ownerId: 'bot-1',
368
+ botUserId: 'bot-1',
369
+ members: [{ id: 'bot-1', bot: true, joinedTimestamp: 1 }],
370
+ onFetch: async () => {
371
+ fetched = true;
372
+ return new Map([
373
+ ['bot-1', { id: 'bot-1', user: { bot: true }, joinedTimestamp: 1 }],
374
+ ['user-2', { id: 'user-2', user: { bot: false }, joinedTimestamp: 2 }],
375
+ ]);
376
+ },
377
+ });
378
+ await expect(resolveThreadFooterMentionUserId({
379
+ sessionUserId: undefined,
380
+ thread,
381
+ })).resolves.toBe('user-2');
382
+ expect(fetched).toBe(true);
383
+ });
384
+ });
251
385
  describe('raceDiscordRename', () => {
252
386
  test('returns timeout when rename hangs', async () => {
253
387
  const result = await raceDiscordRename({
package/dist/errors.js CHANGED
@@ -20,6 +20,11 @@ export class ServerNotReadyError extends errore.createTaggedError({
20
20
  message: 'OpenCode client for directory "$directory" is not available because the shared server is not ready',
21
21
  }) {
22
22
  }
23
+ export class OpencodeIncompatibleVersionError extends errore.createTaggedError({
24
+ name: 'OpencodeIncompatibleVersionError',
25
+ message: 'Kimaki is not compatible with OpenCode version $version. Install an OpenCode 1.x release.',
26
+ }) {
27
+ }
23
28
  export class ApiKeyMissingError extends errore.createTaggedError({
24
29
  name: 'ApiKeyMissingError',
25
30
  message: '$service API key is required',
@@ -64,18 +64,22 @@ describe('external directory permissions', () => {
64
64
  "--- from: user (external-directory-tester)
65
65
  EXTERNAL_DIRECTORY_PROBE_MARKER first
66
66
  --- from: assistant (TestBot)
67
- *using deterministic-provider/deterministic-v2*
68
- ⬥ reading external directory
67
+ > *using deterministic-provider/deterministic-v2*
68
+ > reading external directory
69
+
69
70
  ┣ read *probe.txt*
70
- ⬥ external-directory-probe-done
71
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
71
+
72
+ > external-directory-probe-done
73
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
72
74
  --- from: user (external-directory-tester)
73
75
  EXTERNAL_DIRECTORY_PROBE_MARKER followup
74
76
  --- from: assistant (TestBot)
75
- ⬥ reading external directory
77
+ > reading external directory
78
+
76
79
  ┣ read *probe.txt*
77
- ⬥ external-directory-probe-done
78
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
80
+
81
+ > external-directory-probe-done
82
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
79
83
  `);
80
84
  expect(text).not.toContain('Permission Required');
81
85
  });
@@ -54,10 +54,12 @@ describe('external directory project deny', () => {
54
54
  "--- from: user (external-directory-deny-tester)
55
55
  EXTERNAL_DIRECTORY_PROBE_MARKER denied
56
56
  --- from: assistant (TestBot)
57
- *using deterministic-provider/deterministic-v2*
58
- ⬥ reading external directory
57
+ > *using deterministic-provider/deterministic-v2*
58
+ > reading external directory
59
+
59
60
  ┣ read *probe.txt*
60
- ⬥ external-directory-probe-denied"
61
+
62
+ > external-directory-probe-denied"
61
63
  `);
62
64
  // A deny is silent: it must not fall back to asking the user.
63
65
  expect(text).not.toContain('Permission Required');
@@ -1,11 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
3
  import { getChannelVerbosity, getPartMessageIds, getThreadIdBySessionId, getThreadSessionSource, listTrackedTextChannels, setPartMessagesBatch, upsertThreadSession, } from './database.js';
4
- import { sendThreadMessage } from './discord-utils.js';
4
+ import { sendSessionPartBatches } from './discord-utils.js';
5
5
  import { createLogger, LogPrefix } from './logger.js';
6
- import { formatPart, collectSessionChunks, batchChunksForDiscord, } from './message-formatting.js';
6
+ import { formatPart, collectSessionChunks, batchChunksForDiscord, QUEUE_PREFIX, } from './message-formatting.js';
7
7
  import { initializeOpencodeForDirectory, } from './opencode.js';
8
- import { isEssentialToolPart } from './session-handler/thread-session-runtime.js';
8
+ import { getRuntime, isEssentialToolPart } from './session-handler/thread-session-runtime.js';
9
9
  import { notifyError } from './sentry.js';
10
10
  import { store } from './store.js';
11
11
  import { extractNonXmlContent } from './xml.js';
@@ -20,8 +20,7 @@ const SYNC_PER_DIRECTORY_TIMEOUT_MS = 30_000;
20
20
  const CLI_START_MS = Date.now() - 5 * 60 * 1000;
21
21
  let externalSyncInterval = null;
22
22
  function isSyntheticTextPart(part) {
23
- const candidate = part;
24
- return candidate.synthetic === true;
23
+ return part.synthetic === true;
25
24
  }
26
25
  function parseDiscordOriginMetadata(text) {
27
26
  const match = text.match(/<discord-user\s+([^>]+)\s*\/>/);
@@ -64,7 +63,7 @@ function getDiscordOriginMetadataFromMessage({ message, }) {
64
63
  }
65
64
  return null;
66
65
  }
67
- function getRenderableUserTextParts({ message, }) {
66
+ export function getRenderableUserTextParts({ message, }) {
68
67
  if (message.info.role !== 'user') {
69
68
  return [];
70
69
  }
@@ -72,7 +71,7 @@ function getRenderableUserTextParts({ message, }) {
72
71
  if (part.type !== 'text') {
73
72
  return [];
74
73
  }
75
- if (isSyntheticTextPart(part)) {
74
+ if (isSyntheticTextPart(part) || part.ignored === true) {
76
75
  return [];
77
76
  }
78
77
  const cleanedText = extractNonXmlContent(part.text || '').trim();
@@ -82,8 +81,49 @@ function getRenderableUserTextParts({ message, }) {
82
81
  return [{ id: part.id, text: cleanedText }];
83
82
  });
84
83
  }
84
+ function hasCompactionContinueMetadata(part) {
85
+ return part.metadata?.compaction_continue === true;
86
+ }
87
+ export function isInternalOpenCodeUserMessage({ message, }) {
88
+ if (message.info.role !== 'user') {
89
+ return false;
90
+ }
91
+ return message.parts.some((part) => {
92
+ if (part.type === 'compaction') {
93
+ return true;
94
+ }
95
+ if (part.type !== 'text') {
96
+ return false;
97
+ }
98
+ return isSyntheticTextPart(part) && hasCompactionContinueMetadata(part);
99
+ });
100
+ }
101
+ export function shouldSkipExternalAssistantMessage({ message, }) {
102
+ if (message.info.role !== 'assistant') {
103
+ return false;
104
+ }
105
+ return message.info.summary === true;
106
+ }
107
+ export function getIgnoredNoticeTextParts({ message, }) {
108
+ if (message.info.role !== 'user') {
109
+ return [];
110
+ }
111
+ return message.parts.flatMap((part) => {
112
+ if (part.type !== 'text') {
113
+ return [];
114
+ }
115
+ if (part.ignored !== true || isSyntheticTextPart(part)) {
116
+ return [];
117
+ }
118
+ const text = part.text?.trim();
119
+ if (!text) {
120
+ return [];
121
+ }
122
+ return [{ id: part.id, text }];
123
+ });
124
+ }
85
125
  function getExternalUserMirrorText({ username, prompt, }) {
86
- return `» **${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
126
+ return `${QUEUE_PREFIX}**${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
87
127
  }
88
128
  // Pure derivation: is the latest user turn from Discord?
89
129
  // Checks the newest user message with renderable text for a <discord-user />
@@ -91,12 +131,15 @@ function getExternalUserMirrorText({ username, prompt, }) {
91
131
  // (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
92
132
  // external sync should mirror it — this naturally handles the "reclaim" case
93
133
  // (external → discord → external) without any DB source toggling.
94
- function isLatestUserTurnFromDiscord({ messages, }) {
134
+ export function isLatestUserTurnFromDiscord({ messages, }) {
95
135
  for (let i = messages.length - 1; i >= 0; i--) {
96
136
  const message = messages[i];
97
137
  if (message.info.role !== 'user') {
98
138
  continue;
99
139
  }
140
+ if (isInternalOpenCodeUserMessage({ message })) {
141
+ continue;
142
+ }
100
143
  const renderableParts = getRenderableUserTextParts({ message });
101
144
  if (renderableParts.length === 0) {
102
145
  continue;
@@ -234,6 +277,23 @@ function collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread, })
234
277
  const directMappings = [];
235
278
  for (const message of messages) {
236
279
  if (message.info.role === 'user') {
280
+ if (isInternalOpenCodeUserMessage({ message })) {
281
+ continue;
282
+ }
283
+ const ignoredNoticeParts = getIgnoredNoticeTextParts({ message }).filter((part) => {
284
+ return !syncedPartIds.has(part.id);
285
+ });
286
+ if (ignoredNoticeParts.length > 0) {
287
+ chunks.push({
288
+ partIds: ignoredNoticeParts.map((part) => {
289
+ return part.id;
290
+ }),
291
+ content: ignoredNoticeParts.map((part) => {
292
+ return part.text;
293
+ }).join('\n\n'),
294
+ kind: 'text',
295
+ });
296
+ }
237
297
  const renderableParts = getRenderableUserTextParts({ message });
238
298
  const unsyncedParts = renderableParts.filter((p) => {
239
299
  return !syncedPartIds.has(p.id);
@@ -265,12 +325,16 @@ function collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread, })
265
325
  return p.id;
266
326
  }),
267
327
  content: getExternalUserMirrorText({ username: 'user', prompt: promptText }),
328
+ kind: 'text',
268
329
  });
269
330
  continue;
270
331
  }
271
332
  if (message.info.role !== 'assistant') {
272
333
  continue;
273
334
  }
335
+ if (shouldSkipExternalAssistantMessage({ message })) {
336
+ continue;
337
+ }
274
338
  // Filter assistant parts by verbosity before passing to shared collector
275
339
  const filteredParts = message.parts.filter((part) => {
276
340
  return shouldMirrorAssistantPart({ part, verbosity });
@@ -314,6 +378,10 @@ async function syncSessionToThread({ client, discordClient, directory, channelId
314
378
  if (isLatestUserTurnFromDiscord({ messages })) {
315
379
  return;
316
380
  }
381
+ const existingThreadId = await getThreadIdBySessionId(sessionId);
382
+ if (existingThreadId && getRuntime(existingThreadId)) {
383
+ return;
384
+ }
317
385
  const thread = await ensureExternalSessionThread({
318
386
  discordClient,
319
387
  channelId,
@@ -340,13 +408,15 @@ async function syncSessionToThread({ client, discordClient, directory, channelId
340
408
  await setPartMessagesBatch(directMappings);
341
409
  }
342
410
  const batched = batchChunksForDiscord(chunks);
343
- for (const batch of batched) {
411
+ if (signal.aborted)
412
+ return;
413
+ const sent = await sendSessionPartBatches({ thread, batches: batched });
414
+ for (const batch of sent) {
344
415
  if (signal.aborted)
345
416
  return;
346
- const sentMessage = await sendThreadMessage(thread, batch.content);
347
417
  await setPartMessagesBatch(batch.partIds.map((partId) => ({
348
418
  partId,
349
- messageId: sentMessage.id,
419
+ messageId: batch.message.id,
350
420
  threadId: thread.id,
351
421
  })));
352
422
  }
@@ -377,6 +447,15 @@ async function pulseTypingForBusySessions({ discordClient, statuses, }) {
377
447
  }
378
448
  }
379
449
  const EXTERNAL_SYNC_MAX_SESSIONS = 50;
450
+ export function isExternalSyncRootSession({ title, parentID, }) {
451
+ if (parentID) {
452
+ return false;
453
+ }
454
+ if (/^new session\s*-/i.test(title || '')) {
455
+ return false;
456
+ }
457
+ return !/subagent\)\s*$/i.test(title || '');
458
+ }
380
459
  // Tracks directories with an in-flight sync. When a directory times out,
381
460
  // its AbortController is aborted so the inner work stops producing side
382
461
  // effects (Discord messages, DB writes). The next poll tick skips the
@@ -449,11 +528,7 @@ async function syncDirectoryInner({ directory, channelId, startMs, discordClient
449
528
  if (signal.aborted)
450
529
  return;
451
530
  const sessions = (sessionsResponse.data || []).filter((session) => {
452
- const title = session.title || '';
453
- if (/^new session\s*-/i.test(title)) {
454
- return false;
455
- }
456
- return !/subagent\)\s*$/i.test(title);
531
+ return isExternalSyncRootSession(session);
457
532
  });
458
533
  const sorted = sortSessionsByRecency(sessions);
459
534
  for (const session of sorted) {
@@ -540,4 +615,6 @@ export const externalOpencodeSyncInternals = {
540
615
  parseDiscordOriginMetadata,
541
616
  getDiscordOriginMetadataFromMessage,
542
617
  isLatestUserTurnFromDiscord,
618
+ isInternalOpenCodeUserMessage,
619
+ shouldSkipExternalAssistantMessage,
543
620
  };