@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,2668 @@
1
+ // OpenCode session lifecycle manager.
2
+ // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
+ // Handles streaming events, permissions, abort signals, and message queuing.
4
+
5
+ import type {
6
+ Part,
7
+ PermissionRequest,
8
+ QuestionRequest,
9
+ } from '@opencode-ai/sdk/v2'
10
+ import type { DiscordFileAttachment } from './message-formatting.js'
11
+ import { ChannelType, type Message, type ThreadChannel } from 'discord.js'
12
+ import prettyMilliseconds from 'pretty-ms'
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { xdgState } from 'xdg-basedir'
16
+ import {
17
+ getSessionAgent,
18
+ getSessionModel,
19
+ getVariantCascade,
20
+ getChannelAgent,
21
+ setSessionAgent,
22
+ getThreadWorktree,
23
+ getChannelVerbosity,
24
+ getThreadSession,
25
+ setThreadSession,
26
+ getPartMessageIds,
27
+ setPartMessage,
28
+ getChannelDirectory,
29
+ setSessionStartSource,
30
+ type ScheduledTaskScheduleKind,
31
+ } from './database.js'
32
+ import {
33
+ ensureSessionPreferencesSnapshot,
34
+ getCurrentModelInfo,
35
+ } from './commands/model.js'
36
+ import {
37
+ initializeOpencodeForDirectory,
38
+ getOpencodeServers,
39
+ getOpencodeClient,
40
+ } from './opencode.js'
41
+ import {
42
+ sendThreadMessage,
43
+ resolveWorkingDirectory,
44
+ NOTIFY_MESSAGE_FLAGS,
45
+ SILENT_MESSAGE_FLAGS,
46
+ } from './discord-utils.js'
47
+ import { formatPart } from './message-formatting.js'
48
+ import {
49
+ getOpencodeSystemMessage,
50
+ type AgentInfo,
51
+ type WorktreeInfo,
52
+ } from './system-message.js'
53
+ import { createLogger, LogPrefix } from './logger.js'
54
+ import { isAbortError } from './utils.js'
55
+ import { SessionAbortError } from './errors.js'
56
+ import { notifyError } from './sentry.js'
57
+ import {
58
+ showAskUserQuestionDropdowns,
59
+ cancelPendingQuestion,
60
+ pendingQuestionContexts,
61
+ } from './commands/ask-question.js'
62
+ import {
63
+ showPermissionButtons,
64
+ cleanupPermissionContext,
65
+ addPermissionRequestToContext,
66
+ arePatternsCoveredBy,
67
+ } from './commands/permissions.js'
68
+ import { cancelPendingFileUpload } from './commands/file-upload.js'
69
+ import {
70
+ cancelPendingActionButtons,
71
+ showActionButtons,
72
+ waitForQueuedActionButtonsRequest,
73
+ } from './commands/action-buttons.js'
74
+ import {
75
+ getThinkingValuesForModel,
76
+ matchThinkingValue,
77
+ } from './thinking-utils.js'
78
+ import { execAsync } from './worktree-utils.js'
79
+ import * as errore from 'errore'
80
+ import * as sessionRunState from './session-handler/state.js'
81
+
82
+ const sessionLogger = createLogger(LogPrefix.SESSION)
83
+ const voiceLogger = createLogger(LogPrefix.VOICE)
84
+ const discordLogger = createLogger(LogPrefix.DISCORD)
85
+
86
+ export const abortControllers = new Map<string, AbortController>()
87
+
88
+ /** Format opencode session error with status, provider, and response body for debugging. */
89
+ function formatSessionError(error?: {
90
+ data?: {
91
+ message?: string
92
+ statusCode?: number
93
+ providerID?: string
94
+ isRetryable?: boolean
95
+ responseBody?: string
96
+ }
97
+ name?: string
98
+ message?: string
99
+ }): string {
100
+ const name = error?.name || 'Error'
101
+ // Prefer data.message (SDK shape), fall back to error.message (plain Error)
102
+ const message =
103
+ error?.data?.message ||
104
+ error?.message ||
105
+ 'Unknown error'
106
+ const details: string[] = []
107
+ if (error?.data?.statusCode !== undefined) {
108
+ details.push(`status=${error.data.statusCode}`)
109
+ }
110
+ if (error?.data?.providerID) {
111
+ details.push(`provider=${error.data.providerID}`)
112
+ }
113
+ if (typeof error?.data?.isRetryable === 'boolean') {
114
+ details.push(error.data.isRetryable ? 'retryable' : 'non-retryable')
115
+ }
116
+ const responseBody =
117
+ typeof error?.data?.responseBody === 'string'
118
+ ? error.data.responseBody.trim()
119
+ : ''
120
+ if (responseBody) {
121
+ details.push(`body=${responseBody.slice(0, 180)}`)
122
+ }
123
+ const suffix = details.length > 0 ? ` (${details.join(', ')})` : ''
124
+ return `${name}: ${message}${suffix}`
125
+ }
126
+
127
+ export function signalThreadInterrupt({
128
+ threadId,
129
+ serverDirectory,
130
+ sdkDirectory,
131
+ }: {
132
+ threadId: string
133
+ serverDirectory?: string
134
+ sdkDirectory?: string
135
+ }): void {
136
+ void (async () => {
137
+ const sessionId = await getThreadSession(threadId)
138
+ if (!sessionId) {
139
+ return
140
+ }
141
+
142
+ const controller = abortControllers.get(sessionId)
143
+ if (!controller || controller.signal.aborted) {
144
+ return
145
+ }
146
+
147
+ sessionLogger.log(
148
+ `[ABORT] reason=queued-message sessionId=${sessionId} threadId=${threadId} - new message queued, aborting running session immediately`,
149
+ )
150
+ controller.abort(new SessionAbortError({ reason: 'new-request' }))
151
+
152
+ if (!serverDirectory || !sdkDirectory) {
153
+ return
154
+ }
155
+
156
+ const client = getOpencodeClient(serverDirectory)
157
+ if (!client) {
158
+ sessionLogger.log(
159
+ `[ABORT-API] reason=queued-message sessionId=${sessionId} - no OpenCode client found for directory ${serverDirectory}`,
160
+ )
161
+ return
162
+ }
163
+
164
+ const abortResult = await errore.tryAsync(() => {
165
+ return client.session.abort({
166
+ sessionID: sessionId,
167
+ directory: sdkDirectory,
168
+ })
169
+ })
170
+ if (abortResult instanceof Error) {
171
+ sessionLogger.log(
172
+ `[ABORT-API] reason=queued-message sessionId=${sessionId} - API abort failed (may already be done):`,
173
+ abortResult,
174
+ )
175
+ }
176
+ })()
177
+ }
178
+
179
+ // Built-in tools that are hidden in text-and-essential-tools verbosity mode.
180
+ // Essential tools (edits, bash with side effects, todos, tasks, custom MCP tools) are shown; these navigation/read tools are hidden.
181
+ const NON_ESSENTIAL_TOOLS = new Set([
182
+ 'read',
183
+ 'list',
184
+ 'glob',
185
+ 'grep',
186
+ 'todoread',
187
+ 'question',
188
+ 'kimaki_action_buttons',
189
+ 'webfetch',
190
+ ])
191
+
192
+ function isEssentialToolName(toolName: string): boolean {
193
+ return !NON_ESSENTIAL_TOOLS.has(toolName)
194
+ }
195
+
196
+ function isEssentialToolPart(part: Part): boolean {
197
+ if (part.type !== 'tool') {
198
+ return false
199
+ }
200
+ if (!isEssentialToolName(part.tool)) {
201
+ return false
202
+ }
203
+ if (part.tool === 'bash') {
204
+ const hasSideEffect = part.state.input?.hasSideEffect
205
+ return hasSideEffect !== false
206
+ }
207
+ return true
208
+ }
209
+
210
+ // Track multiple pending permissions per thread (keyed by permission ID)
211
+ // OpenCode handles blocking/sequencing - we just need to track all pending permissions
212
+ // to avoid duplicates and properly clean up on auto-reject
213
+ export const pendingPermissions = new Map<
214
+ string, // threadId
215
+ Map<
216
+ string,
217
+ {
218
+ permission: PermissionRequest
219
+ messageId: string
220
+ directory: string
221
+ permissionDirectory: string
222
+ contextHash: string
223
+ dedupeKey: string
224
+ }
225
+ > // permissionId -> data
226
+ >()
227
+
228
+ async function removeBotErrorReaction({
229
+ message,
230
+ }: {
231
+ message: Message
232
+ }): Promise<void> {
233
+ const botUserId = message.client.user?.id
234
+ if (!botUserId) {
235
+ return
236
+ }
237
+ const errorReaction = message.reactions.cache.find((reaction) => {
238
+ return reaction.emoji.name === '❌'
239
+ })
240
+ if (!errorReaction) {
241
+ return
242
+ }
243
+ await errorReaction.users.remove(botUserId)
244
+ }
245
+
246
+ function buildPermissionDedupeKey({
247
+ permission,
248
+ directory,
249
+ }: {
250
+ permission: PermissionRequest
251
+ directory: string
252
+ }): string {
253
+ const normalizedPatterns = [...permission.patterns].sort((a, b) => {
254
+ return a.localeCompare(b)
255
+ })
256
+ return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`
257
+ }
258
+
259
+ export type QueuedMessage = {
260
+ prompt: string
261
+ userId: string
262
+ username: string
263
+ queuedAt: number
264
+ images?: DiscordFileAttachment[]
265
+ appId?: string
266
+ /** If set, uses session.command API instead of session.prompt */
267
+ command?: { name: string; arguments: string }
268
+ }
269
+
270
+ // Queue of messages waiting to be sent after current response finishes
271
+ // Key is threadId, value is array of queued messages
272
+ export const messageQueue = new Map<string, QueuedMessage[]>()
273
+
274
+ const activeEventHandlers = new Map<string, Promise<void>>()
275
+
276
+ export function addToQueue({
277
+ threadId,
278
+ message,
279
+ }: {
280
+ threadId: string
281
+ message: QueuedMessage
282
+ }): number {
283
+ const queue = messageQueue.get(threadId) || []
284
+ queue.push(message)
285
+ messageQueue.set(threadId, queue)
286
+ return queue.length
287
+ }
288
+
289
+ export function getQueueLength(threadId: string): number {
290
+ return messageQueue.get(threadId)?.length || 0
291
+ }
292
+
293
+ export function clearQueue(threadId: string): void {
294
+ messageQueue.delete(threadId)
295
+ }
296
+
297
+ export type QueueOrSendResult =
298
+ | { action: 'sent' }
299
+ | { action: 'queued'; position: number }
300
+ | { action: 'no-session' }
301
+ | { action: 'no-directory' }
302
+
303
+ /**
304
+ * Queue a message if there's an active in-progress request, otherwise send immediately.
305
+ * Abstracts the "check active request → send or queue" pattern used by /queue command
306
+ * and voice transcription queue detection.
307
+ *
308
+ * Checks active request BEFORE resolving directory so that queueing works even if
309
+ * directory resolution would fail — the queued message only needs a directory later
310
+ * when it's actually sent (the drain logic in handleOpencodeSession already has it).
311
+ *
312
+ * If there is no existing session or no project directory (on immediate-send path),
313
+ * returns an error-like result so the caller can handle it.
314
+ */
315
+ export async function queueOrSendMessage({
316
+ thread,
317
+ prompt,
318
+ userId,
319
+ username,
320
+ appId,
321
+ images,
322
+ forceQueue,
323
+ }: {
324
+ thread: ThreadChannel
325
+ prompt: string
326
+ userId: string
327
+ username: string
328
+ appId?: string
329
+ images?: DiscordFileAttachment[]
330
+ /** When true, queue the message even if no active request is detected right now.
331
+ * Used by voice transcription: the active request state is snapshotted at message
332
+ * arrival time (before the prev task finishes), because by the time transcription
333
+ * completes and this function runs, the previous session may have already finished. */
334
+ forceQueue?: boolean
335
+ }): Promise<QueueOrSendResult> {
336
+ const sessionId = await getThreadSession(thread.id)
337
+ if (!sessionId) {
338
+ return { action: 'no-session' }
339
+ }
340
+
341
+ // Check active request FIRST — queueing doesn't need directory resolution
342
+ const existingController = abortControllers.get(sessionId)
343
+ const hasActiveRequest = Boolean(
344
+ existingController && !existingController.signal.aborted,
345
+ )
346
+ if (existingController && existingController.signal.aborted) {
347
+ abortControllers.delete(sessionId)
348
+ }
349
+
350
+ if (hasActiveRequest || forceQueue) {
351
+ // Active request — add to queue (no directory needed, drain logic has it)
352
+ const position = addToQueue({
353
+ threadId: thread.id,
354
+ message: {
355
+ prompt,
356
+ userId,
357
+ username,
358
+ queuedAt: Date.now(),
359
+ images,
360
+ appId,
361
+ },
362
+ })
363
+
364
+ sessionLogger.log(
365
+ `[QUEUE] User ${username} queued message in thread ${thread.id} (position: ${position})`,
366
+ )
367
+
368
+ return { action: 'queued', position }
369
+ }
370
+
371
+ // No active request — send immediately (need directory for this path)
372
+ const resolved = await resolveWorkingDirectory({ channel: thread })
373
+ if (!resolved) {
374
+ return { action: 'no-directory' }
375
+ }
376
+
377
+ sessionLogger.log(
378
+ `[QUEUE] No active request, sending immediately in thread ${thread.id}`,
379
+ )
380
+
381
+ handleOpencodeSession({
382
+ prompt,
383
+ thread,
384
+ projectDirectory: resolved.projectDirectory,
385
+ channelId: thread.parentId || thread.id,
386
+ images,
387
+ username,
388
+ userId,
389
+ appId,
390
+ }).catch(async (e) => {
391
+ sessionLogger.error(`[QUEUE] Failed to send message:`, e)
392
+ void notifyError(e, 'Queue: failed to send message')
393
+ const errorMsg = e instanceof Error ? e.message : String(e)
394
+ await sendThreadMessage(thread, `✗ Failed: ${errorMsg.slice(0, 200)}`)
395
+ })
396
+
397
+ return { action: 'sent' }
398
+ }
399
+
400
+ /**
401
+ * Read user's recent models from OpenCode TUI's state file.
402
+ * Uses same path as OpenCode: path.join(xdgState, "opencode", "model.json")
403
+ * Returns all recent models so we can iterate until finding a valid one.
404
+ * See: opensrc/repos/github.com/sst/opencode/packages/opencode/src/global/index.ts
405
+ */
406
+ function getRecentModelsFromTuiState(): Array<{
407
+ providerID: string
408
+ modelID: string
409
+ }> {
410
+ if (!xdgState) {
411
+ return []
412
+ }
413
+ // Same path as OpenCode TUI: path.join(Global.Path.state, "model.json")
414
+ const modelJsonPath = path.join(xdgState, 'opencode', 'model.json')
415
+
416
+ const result = errore.tryFn(() => {
417
+ const content = fs.readFileSync(modelJsonPath, 'utf-8')
418
+ const data = JSON.parse(content) as {
419
+ recent?: Array<{ providerID: string; modelID: string }>
420
+ }
421
+ return data.recent ?? []
422
+ })
423
+
424
+ if (result instanceof Error) {
425
+ // File doesn't exist or is invalid - this is normal for fresh installs
426
+ return []
427
+ }
428
+
429
+ return result
430
+ }
431
+
432
+ /**
433
+ * Parse a model string in format "provider/model" into providerID and modelID.
434
+ */
435
+ function parseModelString(
436
+ model: string,
437
+ ): { providerID: string; modelID: string } | undefined {
438
+ const [providerID, ...modelParts] = model.split('/')
439
+ const modelID = modelParts.join('/')
440
+ if (!providerID || !modelID) {
441
+ return undefined
442
+ }
443
+ return { providerID, modelID }
444
+ }
445
+
446
+ /**
447
+ * Validate that a model is available (provider connected + model exists).
448
+ */
449
+ function isModelValid(
450
+ model: { providerID: string; modelID: string },
451
+ connected: string[],
452
+ providers: Array<{ id: string; models?: Record<string, unknown> }>,
453
+ ): boolean {
454
+ const isConnected = connected.includes(model.providerID)
455
+ const provider = providers.find((p) => p.id === model.providerID)
456
+ const modelExists = provider?.models && model.modelID in provider.models
457
+ return isConnected && !!modelExists
458
+ }
459
+
460
+ async function resolveValidatedAgentPreference({
461
+ agent,
462
+ sessionId,
463
+ channelId,
464
+ getClient,
465
+ }: {
466
+ agent?: string
467
+ sessionId: string
468
+ channelId?: string
469
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
470
+ }): Promise<{ agentPreference?: string; agents: AgentInfo[] }> {
471
+ const agentPreference = await (async (): Promise<string | undefined> => {
472
+ if (agent) {
473
+ return agent
474
+ }
475
+
476
+ const sessionAgent = await getSessionAgent(sessionId)
477
+ if (sessionAgent) {
478
+ return sessionAgent
479
+ }
480
+
481
+ const sessionModel = await getSessionModel(sessionId)
482
+ if (sessionModel) {
483
+ return undefined
484
+ }
485
+
486
+ if (!channelId) {
487
+ return undefined
488
+ }
489
+ return getChannelAgent(channelId)
490
+ })()
491
+
492
+ if (getClient instanceof Error) {
493
+ return { agentPreference: agentPreference || undefined, agents: [] }
494
+ }
495
+
496
+ const agentsResponse = await errore.tryAsync(() => {
497
+ return getClient().app.agents({})
498
+ })
499
+ if (agentsResponse instanceof Error) {
500
+ if (agentPreference) {
501
+ throw new Error(`Failed to validate agent "${agentPreference}"`, {
502
+ cause: agentsResponse,
503
+ })
504
+ }
505
+ return { agentPreference: undefined, agents: [] }
506
+ }
507
+
508
+ const availableAgents = agentsResponse.data || []
509
+ // Non-hidden primary/all agents for system message context
510
+ const agents: AgentInfo[] = availableAgents
511
+ .filter((a) => {
512
+ return (
513
+ (a.mode === 'primary' || a.mode === 'all') &&
514
+ !a.hidden
515
+ )
516
+ })
517
+ .map((a) => {
518
+ return { name: a.name, description: a.description }
519
+ })
520
+
521
+ if (!agentPreference) {
522
+ return { agentPreference: undefined, agents }
523
+ }
524
+
525
+ const hasAgent = availableAgents.some((availableAgent) => {
526
+ return availableAgent.name === agentPreference
527
+ })
528
+ if (hasAgent) {
529
+ return { agentPreference, agents }
530
+ }
531
+
532
+ const availableAgentNames = availableAgents
533
+ .map((availableAgent) => {
534
+ return availableAgent.name
535
+ })
536
+ .slice(0, 20)
537
+ const availableAgentsMessage =
538
+ availableAgentNames.length > 0
539
+ ? `Available agents: ${availableAgentNames.join(', ')}`
540
+ : 'No agents are available in this project.'
541
+ throw new Error(
542
+ `Agent "${agentPreference}" not found. ${availableAgentsMessage} Use /agent to choose a valid one.`,
543
+ )
544
+ }
545
+
546
+ export type DefaultModelSource =
547
+ | 'opencode-config'
548
+ | 'opencode-recent'
549
+ | 'opencode-provider-default'
550
+
551
+ export type SessionStartSourceContext = {
552
+ scheduleKind: ScheduledTaskScheduleKind
553
+ scheduledTaskId?: number
554
+ }
555
+
556
+ /**
557
+ * Get the default model from OpenCode when no user preference is set.
558
+ * Priority (matches OpenCode TUI behavior):
559
+ * 1. OpenCode config.model setting
560
+ * 2. User's recent models from TUI state (~/.local/state/opencode/model.json)
561
+ * 3. First connected provider's default model from API
562
+ * Returns the model and its source.
563
+ */
564
+ export async function getDefaultModel({
565
+ getClient,
566
+ }: {
567
+ getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>
568
+ }): Promise<
569
+ | { providerID: string; modelID: string; source: DefaultModelSource }
570
+ | undefined
571
+ > {
572
+ if (getClient instanceof Error) {
573
+ return undefined
574
+ }
575
+
576
+ // Fetch connected providers to validate any model we return
577
+ const providersResponse = await errore.tryAsync(() => {
578
+ return getClient().provider.list({})
579
+ })
580
+ if (providersResponse instanceof Error) {
581
+ sessionLogger.log(
582
+ `[MODEL] Failed to fetch providers for default model:`,
583
+ providersResponse.message,
584
+ )
585
+ return undefined
586
+ }
587
+ if (!providersResponse.data) {
588
+ return undefined
589
+ }
590
+
591
+ const {
592
+ connected,
593
+ default: defaults,
594
+ all: providers,
595
+ } = providersResponse.data
596
+ if (connected.length === 0) {
597
+ sessionLogger.log(`[MODEL] No connected providers found`)
598
+ return undefined
599
+ }
600
+
601
+ // 1. Check OpenCode config.model setting (highest priority after user preference)
602
+ const configResponse = await errore.tryAsync(() => {
603
+ return getClient().config.get({})
604
+ })
605
+ if (!(configResponse instanceof Error) && configResponse.data?.model) {
606
+ const configModel = parseModelString(configResponse.data.model)
607
+ if (configModel && isModelValid(configModel, connected, providers)) {
608
+ sessionLogger.log(
609
+ `[MODEL] Using config model: ${configModel.providerID}/${configModel.modelID}`,
610
+ )
611
+ return { ...configModel, source: 'opencode-config' }
612
+ }
613
+ if (configModel) {
614
+ sessionLogger.log(
615
+ `[MODEL] Config model ${configResponse.data.model} not available, checking recent`,
616
+ )
617
+ }
618
+ }
619
+
620
+ // 2. Try to use user's recent models from TUI state (iterate until finding valid one)
621
+ const recentModels = getRecentModelsFromTuiState()
622
+ for (const recentModel of recentModels) {
623
+ if (isModelValid(recentModel, connected, providers)) {
624
+ sessionLogger.log(
625
+ `[MODEL] Using recent TUI model: ${recentModel.providerID}/${recentModel.modelID}`,
626
+ )
627
+ return { ...recentModel, source: 'opencode-recent' }
628
+ }
629
+ }
630
+ if (recentModels.length > 0) {
631
+ sessionLogger.log(`[MODEL] No valid recent TUI models found`)
632
+ }
633
+
634
+ // 3. Fall back to first connected provider's default model
635
+ const firstConnected = connected[0]
636
+ if (!firstConnected) {
637
+ return undefined
638
+ }
639
+ const defaultModelId = defaults[firstConnected]
640
+ if (!defaultModelId) {
641
+ sessionLogger.log(`[MODEL] No default model for provider ${firstConnected}`)
642
+ return undefined
643
+ }
644
+
645
+ sessionLogger.log(
646
+ `[MODEL] Using provider default: ${firstConnected}/${defaultModelId}`,
647
+ )
648
+ return {
649
+ providerID: firstConnected,
650
+ modelID: defaultModelId,
651
+ source: 'opencode-provider-default',
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Abort a running session and retry with the last user message.
657
+ * Used when model preference changes mid-request.
658
+ * Fetches last user message from OpenCode API instead of tracking in memory.
659
+ * @returns true if aborted and retry scheduled, false if no active request
660
+ */
661
+ export async function abortAndRetrySession({
662
+ sessionId,
663
+ thread,
664
+ projectDirectory,
665
+ appId,
666
+ channelId,
667
+ }: {
668
+ sessionId: string
669
+ thread: ThreadChannel
670
+ projectDirectory: string
671
+ appId?: string
672
+ channelId?: string
673
+ }): Promise<boolean> {
674
+ const controller = abortControllers.get(sessionId)
675
+
676
+ if (!controller) {
677
+ sessionLogger.log(
678
+ `[ABORT+RETRY] No active request for session ${sessionId}`,
679
+ )
680
+ return false
681
+ }
682
+
683
+ sessionLogger.log(
684
+ `[ABORT+RETRY] Aborting session ${sessionId} for model change`,
685
+ )
686
+
687
+ // Abort with special reason so we don't show "completed" message
688
+ sessionLogger.log(
689
+ `[ABORT] reason=model-change sessionId=${sessionId} - user changed model mid-request, will retry with new model`,
690
+ )
691
+ controller.abort(new SessionAbortError({ reason: 'model-change' }))
692
+
693
+ // Also call the API abort endpoint
694
+ const getClient = await initializeOpencodeForDirectory(projectDirectory, {
695
+ channelId,
696
+ })
697
+ if (getClient instanceof Error) {
698
+ sessionLogger.error(
699
+ `[ABORT+RETRY] Failed to initialize OpenCode client:`,
700
+ getClient.message,
701
+ )
702
+ return false
703
+ }
704
+ sessionLogger.log(
705
+ `[ABORT-API] reason=model-change sessionId=${sessionId} - sending API abort for model change retry`,
706
+ )
707
+ const abortResult = await errore.tryAsync(() => {
708
+ return getClient().session.abort({ sessionID: sessionId })
709
+ })
710
+ if (abortResult instanceof Error) {
711
+ sessionLogger.log(
712
+ `[ABORT-API] API abort call failed (may already be done):`,
713
+ abortResult,
714
+ )
715
+ }
716
+
717
+ // Small delay to let the abort propagate
718
+ await new Promise((resolve) => {
719
+ setTimeout(resolve, 300)
720
+ })
721
+
722
+ // Fetch last user message from API
723
+ sessionLogger.log(
724
+ `[ABORT+RETRY] Fetching last user message for session ${sessionId}`,
725
+ )
726
+ const messagesResponse = await getClient().session.messages({
727
+ sessionID: sessionId,
728
+ })
729
+ const messages = messagesResponse.data || []
730
+ const lastUserMessage = [...messages]
731
+ .reverse()
732
+ .find((m) => m.info.role === 'user')
733
+
734
+ if (!lastUserMessage) {
735
+ sessionLogger.log(
736
+ `[ABORT+RETRY] No user message found in session ${sessionId}`,
737
+ )
738
+ return false
739
+ }
740
+
741
+ // Extract text and images from parts (skip synthetic parts like branch context)
742
+ const textPart = lastUserMessage.parts.find(
743
+ (p) => p.type === 'text' && !p.synthetic,
744
+ ) as { type: 'text'; text: string } | undefined
745
+ const prompt = textPart?.text || ''
746
+ const images = lastUserMessage.parts.filter(
747
+ (p) => p.type === 'file',
748
+ ) as DiscordFileAttachment[]
749
+
750
+ sessionLogger.log(
751
+ `[ABORT+RETRY] Re-triggering session ${sessionId} with new model`,
752
+ )
753
+
754
+ // Use setImmediate to avoid blocking
755
+ setImmediate(() => {
756
+ void errore
757
+ .tryAsync(async () => {
758
+ return handleOpencodeSession({
759
+ prompt,
760
+ thread,
761
+ projectDirectory,
762
+ images,
763
+ appId,
764
+ channelId,
765
+ })
766
+ })
767
+ .then(async (result) => {
768
+ if (!(result instanceof Error)) {
769
+ return
770
+ }
771
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result)
772
+ void notifyError(result, 'Abort+retry session failed')
773
+ await sendThreadMessage(
774
+ thread,
775
+ `✗ Failed to retry with new model: ${result.message.slice(0, 200)}`,
776
+ )
777
+ })
778
+ })
779
+
780
+ return true
781
+ }
782
+
783
+ export async function handleOpencodeSession({
784
+ prompt,
785
+ thread,
786
+ projectDirectory,
787
+ originalMessage,
788
+ images = [],
789
+ channelId,
790
+ command,
791
+ agent,
792
+ model,
793
+ username,
794
+ userId,
795
+ appId,
796
+ sessionStartSource,
797
+ }: {
798
+ prompt: string
799
+ thread: ThreadChannel
800
+ projectDirectory?: string
801
+ originalMessage?: Message
802
+ images?: DiscordFileAttachment[]
803
+ channelId?: string
804
+ /** If set, uses session.command API instead of session.prompt */
805
+ command?: { name: string; arguments: string }
806
+ /** Agent to use for this session */
807
+ agent?: string
808
+ /** Model override (format: provider/model) */
809
+ model?: string
810
+ /** Discord username for synthetic context (not shown in TUI) */
811
+ username?: string
812
+ /** Discord user ID for system prompt examples */
813
+ userId?: string
814
+ appId?: string
815
+ /** Metadata for sessions started by scheduled tasks */
816
+ sessionStartSource?: SessionStartSourceContext
817
+ }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
818
+ voiceLogger.log(
819
+ `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
820
+ )
821
+
822
+ const sessionStartTime = Date.now()
823
+
824
+ const directory = projectDirectory || process.cwd()
825
+ sessionLogger.log(`Using directory: ${directory}`)
826
+
827
+ // Fire DB lookups in parallel - they're independent and we need both before proceeding.
828
+ // initializeOpencodeForDirectory is NOT included here because it needs worktree info
829
+ // to set originalRepoDirectory permissions on the spawned server (reuse check means
830
+ // a second call with different options won't fix a server already spawned without them).
831
+ const [worktreeInfo, existingSessionId] = await Promise.all([
832
+ getThreadWorktree(thread.id),
833
+ getThreadSession(thread.id),
834
+ ])
835
+
836
+ const worktreeDirectory =
837
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
838
+ ? worktreeInfo.worktree_directory
839
+ : undefined
840
+ const sdkDirectory = worktreeDirectory || directory
841
+ if (worktreeDirectory) {
842
+ sessionLogger.log(
843
+ `Using worktree directory for SDK calls: ${worktreeDirectory}`,
844
+ )
845
+ }
846
+
847
+ const originalRepoDirectory = worktreeDirectory
848
+ ? worktreeInfo?.project_directory
849
+ : undefined
850
+ const getClient = await initializeOpencodeForDirectory(directory, {
851
+ originalRepoDirectory,
852
+ channelId,
853
+ })
854
+ if (getClient instanceof Error) {
855
+ await sendThreadMessage(thread, `✗ ${getClient.message}`)
856
+ return
857
+ }
858
+
859
+ const serverEntry = getOpencodeServers().get(directory)
860
+ const port = serverEntry?.port
861
+
862
+ let sessionId = existingSessionId
863
+ let session
864
+ let createdNewSession = false
865
+
866
+ if (sessionId) {
867
+ sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
868
+ const sessionResponse = await errore.tryAsync(() => {
869
+ return getClient().session.get({
870
+ sessionID: sessionId,
871
+ directory: sdkDirectory,
872
+ })
873
+ })
874
+ if (sessionResponse instanceof Error) {
875
+ voiceLogger.log(
876
+ `[SESSION] Session ${sessionId} not found, will create new one`,
877
+ )
878
+ } else {
879
+ session = sessionResponse.data
880
+ sessionLogger.log(`Successfully reused session ${sessionId}`)
881
+ }
882
+ }
883
+
884
+ if (!session) {
885
+ const sessionTitle =
886
+ prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
887
+ voiceLogger.log(
888
+ `[SESSION] Creating new session with title: "${sessionTitle}"`,
889
+ )
890
+ const sessionResponse = await getClient().session.create({
891
+ title: sessionTitle,
892
+ directory: sdkDirectory,
893
+ })
894
+ session = sessionResponse.data
895
+ createdNewSession = true
896
+ sessionLogger.log(`Created new session ${session?.id}`)
897
+ }
898
+
899
+ if (!session) {
900
+ throw new Error('Failed to create or get session')
901
+ }
902
+
903
+ await setThreadSession(thread.id, session.id)
904
+ sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
905
+
906
+ const channelInfo = channelId
907
+ ? await getChannelDirectory(channelId)
908
+ : undefined
909
+ const resolvedAppId = channelInfo?.appId ?? appId
910
+
911
+ if (createdNewSession && sessionStartSource) {
912
+ const saveStartSourceResult = await errore.tryAsync(() => {
913
+ return setSessionStartSource({
914
+ sessionId: session.id,
915
+ scheduleKind: sessionStartSource.scheduleKind,
916
+ scheduledTaskId: sessionStartSource.scheduledTaskId,
917
+ })
918
+ })
919
+ if (saveStartSourceResult instanceof Error) {
920
+ sessionLogger.warn(
921
+ `[SESSION] Failed to store start source for session ${session.id}: ${saveStartSourceResult.message}`,
922
+ )
923
+ }
924
+ }
925
+
926
+ // Store agent preference if provided
927
+ if (agent) {
928
+ await setSessionAgent(session.id, agent)
929
+ sessionLogger.log(
930
+ `Set agent preference for session ${session.id}: ${agent}`,
931
+ )
932
+ }
933
+
934
+ await ensureSessionPreferencesSnapshot({
935
+ sessionId: session.id,
936
+ channelId,
937
+ appId: resolvedAppId,
938
+ getClient,
939
+ agentOverride: agent,
940
+ modelOverride: model,
941
+ force: createdNewSession,
942
+ })
943
+
944
+ const existingController = abortControllers.get(session.id)
945
+ if (existingController) {
946
+ voiceLogger.log(
947
+ `[ABORT] Cancelling existing request for session: ${session.id}`,
948
+ )
949
+ sessionLogger.log(
950
+ `[ABORT] reason=new-request sessionId=${session.id} threadId=${thread.id} - new user message arrived while previous request was still running`,
951
+ )
952
+ existingController.abort(new SessionAbortError({ reason: 'new-request' }))
953
+ sessionLogger.log(
954
+ `[ABORT-API] reason=new-request sessionId=${session.id} - sending API abort because new message arrived`,
955
+ )
956
+ const abortResult = await errore.tryAsync(() => {
957
+ return getClient().session.abort({
958
+ sessionID: session.id,
959
+ directory: sdkDirectory,
960
+ })
961
+ })
962
+ if (abortResult instanceof Error) {
963
+ sessionLogger.log(
964
+ `[ABORT-API] Server abort failed (may be already done):`,
965
+ abortResult,
966
+ )
967
+ }
968
+ }
969
+
970
+ // Auto-reject ALL pending permissions for this thread
971
+ const threadPermissions = pendingPermissions.get(thread.id)
972
+ if (threadPermissions && threadPermissions.size > 0) {
973
+ const permClient = getOpencodeClient(directory)
974
+ for (const [permId, pendingPerm] of threadPermissions) {
975
+ sessionLogger.log(
976
+ `[PERMISSION] Auto-rejecting permission ${permId} due to new message`,
977
+ )
978
+ // Remove the permission buttons from the Discord message
979
+ const removeButtonsResult = await errore.tryAsync(async () => {
980
+ const msg = await thread.messages.fetch(pendingPerm.messageId)
981
+ await msg.edit({ components: [] })
982
+ })
983
+ if (removeButtonsResult instanceof Error) {
984
+ sessionLogger.log(
985
+ `[PERMISSION] Failed to remove buttons for ${permId}:`,
986
+ removeButtonsResult,
987
+ )
988
+ }
989
+ if (!permClient) {
990
+ sessionLogger.log(
991
+ `[PERMISSION] OpenCode client unavailable for permission ${permId}`,
992
+ )
993
+ cleanupPermissionContext(pendingPerm.contextHash)
994
+ continue
995
+ }
996
+ const rejectResult = await errore.tryAsync(() => {
997
+ return permClient.permission.reply({
998
+ requestID: permId,
999
+ directory: pendingPerm.permissionDirectory,
1000
+ reply: 'reject',
1001
+ })
1002
+ })
1003
+ if (rejectResult instanceof Error) {
1004
+ sessionLogger.log(
1005
+ `[PERMISSION] Failed to auto-reject permission ${permId}:`,
1006
+ rejectResult,
1007
+ )
1008
+ }
1009
+ cleanupPermissionContext(pendingPerm.contextHash)
1010
+ }
1011
+ pendingPermissions.delete(thread.id)
1012
+ }
1013
+
1014
+ // Answer any pending question tool with the user's message (silently, no thread message)
1015
+ const questionAnswered = await cancelPendingQuestion(thread.id, prompt)
1016
+ if (questionAnswered) {
1017
+ sessionLogger.log(`[QUESTION] Answered pending question with user message`)
1018
+ }
1019
+
1020
+ // Cancel any pending file upload (resolves with empty array so plugin tool unblocks)
1021
+ const fileUploadCancelled = await cancelPendingFileUpload(thread.id)
1022
+ if (fileUploadCancelled) {
1023
+ sessionLogger.log(
1024
+ `[FILE-UPLOAD] Cancelled pending file upload due to new message`,
1025
+ )
1026
+ }
1027
+
1028
+ // Dismiss any pending action buttons (user sent a new message instead of clicking)
1029
+ const actionButtonsDismissed = cancelPendingActionButtons(thread.id)
1030
+ if (actionButtonsDismissed) {
1031
+ sessionLogger.log(
1032
+ `[ACTION] Dismissed pending action buttons due to new message`,
1033
+ )
1034
+ }
1035
+
1036
+ // Snapshot model+agent early so user changes (e.g. /agent) during the async gap
1037
+ // (debounce, previous handler wait, event subscribe) don't affect this request.
1038
+ const earlyAgentResult = await errore.tryAsync(() => {
1039
+ return resolveValidatedAgentPreference({
1040
+ agent,
1041
+ sessionId: session.id,
1042
+ channelId,
1043
+ getClient,
1044
+ })
1045
+ })
1046
+ if (earlyAgentResult instanceof Error) {
1047
+ await sendThreadMessage(
1048
+ thread,
1049
+ `Failed to resolve agent: ${earlyAgentResult.message}`,
1050
+ )
1051
+ return
1052
+ }
1053
+ const earlyAgentPreference = earlyAgentResult.agentPreference
1054
+ const earlyAvailableAgents = earlyAgentResult.agents
1055
+ if (earlyAgentPreference) {
1056
+ sessionLogger.log(
1057
+ `[AGENT] Resolved agent preference early: ${earlyAgentPreference}`,
1058
+ )
1059
+ }
1060
+
1061
+ // Model resolution and variant cascade are independent - run in parallel.
1062
+ // Variant cascade only needs resolvedAppId (available now). Variant validation
1063
+ // against the model happens after both complete.
1064
+ const [earlyModelResult, preferredVariant] = await Promise.all([
1065
+ errore.tryAsync(async () => {
1066
+ if (model) {
1067
+ const [providerID, ...modelParts] = model.split('/')
1068
+ const modelID = modelParts.join('/')
1069
+ if (providerID && modelID) {
1070
+ sessionLogger.log(`[MODEL] Using explicit model (early): ${model}`)
1071
+ return { providerID, modelID }
1072
+ }
1073
+ }
1074
+ const modelInfo = await getCurrentModelInfo({
1075
+ sessionId: session.id,
1076
+ channelId,
1077
+ appId: resolvedAppId,
1078
+ agentPreference: earlyAgentPreference,
1079
+ getClient,
1080
+ })
1081
+ if (modelInfo.type === 'none') {
1082
+ sessionLogger.log(`[MODEL] No model available (early resolution)`)
1083
+ return undefined
1084
+ }
1085
+ sessionLogger.log(
1086
+ `[MODEL] Resolved ${modelInfo.type} early: ${modelInfo.model}`,
1087
+ )
1088
+ return { providerID: modelInfo.providerID, modelID: modelInfo.modelID }
1089
+ }),
1090
+ getVariantCascade({
1091
+ sessionId: session.id,
1092
+ channelId,
1093
+ appId: resolvedAppId,
1094
+ }),
1095
+ ])
1096
+ if (earlyModelResult instanceof Error) {
1097
+ await sendThreadMessage(
1098
+ thread,
1099
+ `Failed to resolve model: ${earlyModelResult.message}`,
1100
+ )
1101
+ return
1102
+ }
1103
+ const earlyModelParam = earlyModelResult
1104
+ if (!earlyModelParam) {
1105
+ await sendThreadMessage(
1106
+ thread,
1107
+ 'No AI provider connected. Configure a provider in OpenCode with `/connect` command.',
1108
+ )
1109
+ return
1110
+ }
1111
+
1112
+ // Validate the preferred variant against the current model's available variants.
1113
+ // preferredVariant was already fetched in parallel above.
1114
+ const earlyThinkingValue = await (async (): Promise<string | undefined> => {
1115
+ if (!preferredVariant) {
1116
+ return undefined
1117
+ }
1118
+ const providersResponse = await errore.tryAsync(() => {
1119
+ return getClient().provider.list({ directory: sdkDirectory })
1120
+ })
1121
+ if (providersResponse instanceof Error || !providersResponse.data) {
1122
+ return undefined
1123
+ }
1124
+ const availableValues = getThinkingValuesForModel({
1125
+ providers: providersResponse.data.all,
1126
+ providerId: earlyModelParam.providerID,
1127
+ modelId: earlyModelParam.modelID,
1128
+ })
1129
+ if (availableValues.length === 0) {
1130
+ sessionLogger.log(
1131
+ `[THINK] Model ${earlyModelParam.providerID}/${earlyModelParam.modelID} has no variants, ignoring preference`,
1132
+ )
1133
+ return undefined
1134
+ }
1135
+ const matched = matchThinkingValue({
1136
+ requestedValue: preferredVariant,
1137
+ availableValues,
1138
+ })
1139
+ if (!matched) {
1140
+ sessionLogger.log(
1141
+ `[THINK] Preference "${preferredVariant}" invalid for current model, ignoring`,
1142
+ )
1143
+ return undefined
1144
+ }
1145
+ sessionLogger.log(`[THINK] Using variant: ${matched}`)
1146
+ return matched
1147
+ })()
1148
+
1149
+ const abortController = new AbortController()
1150
+ abortControllers.set(session.id, abortController)
1151
+
1152
+ if (existingController) {
1153
+ await new Promise((resolve) => {
1154
+ setTimeout(resolve, 200)
1155
+ })
1156
+ if (abortController.signal.aborted) {
1157
+ sessionLogger.log(
1158
+ `[DEBOUNCE] Request was superseded during wait, exiting`,
1159
+ )
1160
+ return
1161
+ }
1162
+ }
1163
+
1164
+ if (abortController.signal.aborted) {
1165
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
1166
+ return
1167
+ }
1168
+
1169
+ const previousHandler = activeEventHandlers.get(thread.id)
1170
+ if (previousHandler) {
1171
+ sessionLogger.log(`[EVENT] Waiting for previous handler to finish`)
1172
+ const previousHandlerResult = await errore.tryAsync(() => {
1173
+ return previousHandler
1174
+ })
1175
+ if (previousHandlerResult instanceof Error) {
1176
+ sessionLogger.warn(
1177
+ `[EVENT] Previous handler exited with error while waiting: ${previousHandlerResult.message}`,
1178
+ )
1179
+ }
1180
+ }
1181
+
1182
+ const eventClient = getOpencodeClient(directory)
1183
+ if (!eventClient) {
1184
+ throw new Error(`OpenCode client not found for directory: ${directory}`)
1185
+ }
1186
+ const eventsResult = await eventClient.event.subscribe(
1187
+ { directory: sdkDirectory },
1188
+ { signal: abortController.signal },
1189
+ )
1190
+
1191
+ if (abortController.signal.aborted) {
1192
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
1193
+ return
1194
+ }
1195
+
1196
+ const events = eventsResult.stream
1197
+ sessionLogger.log(`Subscribed to OpenCode events`)
1198
+
1199
+ const existingPartIds = await getPartMessageIds(thread.id)
1200
+ const sentPartIds = new Set<string>(existingPartIds)
1201
+
1202
+ const partBuffer = new Map<string, Map<string, Part>>()
1203
+ let usedModel: string | undefined = earlyModelParam.modelID
1204
+ let usedProviderID: string | undefined = earlyModelParam.providerID
1205
+ let usedAgent: string | undefined
1206
+ let tokensUsedInSession = 0
1207
+ let lastDisplayedContextPercentage = 0
1208
+ let lastRateLimitDisplayTime = 0
1209
+ let modelContextLimit: number | undefined
1210
+ let modelContextLimitKey: string | undefined
1211
+ let assistantMessageId: string | undefined
1212
+ let handlerPromise: Promise<void> | null = null
1213
+
1214
+ let typingInterval: NodeJS.Timeout | null = null
1215
+ let typingRestartTimeout: NodeJS.Timeout | null = null
1216
+ let handlerClosed = false
1217
+ let hasSentParts = false
1218
+ const mainRunStore = sessionRunState.createMainRunStore()
1219
+
1220
+ const finishMainSessionFromIdle = (): void => {
1221
+ if (abortController.signal.aborted) {
1222
+ return
1223
+ }
1224
+ sessionRunState.markFinished({ store: mainRunStore })
1225
+ sessionLogger.log(
1226
+ `[SESSION IDLE] Session ${session.id} is idle, ending stream`,
1227
+ )
1228
+ sessionLogger.log(
1229
+ `[ABORT] reason=finished sessionId=${session.id} threadId=${thread.id} - session completed normally, received idle event after prompt resolved`,
1230
+ )
1231
+ abortController.abort(new SessionAbortError({ reason: 'finished' }))
1232
+ }
1233
+
1234
+ function clearTypingInterval(): void {
1235
+ if (!typingInterval) {
1236
+ return
1237
+ }
1238
+ clearInterval(typingInterval)
1239
+ typingInterval = null
1240
+ }
1241
+
1242
+ function clearTypingRestartTimeout(): void {
1243
+ if (!typingRestartTimeout) {
1244
+ return
1245
+ }
1246
+ clearTimeout(typingRestartTimeout)
1247
+ typingRestartTimeout = null
1248
+ }
1249
+
1250
+ function stopTyping(): void {
1251
+ clearTypingInterval()
1252
+ clearTypingRestartTimeout()
1253
+ }
1254
+
1255
+ function startTyping(): void {
1256
+ if (abortController.signal.aborted || handlerClosed) {
1257
+ discordLogger.log(`Not starting typing, handler already closing`)
1258
+ return
1259
+ }
1260
+
1261
+ clearTypingRestartTimeout()
1262
+ clearTypingInterval()
1263
+
1264
+ void errore
1265
+ .tryAsync(() => thread.sendTyping())
1266
+ .then((result) => {
1267
+ if (result instanceof Error) {
1268
+ discordLogger.log(`Failed to send initial typing: ${result}`)
1269
+ }
1270
+ })
1271
+
1272
+ typingInterval = setInterval(() => {
1273
+ if (abortController.signal.aborted || handlerClosed) {
1274
+ clearTypingInterval()
1275
+ return
1276
+ }
1277
+ void errore
1278
+ .tryAsync(() => thread.sendTyping())
1279
+ .then((result) => {
1280
+ if (result instanceof Error) {
1281
+ discordLogger.log(`Failed to send periodic typing: ${result}`)
1282
+ }
1283
+ })
1284
+ }, 8000)
1285
+ }
1286
+
1287
+ function scheduleTypingRestart(): void {
1288
+ clearTypingRestartTimeout()
1289
+ if (abortController.signal.aborted || handlerClosed) {
1290
+ return
1291
+ }
1292
+
1293
+ typingRestartTimeout = setTimeout(() => {
1294
+ typingRestartTimeout = null
1295
+ if (abortController.signal.aborted || handlerClosed) {
1296
+ return
1297
+ }
1298
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
1299
+ (ctx) => {
1300
+ return ctx.thread.id === thread.id
1301
+ },
1302
+ )
1303
+ const hasPendingPermission =
1304
+ (pendingPermissions.get(thread.id)?.size ?? 0) > 0
1305
+ if (hasPendingQuestion || hasPendingPermission) {
1306
+ return
1307
+ }
1308
+ startTyping()
1309
+ }, 300)
1310
+ }
1311
+
1312
+ if (!abortController.signal.aborted) {
1313
+ abortController.signal.addEventListener(
1314
+ 'abort',
1315
+ () => {
1316
+ stopTyping()
1317
+ },
1318
+ { once: true },
1319
+ )
1320
+ }
1321
+
1322
+ // Read verbosity dynamically so mid-session /verbosity changes take effect immediately
1323
+ const verbosityChannelId = channelId || thread.parentId || thread.id
1324
+ const getVerbosity = async () => {
1325
+ return getChannelVerbosity(verbosityChannelId)
1326
+ }
1327
+
1328
+ const sendPartMessage = async (part: Part) => {
1329
+ const verbosity = await getVerbosity()
1330
+ // In text-only mode, only send text parts (the ⬥ diamond messages)
1331
+ if (verbosity === 'text-only' && part.type !== 'text') {
1332
+ return
1333
+ }
1334
+ // In text-and-essential-tools mode, show text + essential tools (edits, custom MCP tools)
1335
+ if (verbosity === 'text-and-essential-tools') {
1336
+ if (part.type === 'text') {
1337
+ // text is always shown
1338
+ } else if (part.type === 'tool' && isEssentialToolPart(part)) {
1339
+ // essential tools are shown
1340
+ } else {
1341
+ return
1342
+ }
1343
+ }
1344
+
1345
+ const content = formatPart(part) + '\n\n'
1346
+ if (!content.trim() || content.length === 0) {
1347
+ // discordLogger.log(`SKIP: Part ${part.id} has no content`)
1348
+ return
1349
+ }
1350
+
1351
+ if (sentPartIds.has(part.id)) {
1352
+ return
1353
+ }
1354
+ // Mark as sent BEFORE the async send to prevent concurrent flushes
1355
+ // (from message.updated, step-finish, finally block) from sending the
1356
+ // same part while this await is in-flight. If the send fails we remove
1357
+ // the id so a retry can pick it up.
1358
+ sentPartIds.add(part.id)
1359
+
1360
+ const sendResult = await errore.tryAsync(() => {
1361
+ return sendThreadMessage(thread, content)
1362
+ })
1363
+ if (sendResult instanceof Error) {
1364
+ sentPartIds.delete(part.id)
1365
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult)
1366
+ return
1367
+ }
1368
+ hasSentParts = true
1369
+ await setPartMessage(part.id, sendResult.id, thread.id)
1370
+ }
1371
+
1372
+ const eventHandler = async () => {
1373
+ // Subtask tracking: child sessionId → { label, assistantMessageId }
1374
+ const subtaskSessions = new Map<
1375
+ string,
1376
+ { label: string; assistantMessageId?: string }
1377
+ >()
1378
+ // Counts spawned tasks per agent type: "explore" → 2
1379
+ const agentSpawnCounts: Record<string, number> = {}
1380
+
1381
+ const storePart = (part: Part) => {
1382
+ const messageParts =
1383
+ partBuffer.get(part.messageID) || new Map<string, Part>()
1384
+ messageParts.set(part.id, part)
1385
+ partBuffer.set(part.messageID, messageParts)
1386
+ }
1387
+
1388
+ const getBufferedParts = (messageID: string) => {
1389
+ return Array.from(partBuffer.get(messageID)?.values() ?? [])
1390
+ }
1391
+
1392
+ const shouldSendPart = ({
1393
+ part,
1394
+ force,
1395
+ }: {
1396
+ part: Part
1397
+ force: boolean
1398
+ }) => {
1399
+ if (part.type === 'step-start' || part.type === 'step-finish') {
1400
+ return false
1401
+ }
1402
+
1403
+ if (part.type === 'tool' && part.state.status === 'pending') {
1404
+ return false
1405
+ }
1406
+
1407
+ if (!force && part.type === 'text' && !part.time?.end) {
1408
+ return false
1409
+ }
1410
+
1411
+ if (!force && part.type === 'tool' && part.state.status === 'completed') {
1412
+ return false
1413
+ }
1414
+
1415
+ return true
1416
+ }
1417
+
1418
+ const flushBufferedParts = async ({
1419
+ messageID,
1420
+ force,
1421
+ skipPartId,
1422
+ }: {
1423
+ messageID: string
1424
+ force: boolean
1425
+ skipPartId?: string
1426
+ }) => {
1427
+ if (!messageID) {
1428
+ return
1429
+ }
1430
+ const parts = getBufferedParts(messageID)
1431
+ for (const part of parts) {
1432
+ if (skipPartId && part.id === skipPartId) {
1433
+ continue
1434
+ }
1435
+ if (!shouldSendPart({ part, force })) {
1436
+ continue
1437
+ }
1438
+ await sendPartMessage(part)
1439
+ }
1440
+ }
1441
+
1442
+ const showInteractiveUi = async ({
1443
+ skipPartId,
1444
+ flushMessageId,
1445
+ show,
1446
+ }: {
1447
+ skipPartId?: string
1448
+ flushMessageId?: string
1449
+ show: () => Promise<void>
1450
+ }) => {
1451
+ stopTyping()
1452
+ const targetMessageId = flushMessageId || assistantMessageId
1453
+ if (targetMessageId) {
1454
+ await flushBufferedParts({
1455
+ messageID: targetMessageId,
1456
+ force: true,
1457
+ skipPartId,
1458
+ })
1459
+ }
1460
+ await show()
1461
+ }
1462
+
1463
+ const ensureModelContextLimit = async () => {
1464
+ if (!usedProviderID || !usedModel) {
1465
+ return
1466
+ }
1467
+
1468
+ const key = `${usedProviderID}/${usedModel}`
1469
+ if (modelContextLimit && modelContextLimitKey === key) {
1470
+ return
1471
+ }
1472
+
1473
+ const providersResponse = await errore.tryAsync(() => {
1474
+ return getClient().provider.list({
1475
+ directory: sdkDirectory,
1476
+ })
1477
+ })
1478
+ if (providersResponse instanceof Error) {
1479
+ sessionLogger.error(
1480
+ 'Failed to fetch provider info for context limit:',
1481
+ providersResponse,
1482
+ )
1483
+ return
1484
+ }
1485
+
1486
+ const provider = providersResponse.data?.all?.find(
1487
+ (p) => p.id === usedProviderID,
1488
+ )
1489
+ const model = provider?.models?.[usedModel]
1490
+ if (!model?.limit?.context) {
1491
+ return
1492
+ }
1493
+
1494
+ modelContextLimit = model.limit.context
1495
+ modelContextLimitKey = key
1496
+ }
1497
+
1498
+ const handleMessageUpdated = async (msg: {
1499
+ id: string
1500
+ sessionID: string
1501
+ role: string
1502
+ modelID?: string
1503
+ providerID?: string
1504
+ mode?: string
1505
+ tokens?: {
1506
+ input: number
1507
+ output: number
1508
+ reasoning: number
1509
+ cache: { read: number; write: number }
1510
+ }
1511
+ }) => {
1512
+ const subtaskInfo = subtaskSessions.get(msg.sessionID)
1513
+ if (subtaskInfo && msg.role === 'assistant') {
1514
+ subtaskInfo.assistantMessageId = msg.id
1515
+ }
1516
+
1517
+ if (msg.sessionID !== session.id) {
1518
+ return
1519
+ }
1520
+ if (msg.role !== 'assistant') {
1521
+ return
1522
+ }
1523
+
1524
+ sessionRunState.markCurrentPromptEvidence({
1525
+ store: mainRunStore,
1526
+ messageId: msg.id,
1527
+ })
1528
+
1529
+ if (msg.tokens) {
1530
+ const newTokensTotal =
1531
+ msg.tokens.input +
1532
+ msg.tokens.output +
1533
+ msg.tokens.reasoning +
1534
+ msg.tokens.cache.read +
1535
+ msg.tokens.cache.write
1536
+ if (newTokensTotal > 0) {
1537
+ tokensUsedInSession = newTokensTotal
1538
+ }
1539
+ }
1540
+
1541
+ assistantMessageId = msg.id
1542
+ usedModel = msg.modelID
1543
+ usedProviderID = msg.providerID
1544
+ usedAgent = msg.mode
1545
+
1546
+ await flushBufferedParts({
1547
+ messageID: assistantMessageId,
1548
+ force: false,
1549
+ })
1550
+
1551
+ if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
1552
+ return
1553
+ }
1554
+
1555
+ await ensureModelContextLimit()
1556
+
1557
+ if (!modelContextLimit) {
1558
+ return
1559
+ }
1560
+
1561
+ const currentPercentage = Math.floor(
1562
+ (tokensUsedInSession / modelContextLimit) * 100,
1563
+ )
1564
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
1565
+ if (
1566
+ thresholdCrossed <= lastDisplayedContextPercentage ||
1567
+ thresholdCrossed < 10
1568
+ ) {
1569
+ return
1570
+ }
1571
+ lastDisplayedContextPercentage = thresholdCrossed
1572
+ const chunk = `⬦ context usage ${currentPercentage}%`
1573
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
1574
+ }
1575
+
1576
+ const handleMainPart = async (part: Part) => {
1577
+ const isActiveMessage = assistantMessageId
1578
+ ? part.messageID === assistantMessageId
1579
+ : false
1580
+ const allowEarlyProcessing =
1581
+ !assistantMessageId &&
1582
+ part.type === 'tool' &&
1583
+ part.state.status === 'running'
1584
+ if (!isActiveMessage && !allowEarlyProcessing) {
1585
+ if (part.type !== 'step-start') {
1586
+ return
1587
+ }
1588
+ }
1589
+
1590
+ if (part.type === 'step-start') {
1591
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
1592
+ (ctx) => ctx.thread.id === thread.id,
1593
+ )
1594
+ const hasPendingPermission =
1595
+ (pendingPermissions.get(thread.id)?.size ?? 0) > 0
1596
+ if (!hasPendingQuestion && !hasPendingPermission) {
1597
+ startTyping()
1598
+ }
1599
+ return
1600
+ }
1601
+
1602
+ if (part.type === 'tool' && part.state.status === 'running') {
1603
+ await flushBufferedParts({
1604
+ messageID: assistantMessageId || part.messageID,
1605
+ force: true,
1606
+ skipPartId: part.id,
1607
+ })
1608
+ await sendPartMessage(part)
1609
+ if (part.tool === 'task' && !sentPartIds.has(part.id)) {
1610
+ const description = (part.state.input?.description as string) || ''
1611
+ const agent = (part.state.input?.subagent_type as string) || 'task'
1612
+ const childSessionId =
1613
+ (part.state.metadata?.sessionId as string) || ''
1614
+ if (description && childSessionId) {
1615
+ agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
1616
+ const label = `${agent}-${agentSpawnCounts[agent]}`
1617
+ subtaskSessions.set(childSessionId, {
1618
+ label,
1619
+ assistantMessageId: undefined,
1620
+ })
1621
+ // Show task messages in tools-and-text and text-and-essential-tools modes
1622
+ if ((await getVerbosity()) !== 'text-only') {
1623
+ const taskDisplay = `┣ task **${description}**${agent ? ` _${agent}_` : ''}`
1624
+ await sendThreadMessage(thread, taskDisplay + '\n\n')
1625
+ }
1626
+ sentPartIds.add(part.id)
1627
+ }
1628
+ }
1629
+ return
1630
+ }
1631
+
1632
+ // Show large output notifications for tools that are visible in current verbosity mode
1633
+ if (part.type === 'tool' && part.state.status === 'completed') {
1634
+ if (part.tool.endsWith('kimaki_action_buttons')) {
1635
+ await showInteractiveUi({
1636
+ skipPartId: part.id,
1637
+ flushMessageId: assistantMessageId || part.messageID,
1638
+ show: async () => {
1639
+ const request = await waitForQueuedActionButtonsRequest({
1640
+ sessionId: session.id,
1641
+ timeoutMs: 1500,
1642
+ })
1643
+ if (!request) {
1644
+ sessionLogger.warn(
1645
+ `[ACTION] No queued action-buttons request found for session ${session.id}`,
1646
+ )
1647
+ return
1648
+ }
1649
+ if (request.threadId !== thread.id) {
1650
+ sessionLogger.warn(
1651
+ `[ACTION] Ignoring queued action-buttons for different thread (expected: ${thread.id}, got: ${request.threadId})`,
1652
+ )
1653
+ return
1654
+ }
1655
+
1656
+ const showButtonsResult = await errore.tryAsync(() => {
1657
+ return showActionButtons({
1658
+ thread,
1659
+ sessionId: request.sessionId,
1660
+ directory: request.directory,
1661
+ buttons: request.buttons,
1662
+ })
1663
+ })
1664
+ if (!(showButtonsResult instanceof Error)) {
1665
+ return
1666
+ }
1667
+
1668
+ sessionLogger.error(
1669
+ '[ACTION] Failed to show action buttons:',
1670
+ showButtonsResult,
1671
+ )
1672
+ await sendThreadMessage(
1673
+ thread,
1674
+ `Failed to show action buttons: ${showButtonsResult.message}`,
1675
+ )
1676
+ },
1677
+ })
1678
+ return
1679
+ }
1680
+
1681
+ const showLargeOutput = await (async () => {
1682
+ const verbosity = await getVerbosity()
1683
+ if (verbosity === 'text-only') {
1684
+ return false
1685
+ }
1686
+ if (verbosity === 'text-and-essential-tools') {
1687
+ return isEssentialToolPart(part)
1688
+ }
1689
+ return true
1690
+ })()
1691
+ if (showLargeOutput) {
1692
+ const output = part.state.output || ''
1693
+ const outputTokens = Math.ceil(output.length / 4)
1694
+ const largeOutputThreshold = 3000
1695
+ if (outputTokens >= largeOutputThreshold) {
1696
+ await ensureModelContextLimit()
1697
+ const formattedTokens =
1698
+ outputTokens >= 1000
1699
+ ? `${(outputTokens / 1000).toFixed(1)}k`
1700
+ : String(outputTokens)
1701
+ const percentageSuffix = (() => {
1702
+ if (!modelContextLimit) {
1703
+ return ''
1704
+ }
1705
+ const pct = (outputTokens / modelContextLimit) * 100
1706
+ if (pct < 1) {
1707
+ return ''
1708
+ }
1709
+ return ` (${pct.toFixed(1)}%)`
1710
+ })()
1711
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
1712
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
1713
+ }
1714
+ }
1715
+ }
1716
+
1717
+ if (part.type === 'reasoning') {
1718
+ await sendPartMessage(part)
1719
+ return
1720
+ }
1721
+
1722
+ if (part.type === 'text' && part.time?.end) {
1723
+ await sendPartMessage(part)
1724
+ return
1725
+ }
1726
+
1727
+ if (part.type === 'step-finish') {
1728
+ await flushBufferedParts({
1729
+ messageID: assistantMessageId || part.messageID,
1730
+ force: true,
1731
+ })
1732
+ scheduleTypingRestart()
1733
+ }
1734
+ }
1735
+
1736
+ const handleSubtaskPart = async (
1737
+ part: Part,
1738
+ subtaskInfo: { label: string; assistantMessageId?: string },
1739
+ ) => {
1740
+ const verbosity = await getVerbosity()
1741
+ // In text-only mode, skip all subtask output (they're tool-related)
1742
+ if (verbosity === 'text-only') {
1743
+ return
1744
+ }
1745
+ // In text-and-essential-tools mode, only show essential tools from subtasks
1746
+ if (verbosity === 'text-and-essential-tools') {
1747
+ if (!isEssentialToolPart(part)) {
1748
+ return
1749
+ }
1750
+ }
1751
+ if (part.type === 'step-start' || part.type === 'step-finish') {
1752
+ return
1753
+ }
1754
+ if (part.type === 'tool' && part.state.status === 'pending') {
1755
+ return
1756
+ }
1757
+ if (part.type === 'text') {
1758
+ return
1759
+ }
1760
+ if (
1761
+ !subtaskInfo.assistantMessageId ||
1762
+ part.messageID !== subtaskInfo.assistantMessageId
1763
+ ) {
1764
+ return
1765
+ }
1766
+
1767
+ const content = formatPart(part, subtaskInfo.label)
1768
+ if (!content.trim() || sentPartIds.has(part.id)) {
1769
+ return
1770
+ }
1771
+ const sendResult = await errore.tryAsync(() => {
1772
+ return sendThreadMessage(thread, content + '\n\n')
1773
+ })
1774
+ if (sendResult instanceof Error) {
1775
+ discordLogger.error(
1776
+ `ERROR: Failed to send subtask part ${part.id}:`,
1777
+ sendResult,
1778
+ )
1779
+ return
1780
+ }
1781
+ sentPartIds.add(part.id)
1782
+ await setPartMessage(part.id, sendResult.id, thread.id)
1783
+ }
1784
+
1785
+ const handlePartUpdated = async (part: Part) => {
1786
+ storePart(part)
1787
+
1788
+ const subtaskInfo = subtaskSessions.get(part.sessionID)
1789
+ const isSubtaskEvent = Boolean(subtaskInfo)
1790
+
1791
+ if (part.sessionID !== session.id && !isSubtaskEvent) {
1792
+ return
1793
+ }
1794
+
1795
+ if (part.sessionID === session.id) {
1796
+ sessionRunState.markCurrentPromptEvidence({
1797
+ store: mainRunStore,
1798
+ messageId: part.messageID,
1799
+ })
1800
+ }
1801
+
1802
+ if (isSubtaskEvent && subtaskInfo) {
1803
+ await handleSubtaskPart(part, subtaskInfo)
1804
+ return
1805
+ }
1806
+
1807
+ await handleMainPart(part)
1808
+ }
1809
+
1810
+ const handleSessionError = async ({
1811
+ sessionID,
1812
+ error,
1813
+ }: {
1814
+ sessionID?: string
1815
+ error?: {
1816
+ data?: {
1817
+ message?: string
1818
+ statusCode?: number
1819
+ providerID?: string
1820
+ isRetryable?: boolean
1821
+ responseBody?: string
1822
+ }
1823
+ name?: string
1824
+ }
1825
+ }) => {
1826
+ if (!sessionID || sessionID !== session.id) {
1827
+ sessionLogger.log(
1828
+ `Ignoring error for different session (expected: ${session.id}, got: ${sessionID})`,
1829
+ )
1830
+ return
1831
+ }
1832
+
1833
+ // Skip abort errors from the server — these are expected when operations
1834
+ // are cancelled. Checks server error name and the local abort signal.
1835
+ if (
1836
+ error?.name === 'MessageAbortedError' ||
1837
+ abortController.signal.aborted
1838
+ ) {
1839
+ sessionLogger.log(`Operation aborted (expected)`)
1840
+ return
1841
+ }
1842
+ const errorMessage = formatSessionError(error)
1843
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`)
1844
+ const errorPayload = (() => {
1845
+ try {
1846
+ return JSON.stringify(error)
1847
+ } catch {
1848
+ return '[unserializable error payload]'
1849
+ }
1850
+ })()
1851
+ sessionLogger.error(`Session error payload:`, errorPayload)
1852
+ await sendThreadMessage(
1853
+ thread,
1854
+ `✗ opencode session error: ${errorMessage}`,
1855
+ )
1856
+
1857
+ if (!originalMessage) {
1858
+ return
1859
+ }
1860
+ const reactionResult = await errore.tryAsync(async () => {
1861
+ await originalMessage.react('❌')
1862
+ })
1863
+ if (reactionResult instanceof Error) {
1864
+ discordLogger.log(`Could not update reaction:`, reactionResult)
1865
+ } else {
1866
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`)
1867
+ }
1868
+ }
1869
+
1870
+ const handlePermissionAsked = async (permission: PermissionRequest) => {
1871
+ const isMainSession = permission.sessionID === session.id
1872
+ const isSubtaskSession = subtaskSessions.has(permission.sessionID)
1873
+
1874
+ if (!isMainSession && !isSubtaskSession) {
1875
+ voiceLogger.log(
1876
+ `[PERMISSION IGNORED] Permission for unknown session (expected: ${session.id} or subtask, got: ${permission.sessionID})`,
1877
+ )
1878
+ return
1879
+ }
1880
+
1881
+ const subtaskLabel = isSubtaskSession
1882
+ ? subtaskSessions.get(permission.sessionID)?.label
1883
+ : undefined
1884
+
1885
+ const dedupeKey = buildPermissionDedupeKey({ permission, directory })
1886
+ const threadPermissions = pendingPermissions.get(thread.id)
1887
+ const existingPending = threadPermissions
1888
+ ? Array.from(threadPermissions.values()).find((pending) => {
1889
+ if (pending.dedupeKey === dedupeKey) {
1890
+ return true
1891
+ }
1892
+ if (pending.directory !== directory) {
1893
+ return false
1894
+ }
1895
+ if (pending.permission.permission !== permission.permission) {
1896
+ return false
1897
+ }
1898
+ return arePatternsCoveredBy({
1899
+ patterns: permission.patterns,
1900
+ coveringPatterns: pending.permission.patterns,
1901
+ })
1902
+ })
1903
+ : undefined
1904
+
1905
+ if (existingPending) {
1906
+ sessionLogger.log(
1907
+ `[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`,
1908
+ )
1909
+ stopTyping()
1910
+ if (!pendingPermissions.has(thread.id)) {
1911
+ pendingPermissions.set(thread.id, new Map())
1912
+ }
1913
+ pendingPermissions.get(thread.id)!.set(permission.id, {
1914
+ permission,
1915
+ messageId: existingPending.messageId,
1916
+ directory,
1917
+ permissionDirectory: existingPending.permissionDirectory,
1918
+ contextHash: existingPending.contextHash,
1919
+ dedupeKey,
1920
+ })
1921
+ const added = addPermissionRequestToContext({
1922
+ contextHash: existingPending.contextHash,
1923
+ requestId: permission.id,
1924
+ })
1925
+ if (!added) {
1926
+ sessionLogger.log(
1927
+ `[PERMISSION] Failed to attach duplicate request ${permission.id} to context`,
1928
+ )
1929
+ }
1930
+ return
1931
+ }
1932
+
1933
+ sessionLogger.log(
1934
+ `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`,
1935
+ )
1936
+
1937
+ stopTyping()
1938
+
1939
+ const { messageId, contextHash } = await showPermissionButtons({
1940
+ thread,
1941
+ permission,
1942
+ directory,
1943
+ permissionDirectory: sdkDirectory,
1944
+ subtaskLabel,
1945
+ })
1946
+
1947
+ if (!pendingPermissions.has(thread.id)) {
1948
+ pendingPermissions.set(thread.id, new Map())
1949
+ }
1950
+ pendingPermissions.get(thread.id)!.set(permission.id, {
1951
+ permission,
1952
+ messageId,
1953
+ directory,
1954
+ permissionDirectory: sdkDirectory,
1955
+ contextHash,
1956
+ dedupeKey,
1957
+ })
1958
+ }
1959
+
1960
+ const handlePermissionReplied = ({
1961
+ requestID,
1962
+ reply,
1963
+ sessionID,
1964
+ }: {
1965
+ requestID: string
1966
+ reply: string
1967
+ sessionID: string
1968
+ }) => {
1969
+ const isMainSession = sessionID === session.id
1970
+ const isSubtaskSession = subtaskSessions.has(sessionID)
1971
+
1972
+ if (!isMainSession && !isSubtaskSession) {
1973
+ return
1974
+ }
1975
+
1976
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`)
1977
+
1978
+ const threadPermissions = pendingPermissions.get(thread.id)
1979
+ if (!threadPermissions) {
1980
+ return
1981
+ }
1982
+ const pending = threadPermissions.get(requestID)
1983
+ if (!pending) {
1984
+ return
1985
+ }
1986
+ cleanupPermissionContext(pending.contextHash)
1987
+ threadPermissions.delete(requestID)
1988
+ if (threadPermissions.size === 0) {
1989
+ pendingPermissions.delete(thread.id)
1990
+ }
1991
+ }
1992
+
1993
+ const handleQuestionAsked = async (questionRequest: QuestionRequest) => {
1994
+ if (questionRequest.sessionID !== session.id) {
1995
+ sessionLogger.log(
1996
+ `[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
1997
+ )
1998
+ return
1999
+ }
2000
+
2001
+ sessionLogger.log(
2002
+ `Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
2003
+ )
2004
+
2005
+ await showInteractiveUi({
2006
+ flushMessageId: assistantMessageId,
2007
+ show: async () => {
2008
+ await showAskUserQuestionDropdowns({
2009
+ thread,
2010
+ sessionId: session.id,
2011
+ directory,
2012
+ requestId: questionRequest.id,
2013
+ input: { questions: questionRequest.questions },
2014
+ })
2015
+ },
2016
+ })
2017
+
2018
+ const queue = messageQueue.get(thread.id)
2019
+ if (!queue || queue.length === 0) {
2020
+ return
2021
+ }
2022
+
2023
+ const nextMessage = queue.shift()!
2024
+ if (queue.length === 0) {
2025
+ messageQueue.delete(thread.id)
2026
+ }
2027
+
2028
+ sessionLogger.log(
2029
+ `[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
2030
+ )
2031
+
2032
+ const displayText = nextMessage.command
2033
+ ? `/${nextMessage.command.name}`
2034
+ : `${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`
2035
+ await sendThreadMessage(
2036
+ thread,
2037
+ `» **${nextMessage.username}:** ${displayText}`,
2038
+ )
2039
+
2040
+ setImmediate(() => {
2041
+ void errore
2042
+ .tryAsync(async () => {
2043
+ return handleOpencodeSession({
2044
+ prompt: nextMessage.prompt,
2045
+ thread,
2046
+ projectDirectory: directory,
2047
+ images: nextMessage.images,
2048
+ channelId,
2049
+ username: nextMessage.username,
2050
+ appId: nextMessage.appId,
2051
+ command: nextMessage.command,
2052
+ })
2053
+ })
2054
+ .then(async (result) => {
2055
+ if (!(result instanceof Error)) {
2056
+ return
2057
+ }
2058
+ sessionLogger.error(
2059
+ `[QUEUE] Failed to process queued message:`,
2060
+ result,
2061
+ )
2062
+ await sendThreadMessage(
2063
+ thread,
2064
+ `✗ Queued message failed: ${result.message.slice(0, 200)}`,
2065
+ )
2066
+ })
2067
+ })
2068
+ }
2069
+
2070
+ const handleSessionStatus = async (properties: {
2071
+ sessionID: string
2072
+ status:
2073
+ | { type: 'idle' }
2074
+ | { type: 'retry'; attempt: number; message: string; next: number }
2075
+ | { type: 'busy' }
2076
+ }) => {
2077
+ if (properties.sessionID !== session.id) {
2078
+ return
2079
+ }
2080
+ if (properties.status.type !== 'retry') {
2081
+ return
2082
+ }
2083
+ // Throttle to once per 10 seconds
2084
+ const now = Date.now()
2085
+ if (now - lastRateLimitDisplayTime < 10_000) {
2086
+ return
2087
+ }
2088
+ lastRateLimitDisplayTime = now
2089
+
2090
+ const { attempt, message, next } = properties.status
2091
+ const remainingMs = Math.max(0, next - now)
2092
+ const remainingSec = Math.ceil(remainingMs / 1000)
2093
+
2094
+ const duration = (() => {
2095
+ if (remainingSec < 60) {
2096
+ return `${remainingSec}s`
2097
+ }
2098
+ const mins = Math.floor(remainingSec / 60)
2099
+ const secs = remainingSec % 60
2100
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
2101
+ })()
2102
+
2103
+ const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`
2104
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2105
+ }
2106
+
2107
+ const handleTuiToast = async (properties: {
2108
+ title?: string
2109
+ message: string
2110
+ variant: 'info' | 'success' | 'warning' | 'error'
2111
+ duration?: number
2112
+ }) => {
2113
+ if (properties.variant === 'warning') {
2114
+ return
2115
+ }
2116
+ const message = properties.message.trim()
2117
+ if (!message) {
2118
+ return
2119
+ }
2120
+ const titlePrefix = properties.title ? `${properties.title.trim()}: ` : ''
2121
+ const chunk = `⬦ ${properties.variant}: ${titlePrefix}${message}`
2122
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
2123
+ }
2124
+
2125
+ const handleSessionIdle = (idleSessionId: string) => {
2126
+ if (idleSessionId === session.id) {
2127
+ const idleDecision = sessionRunState.handleMainSessionIdle({
2128
+ store: mainRunStore,
2129
+ })
2130
+ if (idleDecision === 'deferred') {
2131
+ sessionLogger.log(
2132
+ `[SESSION IDLE] Deferring idle event for ${session.id} until prompt resolves`,
2133
+ )
2134
+ return
2135
+ }
2136
+
2137
+ if (idleDecision === 'ignore-no-evidence') {
2138
+ sessionLogger.log(
2139
+ `[SESSION IDLE] Ignoring idle event for ${session.id} (no current-prompt events yet)`,
2140
+ )
2141
+ return
2142
+ }
2143
+
2144
+ finishMainSessionFromIdle()
2145
+ return
2146
+ }
2147
+
2148
+ if (!subtaskSessions.has(idleSessionId)) {
2149
+ return
2150
+ }
2151
+ const subtask = subtaskSessions.get(idleSessionId)
2152
+ sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`)
2153
+ subtaskSessions.delete(idleSessionId)
2154
+ }
2155
+
2156
+ try {
2157
+ for await (const event of events) {
2158
+ switch (event.type) {
2159
+ case 'message.updated':
2160
+ await handleMessageUpdated(event.properties.info)
2161
+ break
2162
+ case 'message.part.updated':
2163
+ await handlePartUpdated(event.properties.part)
2164
+ break
2165
+ case 'session.error':
2166
+ await handleSessionError(event.properties)
2167
+ break
2168
+ case 'permission.asked':
2169
+ await handlePermissionAsked(event.properties)
2170
+ break
2171
+ case 'permission.replied':
2172
+ handlePermissionReplied(event.properties)
2173
+ break
2174
+ case 'question.asked':
2175
+ await handleQuestionAsked(event.properties)
2176
+ break
2177
+ case 'session.idle':
2178
+ handleSessionIdle(event.properties.sessionID)
2179
+ break
2180
+ case 'session.status':
2181
+ await handleSessionStatus(event.properties)
2182
+ break
2183
+ case 'tui.toast.show':
2184
+ await handleTuiToast(event.properties)
2185
+ break
2186
+ default:
2187
+ break
2188
+ }
2189
+ }
2190
+ } catch (e) {
2191
+ if (isAbortError(e)) {
2192
+ sessionLogger.log(
2193
+ 'AbortController aborted event handling (normal exit)',
2194
+ )
2195
+ return
2196
+ }
2197
+ sessionLogger.error(`Unexpected error in event handling code`, e)
2198
+ throw e
2199
+ } finally {
2200
+ handlerClosed = true
2201
+ const activeController = abortControllers.get(session.id)
2202
+ if (activeController === abortController) {
2203
+ abortControllers.delete(session.id)
2204
+ }
2205
+ const abortReason =
2206
+ abortController.signal.reason instanceof SessionAbortError
2207
+ ? abortController.signal.reason.reason
2208
+ : undefined
2209
+ if (
2210
+ abortController.signal.aborted &&
2211
+ mainRunStore.getState().phase !== 'finished'
2212
+ ) {
2213
+ sessionRunState.markAborted({ store: mainRunStore })
2214
+ }
2215
+ const shouldFlushFinalParts =
2216
+ !abortController.signal.aborted || abortReason === 'finished'
2217
+ if (shouldFlushFinalParts) {
2218
+ const finalMessageId = assistantMessageId
2219
+ if (finalMessageId) {
2220
+ const parts = getBufferedParts(finalMessageId)
2221
+ for (const part of parts) {
2222
+ if (!sentPartIds.has(part.id)) {
2223
+ await sendPartMessage(part)
2224
+ }
2225
+ }
2226
+ }
2227
+ }
2228
+
2229
+ stopTyping()
2230
+
2231
+ if (!abortController.signal.aborted || abortReason === 'finished') {
2232
+ const sessionDuration = prettyMilliseconds(
2233
+ Date.now() - sessionStartTime,
2234
+ {
2235
+ secondsDecimalDigits: 0,
2236
+ },
2237
+ )
2238
+ const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
2239
+ const agentInfo =
2240
+ usedAgent && usedAgent.toLowerCase() !== 'build'
2241
+ ? ` ⋅ **${usedAgent}**`
2242
+ : ''
2243
+ let contextInfo = ''
2244
+ const folderName = path.basename(sdkDirectory)
2245
+
2246
+ // Run git branch, token fetch, and provider list in parallel to
2247
+ // minimize footer latency (matters for archive-thread 5s delay race)
2248
+ const [branchResult, contextResult] = await Promise.all([
2249
+ errore.tryAsync(() => {
2250
+ return execAsync('git symbolic-ref --short HEAD', {
2251
+ cwd: sdkDirectory,
2252
+ })
2253
+ }),
2254
+ errore.tryAsync(async () => {
2255
+ // Fetch final token count from API since message.updated events can arrive
2256
+ // after session.idle due to race conditions in event ordering
2257
+ const [messagesResult, providersResult] = await Promise.all([
2258
+ tokensUsedInSession === 0
2259
+ ? errore.tryAsync(() => {
2260
+ return getClient().session.messages({
2261
+ sessionID: session.id,
2262
+ directory: sdkDirectory,
2263
+ })
2264
+ })
2265
+ : null,
2266
+ errore.tryAsync(() => {
2267
+ return getClient().provider.list({
2268
+ directory: sdkDirectory,
2269
+ })
2270
+ }),
2271
+ ])
2272
+
2273
+ if (messagesResult && !(messagesResult instanceof Error)) {
2274
+ const messages = messagesResult.data || []
2275
+ const lastAssistant = [...messages]
2276
+ .reverse()
2277
+ .find((m) => m.info.role === 'assistant')
2278
+ if (lastAssistant && 'tokens' in lastAssistant.info) {
2279
+ const tokens = lastAssistant.info.tokens as {
2280
+ input: number
2281
+ output: number
2282
+ reasoning: number
2283
+ cache: { read: number; write: number }
2284
+ }
2285
+ tokensUsedInSession =
2286
+ tokens.input +
2287
+ tokens.output +
2288
+ tokens.reasoning +
2289
+ tokens.cache.read +
2290
+ tokens.cache.write
2291
+ }
2292
+ }
2293
+
2294
+ if (providersResult && !(providersResult instanceof Error)) {
2295
+ const provider = providersResult.data?.all?.find(
2296
+ (p) => p.id === usedProviderID,
2297
+ )
2298
+ const model = provider?.models?.[usedModel || '']
2299
+ if (model?.limit?.context) {
2300
+ const percentage = Math.round(
2301
+ (tokensUsedInSession / model.limit.context) * 100,
2302
+ )
2303
+ contextInfo = ` ⋅ ${percentage}%`
2304
+ }
2305
+ }
2306
+ }),
2307
+ ])
2308
+ const branchName =
2309
+ branchResult instanceof Error ? '' : branchResult.stdout.trim()
2310
+ if (contextResult instanceof Error) {
2311
+ sessionLogger.error(
2312
+ 'Failed to fetch provider info for context percentage:',
2313
+ contextResult,
2314
+ )
2315
+ }
2316
+
2317
+ const projectInfo = branchName
2318
+ ? `${folderName} ⋅ ${branchName} ⋅ `
2319
+ : `${folderName} ⋅ `
2320
+ await sendThreadMessage(
2321
+ thread,
2322
+ `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`,
2323
+ { flags: NOTIFY_MESSAGE_FLAGS },
2324
+ )
2325
+ sessionLogger.log(
2326
+ `DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`,
2327
+ )
2328
+
2329
+ // Process queued messages after completion
2330
+ const queue = messageQueue.get(thread.id)
2331
+ if (queue && queue.length > 0) {
2332
+ const nextMessage = queue.shift()!
2333
+ if (queue.length === 0) {
2334
+ messageQueue.delete(thread.id)
2335
+ }
2336
+
2337
+ sessionLogger.log(
2338
+ `[QUEUE] Processing queued message from ${nextMessage.username}`,
2339
+ )
2340
+
2341
+ // Show that queued message is being sent
2342
+ const displayText = nextMessage.command
2343
+ ? `/${nextMessage.command.name}`
2344
+ : `${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`
2345
+ await sendThreadMessage(
2346
+ thread,
2347
+ `» **${nextMessage.username}:** ${displayText}`,
2348
+ )
2349
+
2350
+ // Send the queued message as a new prompt (recursive call)
2351
+ // Use setImmediate to avoid blocking and allow this finally to complete
2352
+ setImmediate(() => {
2353
+ handleOpencodeSession({
2354
+ prompt: nextMessage.prompt,
2355
+ thread,
2356
+ projectDirectory,
2357
+ images: nextMessage.images,
2358
+ channelId,
2359
+ username: nextMessage.username,
2360
+ appId: nextMessage.appId,
2361
+ command: nextMessage.command,
2362
+ }).catch(async (e) => {
2363
+ sessionLogger.error(
2364
+ `[QUEUE] Failed to process queued message:`,
2365
+ e,
2366
+ )
2367
+ void notifyError(e, 'Queued message processing failed')
2368
+ const errorMsg = e instanceof Error ? e.message : String(e)
2369
+ await sendThreadMessage(
2370
+ thread,
2371
+ `✗ Queued message failed: ${errorMsg.slice(0, 200)}`,
2372
+ )
2373
+ })
2374
+ })
2375
+ }
2376
+ } else {
2377
+ sessionLogger.log(
2378
+ `Session was aborted (reason: ${abortReason}), skipping duration message`,
2379
+ )
2380
+ }
2381
+ }
2382
+ }
2383
+
2384
+ const promptResult:
2385
+ | Error
2386
+ | { sessionID: string; result: any; port?: number }
2387
+ | undefined = await errore.tryAsync(async () => {
2388
+ const newHandlerPromise = eventHandler().finally(() => {
2389
+ if (activeEventHandlers.get(thread.id) === newHandlerPromise) {
2390
+ activeEventHandlers.delete(thread.id)
2391
+ }
2392
+ })
2393
+ activeEventHandlers.set(thread.id, newHandlerPromise)
2394
+ handlerPromise = newHandlerPromise
2395
+
2396
+ if (abortController.signal.aborted) {
2397
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
2398
+ return
2399
+ }
2400
+
2401
+ startTyping()
2402
+
2403
+ voiceLogger.log(
2404
+ `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
2405
+ )
2406
+ const promptWithImagePaths = (() => {
2407
+ if (images.length === 0) {
2408
+ return prompt
2409
+ }
2410
+ sessionLogger.log(
2411
+ `[PROMPT] Sending ${images.length} image(s):`,
2412
+ images.map((img) => ({
2413
+ mime: img.mime,
2414
+ filename: img.filename,
2415
+ sourceUrl: img.sourceUrl,
2416
+ })),
2417
+ )
2418
+ // List source URLs and clarify these images are already in context (not paths to read)
2419
+ const imageList = images
2420
+ .map((img) => `- ${img.sourceUrl || img.filename}`)
2421
+ .join('\n')
2422
+ return `${prompt}\n\n**The following images are already included in this message as inline content (do not use Read tool on these):**\n${imageList}`
2423
+ })()
2424
+
2425
+ // Synthetic context for the model (hidden in TUI)
2426
+ let syntheticContext = ''
2427
+ if (username) {
2428
+ syntheticContext += `<discord-user name="${username}" />`
2429
+ }
2430
+ const parts = [
2431
+ { type: 'text' as const, text: promptWithImagePaths },
2432
+ { type: 'text' as const, text: syntheticContext, synthetic: true },
2433
+ ...images,
2434
+ ]
2435
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
2436
+
2437
+ // Use model+agent snapshotted at message arrival (before debounce/subscribe gap)
2438
+ const agentPreference = earlyAgentPreference
2439
+ const modelParam = earlyModelParam
2440
+
2441
+ // Build worktree info for system message (worktreeInfo was fetched at the start)
2442
+ const worktree: WorktreeInfo | undefined =
2443
+ worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
2444
+ ? {
2445
+ worktreeDirectory: worktreeInfo.worktree_directory,
2446
+ branch: worktreeInfo.worktree_name,
2447
+ mainRepoDirectory: worktreeInfo.project_directory,
2448
+ }
2449
+ : undefined
2450
+
2451
+ const channelTopic = await (async () => {
2452
+ if (thread.parent?.type === ChannelType.GuildText) {
2453
+ return thread.parent.topic?.trim() || undefined
2454
+ }
2455
+ if (!channelId) {
2456
+ return undefined
2457
+ }
2458
+ const fetched = await errore.tryAsync(() => {
2459
+ return thread.guild.channels.fetch(channelId)
2460
+ })
2461
+ if (fetched instanceof Error || !fetched) {
2462
+ return undefined
2463
+ }
2464
+ if (fetched.type !== ChannelType.GuildText) {
2465
+ return undefined
2466
+ }
2467
+ return fetched.topic?.trim() || undefined
2468
+ })()
2469
+
2470
+ hasSentParts = false
2471
+ sessionRunState.beginPromptCycle({ store: mainRunStore })
2472
+ const messagesBeforePromptResult = await errore.tryAsync(() => {
2473
+ return getClient().session.messages({
2474
+ sessionID: session.id,
2475
+ directory: sdkDirectory,
2476
+ })
2477
+ })
2478
+ if (messagesBeforePromptResult instanceof Error) {
2479
+ sessionLogger.log(
2480
+ `[SESSION IDLE] Could not snapshot pre-prompt assistant message for ${session.id}: ${messagesBeforePromptResult.message}`,
2481
+ )
2482
+ } else {
2483
+ const messagesBeforePrompt = messagesBeforePromptResult.data || []
2484
+ const baselineAssistantIds = new Set(
2485
+ messagesBeforePrompt
2486
+ .filter((message) => message.info.role === 'assistant')
2487
+ .map((message) => message.info.id),
2488
+ )
2489
+ sessionRunState.setBaselineAssistantIds({
2490
+ store: mainRunStore,
2491
+ messageIds: baselineAssistantIds,
2492
+ })
2493
+ }
2494
+
2495
+ // variant is accepted by the server API but not yet in the v1 SDK types
2496
+ const variantField = earlyThinkingValue
2497
+ ? { variant: earlyThinkingValue }
2498
+ : {}
2499
+
2500
+ sessionRunState.markDispatching({ store: mainRunStore })
2501
+
2502
+ const response = command
2503
+ ? await getClient().session.command(
2504
+ {
2505
+ sessionID: session.id,
2506
+ directory: sdkDirectory,
2507
+ command: command.name,
2508
+ arguments: command.arguments,
2509
+ agent: agentPreference,
2510
+ ...variantField,
2511
+ },
2512
+ { signal: abortController.signal },
2513
+ )
2514
+ : await getClient().session.prompt(
2515
+ {
2516
+ sessionID: session.id,
2517
+ directory: sdkDirectory,
2518
+ parts,
2519
+ system: getOpencodeSystemMessage({
2520
+ sessionId: session.id,
2521
+ channelId,
2522
+ guildId: thread.guildId,
2523
+ threadId: thread.id,
2524
+ worktree,
2525
+ channelTopic,
2526
+ username,
2527
+ userId,
2528
+ agents: earlyAvailableAgents,
2529
+ }),
2530
+ model: modelParam,
2531
+ agent: agentPreference,
2532
+ ...variantField,
2533
+ },
2534
+ { signal: abortController.signal },
2535
+ )
2536
+
2537
+ if (response.error) {
2538
+ const errorMessage = (() => {
2539
+ const err = response.error
2540
+ if (err && typeof err === 'object') {
2541
+ if (
2542
+ 'data' in err &&
2543
+ err.data &&
2544
+ typeof err.data === 'object' &&
2545
+ 'message' in err.data
2546
+ ) {
2547
+ return String(err.data.message)
2548
+ }
2549
+ if (
2550
+ 'errors' in err &&
2551
+ Array.isArray(err.errors) &&
2552
+ err.errors.length > 0
2553
+ ) {
2554
+ return JSON.stringify(err.errors)
2555
+ }
2556
+ }
2557
+ return JSON.stringify(err)
2558
+ })()
2559
+
2560
+ const responseStatus = (() => {
2561
+ const httpStatus = response.response?.status
2562
+ if (typeof httpStatus === 'number') {
2563
+ return String(httpStatus)
2564
+ }
2565
+
2566
+ const err = response.error
2567
+ if (!err || typeof err !== 'object' || !('data' in err)) {
2568
+ return 'unknown'
2569
+ }
2570
+
2571
+ const data = err.data
2572
+ if (
2573
+ !data ||
2574
+ typeof data !== 'object' ||
2575
+ !('statusCode' in data) ||
2576
+ typeof data.statusCode !== 'number'
2577
+ ) {
2578
+ return 'unknown'
2579
+ }
2580
+
2581
+ return String(data.statusCode)
2582
+ })()
2583
+
2584
+ throw new Error(
2585
+ `OpenCode API error (${responseStatus}): ${errorMessage}`,
2586
+ )
2587
+ }
2588
+
2589
+ const deferredIdleDecision =
2590
+ sessionRunState.markPromptResolvedAndConsumeDeferredIdle({
2591
+ store: mainRunStore,
2592
+ })
2593
+ if (deferredIdleDecision === 'ignore-no-evidence') {
2594
+ sessionLogger.log(
2595
+ `[SESSION IDLE] Ignoring deferred idle for ${session.id} because no current-prompt events were observed`,
2596
+ )
2597
+ } else if (deferredIdleDecision === 'ignore-before-evidence') {
2598
+ sessionLogger.log(
2599
+ `[SESSION IDLE] Ignoring deferred idle for ${session.id} because it arrived before current-prompt evidence`,
2600
+ )
2601
+ } else if (deferredIdleDecision === 'process') {
2602
+ sessionLogger.log(
2603
+ `[SESSION IDLE] Processing deferred idle for ${session.id} after prompt resolved`,
2604
+ )
2605
+ finishMainSessionFromIdle()
2606
+ }
2607
+
2608
+ sessionLogger.log(`Successfully sent prompt, got response`)
2609
+
2610
+ if (originalMessage) {
2611
+ const reactionResult = await errore.tryAsync(async () => {
2612
+ await removeBotErrorReaction({ message: originalMessage })
2613
+ })
2614
+ if (reactionResult instanceof Error) {
2615
+ discordLogger.log(`Could not update reactions:`, reactionResult)
2616
+ }
2617
+ }
2618
+
2619
+ return { sessionID: session.id, result: response.data, port }
2620
+ })
2621
+
2622
+ if (handlerPromise) {
2623
+ await Promise.race([
2624
+ handlerPromise,
2625
+ new Promise((resolve) => {
2626
+ setTimeout(resolve, 1000)
2627
+ }),
2628
+ ])
2629
+ }
2630
+
2631
+ if (!errore.isError(promptResult)) {
2632
+ return promptResult
2633
+ }
2634
+
2635
+ const promptError: Error =
2636
+ promptResult instanceof Error ? promptResult : new Error('Unknown error')
2637
+ if (isAbortError(promptError)) {
2638
+ return
2639
+ }
2640
+
2641
+ sessionLogger.error(
2642
+ `ERROR: Failed to send prompt: ${(promptError as Error).message}`,
2643
+ )
2644
+ void notifyError(promptError, 'Failed to send prompt to OpenCode')
2645
+ sessionLogger.log(
2646
+ `[ABORT] reason=error sessionId=${session.id} threadId=${thread.id} - prompt failed with error: ${(promptError as Error).message}`,
2647
+ )
2648
+ sessionRunState.markAborted({ store: mainRunStore })
2649
+ abortController.abort(new SessionAbortError({ reason: 'error' }))
2650
+
2651
+ if (originalMessage) {
2652
+ const reactionResult = await errore.tryAsync(async () => {
2653
+ await originalMessage.react('❌')
2654
+ })
2655
+ if (reactionResult instanceof Error) {
2656
+ discordLogger.log(`Could not update reaction:`, reactionResult)
2657
+ } else {
2658
+ discordLogger.log(`Added error reaction to message`)
2659
+ }
2660
+ }
2661
+ const errorDisplay = (() => {
2662
+ const promptErrorValue = promptError as unknown as Error
2663
+ const name = promptErrorValue.name || 'Error'
2664
+ const message = promptErrorValue.stack || promptErrorValue.message
2665
+ return `[${name}]\n${message}`
2666
+ })()
2667
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`)
2668
+ }