kimaki 0.26.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 (302) hide show
  1. package/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -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
  };
@@ -0,0 +1,206 @@
1
+ /** Tests external OpenCode user-message filtering for Discord mirroring and ownership. */
2
+ import { describe, expect, test } from 'vitest';
3
+ import { getIgnoredNoticeTextParts, getRenderableUserTextParts, isExternalSyncRootSession, isInternalOpenCodeUserMessage, isLatestUserTurnFromDiscord, shouldSkipExternalAssistantMessage, } from './external-opencode-sync.js';
4
+ function textMessage({ text, ignored, synthetic, }) {
5
+ return {
6
+ info: { role: 'user' },
7
+ parts: [
8
+ {
9
+ id: 'part-1',
10
+ sessionID: 'session-1',
11
+ messageID: 'message-1',
12
+ type: 'text',
13
+ text,
14
+ ignored,
15
+ synthetic,
16
+ },
17
+ ],
18
+ };
19
+ }
20
+ describe('external OpenCode user-message filtering', () => {
21
+ test('does not sync task child sessions into Discord', () => {
22
+ expect(isExternalSyncRootSession({
23
+ title: 'Design shader visual stack',
24
+ parentID: 'ses_parent',
25
+ })).toBe(false);
26
+ expect(isExternalSyncRootSession({
27
+ title: 'Main session',
28
+ })).toBe(true);
29
+ });
30
+ test('keeps normal external user text renderable', () => {
31
+ const message = textMessage({ text: 'Run the tests' });
32
+ expect(getRenderableUserTextParts({ message })).toEqual([
33
+ { id: 'part-1', text: 'Run the tests' },
34
+ ]);
35
+ });
36
+ test('skips ignored plugin notices from user mirroring', () => {
37
+ const message = textMessage({
38
+ text: 'Subrouter: xai/grok-4.6 was rate limited.',
39
+ ignored: true,
40
+ });
41
+ expect(getRenderableUserTextParts({ message })).toEqual([]);
42
+ });
43
+ test('collects ignored plugin notices as bot text', () => {
44
+ const message = textMessage({
45
+ text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
46
+ ignored: true,
47
+ });
48
+ expect(getIgnoredNoticeTextParts({ message })).toEqual([
49
+ {
50
+ id: 'part-1',
51
+ text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
52
+ },
53
+ ]);
54
+ });
55
+ test('does not collect synthetic or normal user text as ignored notices', () => {
56
+ expect(getIgnoredNoticeTextParts({
57
+ message: textMessage({ text: 'Run the tests' }),
58
+ })).toEqual([]);
59
+ expect(getIgnoredNoticeTextParts({
60
+ message: textMessage({
61
+ text: '<discord-user name="Tommy" />',
62
+ synthetic: true,
63
+ }),
64
+ })).toEqual([]);
65
+ });
66
+ test('skips synthetic context parts', () => {
67
+ const message = textMessage({
68
+ text: '<discord-user name="Tommy" />',
69
+ synthetic: true,
70
+ });
71
+ expect(getRenderableUserTextParts({ message })).toEqual([]);
72
+ });
73
+ test('does not treat an ignored notice as an external takeover', () => {
74
+ const discordMessage = {
75
+ info: { role: 'user' },
76
+ parts: [
77
+ {
78
+ id: 'part-2',
79
+ sessionID: 'session-1',
80
+ messageID: 'message-2',
81
+ type: 'text',
82
+ text: '<discord-user name="Tommy" />',
83
+ synthetic: true,
84
+ },
85
+ {
86
+ id: 'part-3',
87
+ sessionID: 'session-1',
88
+ messageID: 'message-2',
89
+ type: 'text',
90
+ text: 'What failed?',
91
+ },
92
+ ],
93
+ };
94
+ const notice = textMessage({
95
+ text: 'Subrouter: xai/grok-4.6 was rate limited.',
96
+ ignored: true,
97
+ });
98
+ expect(isLatestUserTurnFromDiscord({ messages: [discordMessage, notice] })).toBe(true);
99
+ });
100
+ test('skips compaction user messages when deciding Discord ownership', () => {
101
+ const discordMessage = {
102
+ info: { role: 'user' },
103
+ parts: [
104
+ {
105
+ id: 'part-2',
106
+ sessionID: 'session-1',
107
+ messageID: 'message-2',
108
+ type: 'text',
109
+ text: '<discord-user name="Tommy" />',
110
+ synthetic: true,
111
+ },
112
+ {
113
+ id: 'part-3',
114
+ sessionID: 'session-1',
115
+ messageID: 'message-2',
116
+ type: 'text',
117
+ text: 'Fix the tabs',
118
+ },
119
+ ],
120
+ };
121
+ const compactionUser = {
122
+ info: { role: 'user' },
123
+ parts: [
124
+ {
125
+ id: 'part-4',
126
+ sessionID: 'session-1',
127
+ messageID: 'message-4',
128
+ type: 'compaction',
129
+ auto: true,
130
+ },
131
+ ],
132
+ };
133
+ expect(isInternalOpenCodeUserMessage({ message: compactionUser })).toBe(true);
134
+ expect(isLatestUserTurnFromDiscord({ messages: [discordMessage, compactionUser] })).toBe(true);
135
+ });
136
+ test('skips compaction continue users when deciding Discord ownership', () => {
137
+ const discordMessage = {
138
+ info: { role: 'user' },
139
+ parts: [
140
+ {
141
+ id: 'part-2',
142
+ sessionID: 'session-1',
143
+ messageID: 'message-2',
144
+ type: 'text',
145
+ text: '<discord-user name="Tommy" />',
146
+ synthetic: true,
147
+ },
148
+ {
149
+ id: 'part-3',
150
+ sessionID: 'session-1',
151
+ messageID: 'message-2',
152
+ type: 'text',
153
+ text: 'Fix the tabs',
154
+ },
155
+ ],
156
+ };
157
+ const continueUser = {
158
+ info: { role: 'user' },
159
+ parts: [
160
+ {
161
+ id: 'part-5',
162
+ sessionID: 'session-1',
163
+ messageID: 'message-5',
164
+ type: 'text',
165
+ text: 'Continue if you have next steps',
166
+ synthetic: true,
167
+ metadata: { compaction_continue: true },
168
+ },
169
+ ],
170
+ };
171
+ expect(isInternalOpenCodeUserMessage({ message: continueUser })).toBe(true);
172
+ expect(isLatestUserTurnFromDiscord({ messages: [discordMessage, continueUser] })).toBe(true);
173
+ });
174
+ test('skips compaction summary assistants from external mirroring', () => {
175
+ expect(shouldSkipExternalAssistantMessage({
176
+ message: {
177
+ info: { role: 'assistant', summary: true },
178
+ parts: [
179
+ {
180
+ id: 'part-6',
181
+ sessionID: 'session-1',
182
+ messageID: 'message-6',
183
+ type: 'text',
184
+ text: 'internal compaction summary must not reach Discord',
185
+ },
186
+ ],
187
+ },
188
+ })).toBe(true);
189
+ });
190
+ test('does not skip a user-facing assistant just because the agent is named compaction', () => {
191
+ expect(shouldSkipExternalAssistantMessage({
192
+ message: {
193
+ info: { role: 'assistant', agent: 'compaction' },
194
+ parts: [
195
+ {
196
+ id: 'part-7',
197
+ sessionID: 'session-1',
198
+ messageID: 'message-7',
199
+ type: 'text',
200
+ text: 'user-facing reply',
201
+ },
202
+ ],
203
+ },
204
+ })).toBe(false);
205
+ });
206
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { extractSdkErrorMessage } from './opencode.js';
3
+ describe('extractSdkErrorMessage', () => {
4
+ test('includes UnknownError name, message, and ref from session.create', () => {
5
+ expect(extractSdkErrorMessage({
6
+ name: 'UnknownError',
7
+ data: {
8
+ message: 'Unexpected server error. Check server logs for details.',
9
+ ref: 'err_58d6c6cf',
10
+ },
11
+ })).toMatchInlineSnapshot(`"UnknownError: Unexpected server error. Check server logs for details. (err_58d6c6cf)"`);
12
+ });
13
+ });
@@ -0,0 +1,197 @@
1
+ // Tracks which OpenCode sessions last edited each file.
2
+ // Plugin appends JSONL events; CLI derives the per-file session list on read.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import * as errore from 'errore';
6
+ import { FilesystemOperationError } from './errors.js';
7
+ import { extractPatchFilePaths } from './patch-text-parser.js';
8
+ import { createPluginLogger, setPluginLogFilePath } from './plugin-logger.js';
9
+ const logger = createPluginLogger('FILEEDIT');
10
+ export const FILE_EDIT_EVENTS_FILENAME = 'file-edit-events.jsonl';
11
+ const DEFAULT_MAX_EVENTS = 10_000;
12
+ const DEFAULT_COMPACT_AFTER_BYTES = 5 * 1024 * 1024;
13
+ function isFileEditTool(tool) {
14
+ return tool === 'edit' || tool === 'write' || tool === 'apply_patch';
15
+ }
16
+ function getStringField({ args, key }) {
17
+ const value = args[key];
18
+ return typeof value === 'string' ? value : '';
19
+ }
20
+ export function extractEditedFiles({ tool, args, directory, }) {
21
+ const name = tool.toLowerCase();
22
+ if (name === 'edit' || name === 'write') {
23
+ const filePath = getStringField({ args, key: 'filePath' });
24
+ if (!filePath)
25
+ return [];
26
+ return [path.resolve(directory, filePath)];
27
+ }
28
+ if (name !== 'apply_patch')
29
+ return [];
30
+ const patchText = getStringField({ args, key: 'patchText' });
31
+ if (!patchText)
32
+ return [];
33
+ return extractPatchFilePaths(patchText).map((filePath) => {
34
+ return path.resolve(directory, filePath);
35
+ });
36
+ }
37
+ export function editorsForFile({ events, filePath, cwd, }) {
38
+ const resolved = path.resolve(cwd, filePath);
39
+ const latestBySession = new Map();
40
+ for (const event of events) {
41
+ if (path.resolve(event.file) !== resolved)
42
+ continue;
43
+ const previous = latestBySession.get(event.sessionId);
44
+ if (previous === undefined || event.at > previous) {
45
+ latestBySession.set(event.sessionId, event.at);
46
+ }
47
+ }
48
+ return [...latestBySession.entries()]
49
+ .map(([sessionId, at]) => {
50
+ return { sessionId, at };
51
+ })
52
+ .sort((left, right) => {
53
+ return right.at - left.at;
54
+ });
55
+ }
56
+ function parseFileEditEvent(line) {
57
+ const parsed = errore.try(() => JSON.parse(line), (cause) => new FilesystemOperationError({ operation: 'parse file edit event', cause }));
58
+ if (parsed instanceof Error)
59
+ return null;
60
+ if (!parsed || typeof parsed !== 'object')
61
+ return null;
62
+ if (parsed.v !== 1)
63
+ return null;
64
+ if (typeof parsed.at !== 'number' || !Number.isFinite(parsed.at))
65
+ return null;
66
+ if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0)
67
+ return null;
68
+ if (typeof parsed.file !== 'string' || parsed.file.length === 0)
69
+ return null;
70
+ if (typeof parsed.tool !== 'string' || !isFileEditTool(parsed.tool))
71
+ return null;
72
+ return {
73
+ v: 1,
74
+ at: parsed.at,
75
+ sessionId: parsed.sessionId,
76
+ file: parsed.file,
77
+ tool: parsed.tool,
78
+ };
79
+ }
80
+ function nodeErrorCode(error) {
81
+ const cause = error.cause;
82
+ if (!cause || typeof cause !== 'object')
83
+ return '';
84
+ const code = Reflect.get(cause, 'code');
85
+ return typeof code === 'string' ? code : '';
86
+ }
87
+ export function loadFileEditEvents({ dataDir }) {
88
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME);
89
+ const raw = errore.try(() => fs.readFileSync(logPath, 'utf8'), (cause) => new FilesystemOperationError({ operation: 'read file edit log', cause }));
90
+ if (raw instanceof Error) {
91
+ if (nodeErrorCode(raw) === 'ENOENT')
92
+ return [];
93
+ return raw;
94
+ }
95
+ const events = [];
96
+ for (const line of raw.split('\n')) {
97
+ if (!line.trim())
98
+ continue;
99
+ const event = parseFileEditEvent(line);
100
+ if (event)
101
+ events.push(event);
102
+ }
103
+ return events;
104
+ }
105
+ function collapseFileEditEvents(events) {
106
+ const latest = new Map();
107
+ for (const event of events) {
108
+ const key = `${event.sessionId}\0${event.file}`;
109
+ const previous = latest.get(key);
110
+ if (!previous || event.at >= previous.at)
111
+ latest.set(key, event);
112
+ }
113
+ return [...latest.values()].sort((left, right) => {
114
+ return left.at - right.at;
115
+ });
116
+ }
117
+ function compactFileEditLog({ dataDir, maxEvents, }) {
118
+ const loaded = loadFileEditEvents({ dataDir });
119
+ if (loaded instanceof Error)
120
+ return loaded;
121
+ const collapsed = collapseFileEditEvents(loaded);
122
+ const kept = collapsed.length > maxEvents ? collapsed.slice(-maxEvents) : collapsed;
123
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME);
124
+ const tempPath = `${logPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
125
+ const body = kept.map((event) => `${JSON.stringify(event)}\n`).join('');
126
+ const written = errore.try(() => {
127
+ fs.writeFileSync(tempPath, body);
128
+ fs.renameSync(tempPath, logPath);
129
+ }, (cause) => new FilesystemOperationError({ operation: 'compact file edit log', cause }));
130
+ if (written instanceof Error) {
131
+ fs.rmSync(tempPath, { force: true });
132
+ return written;
133
+ }
134
+ }
135
+ export function appendFileEditEvents({ dataDir, events, maxEvents = DEFAULT_MAX_EVENTS, compactAfterBytes = DEFAULT_COMPACT_AFTER_BYTES, }) {
136
+ if (events.length === 0)
137
+ return;
138
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME);
139
+ const prepared = errore.try(() => {
140
+ fs.mkdirSync(dataDir, { recursive: true });
141
+ fs.appendFileSync(logPath, events.map((event) => `${JSON.stringify(event)}\n`).join(''));
142
+ }, (cause) => new FilesystemOperationError({ operation: 'append file edit log', cause }));
143
+ if (prepared instanceof Error)
144
+ return prepared;
145
+ const size = errore.try(() => fs.statSync(logPath).size, (cause) => new FilesystemOperationError({ operation: 'stat file edit log', cause }));
146
+ if (size instanceof Error)
147
+ return size;
148
+ if (size < compactAfterBytes)
149
+ return;
150
+ return compactFileEditLog({ dataDir, maxEvents });
151
+ }
152
+ function recordToolEdits({ dataDir, directory, sessionId, tool, args, }) {
153
+ const name = tool.toLowerCase();
154
+ if (!isFileEditTool(name))
155
+ return;
156
+ const files = extractEditedFiles({ tool: name, args, directory });
157
+ if (files.length === 0)
158
+ return;
159
+ const at = Date.now();
160
+ const events = files.map((file) => {
161
+ return {
162
+ v: 1,
163
+ at,
164
+ sessionId,
165
+ file,
166
+ tool: name,
167
+ };
168
+ });
169
+ const result = appendFileEditEvents({ dataDir, events });
170
+ if (result instanceof Error) {
171
+ logger.warn('Failed to record file edit', result.message);
172
+ }
173
+ }
174
+ export function createFileEditHooks({ dataDir, directory, }) {
175
+ return {
176
+ 'tool.execute.after': async (input) => {
177
+ recordToolEdits({
178
+ dataDir,
179
+ directory,
180
+ sessionId: input.sessionID,
181
+ tool: input.tool,
182
+ args: input.args,
183
+ });
184
+ },
185
+ };
186
+ }
187
+ export const fileEditTrackerPlugin = async ({ directory }) => {
188
+ const dataDir = process.env.KIMAKI_DATA_DIR;
189
+ if (!dataDir)
190
+ return {};
191
+ setPluginLogFilePath(dataDir);
192
+ const created = errore.try(() => fs.mkdirSync(dataDir, { recursive: true }), (cause) => new FilesystemOperationError({ operation: 'create file edit log dir', cause }));
193
+ if (created instanceof Error) {
194
+ logger.warn('Failed to create file edit log dir', created.message);
195
+ }
196
+ return createFileEditHooks({ dataDir, directory });
197
+ };