@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,1874 @@
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
+ import { ChannelType } from 'discord.js';
5
+ import prettyMilliseconds from 'pretty-ms';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { xdgState } from 'xdg-basedir';
9
+ import { getSessionAgent, getSessionModel, getVariantCascade, getChannelAgent, setSessionAgent, getThreadWorktree, getChannelVerbosity, getThreadSession, setThreadSession, getPartMessageIds, setPartMessage, getChannelDirectory, setSessionStartSource, } from './database.js';
10
+ import { ensureSessionPreferencesSnapshot, getCurrentModelInfo, } from './commands/model.js';
11
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClient, } from './opencode.js';
12
+ import { sendThreadMessage, resolveWorkingDirectory, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
13
+ import { formatPart } from './message-formatting.js';
14
+ import { getOpencodeSystemMessage, } from './system-message.js';
15
+ import { createLogger, LogPrefix } from './logger.js';
16
+ import { isAbortError } from './utils.js';
17
+ import { SessionAbortError } from './errors.js';
18
+ import { notifyError } from './sentry.js';
19
+ import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
20
+ import { showPermissionButtons, cleanupPermissionContext, addPermissionRequestToContext, arePatternsCoveredBy, } from './commands/permissions.js';
21
+ import { cancelPendingFileUpload } from './commands/file-upload.js';
22
+ import { cancelPendingActionButtons, showActionButtons, waitForQueuedActionButtonsRequest, } from './commands/action-buttons.js';
23
+ import { getThinkingValuesForModel, matchThinkingValue, } from './thinking-utils.js';
24
+ import { execAsync } from './worktree-utils.js';
25
+ import * as errore from 'errore';
26
+ import * as sessionRunState from './session-handler/state.js';
27
+ const sessionLogger = createLogger(LogPrefix.SESSION);
28
+ const voiceLogger = createLogger(LogPrefix.VOICE);
29
+ const discordLogger = createLogger(LogPrefix.DISCORD);
30
+ export const abortControllers = new Map();
31
+ /** Format opencode session error with status, provider, and response body for debugging. */
32
+ function formatSessionError(error) {
33
+ const name = error?.name || 'Error';
34
+ // Prefer data.message (SDK shape), fall back to error.message (plain Error)
35
+ const message = error?.data?.message ||
36
+ error?.message ||
37
+ 'Unknown error';
38
+ const details = [];
39
+ if (error?.data?.statusCode !== undefined) {
40
+ details.push(`status=${error.data.statusCode}`);
41
+ }
42
+ if (error?.data?.providerID) {
43
+ details.push(`provider=${error.data.providerID}`);
44
+ }
45
+ if (typeof error?.data?.isRetryable === 'boolean') {
46
+ details.push(error.data.isRetryable ? 'retryable' : 'non-retryable');
47
+ }
48
+ const responseBody = typeof error?.data?.responseBody === 'string'
49
+ ? error.data.responseBody.trim()
50
+ : '';
51
+ if (responseBody) {
52
+ details.push(`body=${responseBody.slice(0, 180)}`);
53
+ }
54
+ const suffix = details.length > 0 ? ` (${details.join(', ')})` : '';
55
+ return `${name}: ${message}${suffix}`;
56
+ }
57
+ export function signalThreadInterrupt({ threadId, serverDirectory, sdkDirectory, }) {
58
+ void (async () => {
59
+ const sessionId = await getThreadSession(threadId);
60
+ if (!sessionId) {
61
+ return;
62
+ }
63
+ const controller = abortControllers.get(sessionId);
64
+ if (!controller || controller.signal.aborted) {
65
+ return;
66
+ }
67
+ sessionLogger.log(`[ABORT] reason=queued-message sessionId=${sessionId} threadId=${threadId} - new message queued, aborting running session immediately`);
68
+ controller.abort(new SessionAbortError({ reason: 'new-request' }));
69
+ if (!serverDirectory || !sdkDirectory) {
70
+ return;
71
+ }
72
+ const client = getOpencodeClient(serverDirectory);
73
+ if (!client) {
74
+ sessionLogger.log(`[ABORT-API] reason=queued-message sessionId=${sessionId} - no OpenCode client found for directory ${serverDirectory}`);
75
+ return;
76
+ }
77
+ const abortResult = await errore.tryAsync(() => {
78
+ return client.session.abort({
79
+ sessionID: sessionId,
80
+ directory: sdkDirectory,
81
+ });
82
+ });
83
+ if (abortResult instanceof Error) {
84
+ sessionLogger.log(`[ABORT-API] reason=queued-message sessionId=${sessionId} - API abort failed (may already be done):`, abortResult);
85
+ }
86
+ })();
87
+ }
88
+ // Built-in tools that are hidden in text-and-essential-tools verbosity mode.
89
+ // Essential tools (edits, bash with side effects, todos, tasks, custom MCP tools) are shown; these navigation/read tools are hidden.
90
+ const NON_ESSENTIAL_TOOLS = new Set([
91
+ 'read',
92
+ 'list',
93
+ 'glob',
94
+ 'grep',
95
+ 'todoread',
96
+ 'question',
97
+ 'kimaki_action_buttons',
98
+ 'webfetch',
99
+ ]);
100
+ function isEssentialToolName(toolName) {
101
+ return !NON_ESSENTIAL_TOOLS.has(toolName);
102
+ }
103
+ function isEssentialToolPart(part) {
104
+ if (part.type !== 'tool') {
105
+ return false;
106
+ }
107
+ if (!isEssentialToolName(part.tool)) {
108
+ return false;
109
+ }
110
+ if (part.tool === 'bash') {
111
+ const hasSideEffect = part.state.input?.hasSideEffect;
112
+ return hasSideEffect !== false;
113
+ }
114
+ return true;
115
+ }
116
+ // Track multiple pending permissions per thread (keyed by permission ID)
117
+ // OpenCode handles blocking/sequencing - we just need to track all pending permissions
118
+ // to avoid duplicates and properly clean up on auto-reject
119
+ export const pendingPermissions = new Map();
120
+ async function removeBotErrorReaction({ message, }) {
121
+ const botUserId = message.client.user?.id;
122
+ if (!botUserId) {
123
+ return;
124
+ }
125
+ const errorReaction = message.reactions.cache.find((reaction) => {
126
+ return reaction.emoji.name === '❌';
127
+ });
128
+ if (!errorReaction) {
129
+ return;
130
+ }
131
+ await errorReaction.users.remove(botUserId);
132
+ }
133
+ function buildPermissionDedupeKey({ permission, directory, }) {
134
+ const normalizedPatterns = [...permission.patterns].sort((a, b) => {
135
+ return a.localeCompare(b);
136
+ });
137
+ return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`;
138
+ }
139
+ // Queue of messages waiting to be sent after current response finishes
140
+ // Key is threadId, value is array of queued messages
141
+ export const messageQueue = new Map();
142
+ const activeEventHandlers = new Map();
143
+ export function addToQueue({ threadId, message, }) {
144
+ const queue = messageQueue.get(threadId) || [];
145
+ queue.push(message);
146
+ messageQueue.set(threadId, queue);
147
+ return queue.length;
148
+ }
149
+ export function getQueueLength(threadId) {
150
+ return messageQueue.get(threadId)?.length || 0;
151
+ }
152
+ export function clearQueue(threadId) {
153
+ messageQueue.delete(threadId);
154
+ }
155
+ /**
156
+ * Queue a message if there's an active in-progress request, otherwise send immediately.
157
+ * Abstracts the "check active request → send or queue" pattern used by /queue command
158
+ * and voice transcription queue detection.
159
+ *
160
+ * Checks active request BEFORE resolving directory so that queueing works even if
161
+ * directory resolution would fail — the queued message only needs a directory later
162
+ * when it's actually sent (the drain logic in handleOpencodeSession already has it).
163
+ *
164
+ * If there is no existing session or no project directory (on immediate-send path),
165
+ * returns an error-like result so the caller can handle it.
166
+ */
167
+ export async function queueOrSendMessage({ thread, prompt, userId, username, appId, images, forceQueue, }) {
168
+ const sessionId = await getThreadSession(thread.id);
169
+ if (!sessionId) {
170
+ return { action: 'no-session' };
171
+ }
172
+ // Check active request FIRST — queueing doesn't need directory resolution
173
+ const existingController = abortControllers.get(sessionId);
174
+ const hasActiveRequest = Boolean(existingController && !existingController.signal.aborted);
175
+ if (existingController && existingController.signal.aborted) {
176
+ abortControllers.delete(sessionId);
177
+ }
178
+ if (hasActiveRequest || forceQueue) {
179
+ // Active request — add to queue (no directory needed, drain logic has it)
180
+ const position = addToQueue({
181
+ threadId: thread.id,
182
+ message: {
183
+ prompt,
184
+ userId,
185
+ username,
186
+ queuedAt: Date.now(),
187
+ images,
188
+ appId,
189
+ },
190
+ });
191
+ sessionLogger.log(`[QUEUE] User ${username} queued message in thread ${thread.id} (position: ${position})`);
192
+ return { action: 'queued', position };
193
+ }
194
+ // No active request — send immediately (need directory for this path)
195
+ const resolved = await resolveWorkingDirectory({ channel: thread });
196
+ if (!resolved) {
197
+ return { action: 'no-directory' };
198
+ }
199
+ sessionLogger.log(`[QUEUE] No active request, sending immediately in thread ${thread.id}`);
200
+ handleOpencodeSession({
201
+ prompt,
202
+ thread,
203
+ projectDirectory: resolved.projectDirectory,
204
+ channelId: thread.parentId || thread.id,
205
+ images,
206
+ username,
207
+ userId,
208
+ appId,
209
+ }).catch(async (e) => {
210
+ sessionLogger.error(`[QUEUE] Failed to send message:`, e);
211
+ void notifyError(e, 'Queue: failed to send message');
212
+ const errorMsg = e instanceof Error ? e.message : String(e);
213
+ await sendThreadMessage(thread, `✗ Failed: ${errorMsg.slice(0, 200)}`);
214
+ });
215
+ return { action: 'sent' };
216
+ }
217
+ /**
218
+ * Read user's recent models from OpenCode TUI's state file.
219
+ * Uses same path as OpenCode: path.join(xdgState, "opencode", "model.json")
220
+ * Returns all recent models so we can iterate until finding a valid one.
221
+ * See: opensrc/repos/github.com/sst/opencode/packages/opencode/src/global/index.ts
222
+ */
223
+ function getRecentModelsFromTuiState() {
224
+ if (!xdgState) {
225
+ return [];
226
+ }
227
+ // Same path as OpenCode TUI: path.join(Global.Path.state, "model.json")
228
+ const modelJsonPath = path.join(xdgState, 'opencode', 'model.json');
229
+ const result = errore.tryFn(() => {
230
+ const content = fs.readFileSync(modelJsonPath, 'utf-8');
231
+ const data = JSON.parse(content);
232
+ return data.recent ?? [];
233
+ });
234
+ if (result instanceof Error) {
235
+ // File doesn't exist or is invalid - this is normal for fresh installs
236
+ return [];
237
+ }
238
+ return result;
239
+ }
240
+ /**
241
+ * Parse a model string in format "provider/model" into providerID and modelID.
242
+ */
243
+ function parseModelString(model) {
244
+ const [providerID, ...modelParts] = model.split('/');
245
+ const modelID = modelParts.join('/');
246
+ if (!providerID || !modelID) {
247
+ return undefined;
248
+ }
249
+ return { providerID, modelID };
250
+ }
251
+ /**
252
+ * Validate that a model is available (provider connected + model exists).
253
+ */
254
+ function isModelValid(model, connected, providers) {
255
+ const isConnected = connected.includes(model.providerID);
256
+ const provider = providers.find((p) => p.id === model.providerID);
257
+ const modelExists = provider?.models && model.modelID in provider.models;
258
+ return isConnected && !!modelExists;
259
+ }
260
+ async function resolveValidatedAgentPreference({ agent, sessionId, channelId, getClient, }) {
261
+ const agentPreference = await (async () => {
262
+ if (agent) {
263
+ return agent;
264
+ }
265
+ const sessionAgent = await getSessionAgent(sessionId);
266
+ if (sessionAgent) {
267
+ return sessionAgent;
268
+ }
269
+ const sessionModel = await getSessionModel(sessionId);
270
+ if (sessionModel) {
271
+ return undefined;
272
+ }
273
+ if (!channelId) {
274
+ return undefined;
275
+ }
276
+ return getChannelAgent(channelId);
277
+ })();
278
+ if (getClient instanceof Error) {
279
+ return { agentPreference: agentPreference || undefined, agents: [] };
280
+ }
281
+ const agentsResponse = await errore.tryAsync(() => {
282
+ return getClient().app.agents({});
283
+ });
284
+ if (agentsResponse instanceof Error) {
285
+ if (agentPreference) {
286
+ throw new Error(`Failed to validate agent "${agentPreference}"`, {
287
+ cause: agentsResponse,
288
+ });
289
+ }
290
+ return { agentPreference: undefined, agents: [] };
291
+ }
292
+ const availableAgents = agentsResponse.data || [];
293
+ // Non-hidden primary/all agents for system message context
294
+ const agents = availableAgents
295
+ .filter((a) => {
296
+ return ((a.mode === 'primary' || a.mode === 'all') &&
297
+ !a.hidden);
298
+ })
299
+ .map((a) => {
300
+ return { name: a.name, description: a.description };
301
+ });
302
+ if (!agentPreference) {
303
+ return { agentPreference: undefined, agents };
304
+ }
305
+ const hasAgent = availableAgents.some((availableAgent) => {
306
+ return availableAgent.name === agentPreference;
307
+ });
308
+ if (hasAgent) {
309
+ return { agentPreference, agents };
310
+ }
311
+ const availableAgentNames = availableAgents
312
+ .map((availableAgent) => {
313
+ return availableAgent.name;
314
+ })
315
+ .slice(0, 20);
316
+ const availableAgentsMessage = availableAgentNames.length > 0
317
+ ? `Available agents: ${availableAgentNames.join(', ')}`
318
+ : 'No agents are available in this project.';
319
+ throw new Error(`Agent "${agentPreference}" not found. ${availableAgentsMessage} Use /agent to choose a valid one.`);
320
+ }
321
+ /**
322
+ * Get the default model from OpenCode when no user preference is set.
323
+ * Priority (matches OpenCode TUI behavior):
324
+ * 1. OpenCode config.model setting
325
+ * 2. User's recent models from TUI state (~/.local/state/opencode/model.json)
326
+ * 3. First connected provider's default model from API
327
+ * Returns the model and its source.
328
+ */
329
+ export async function getDefaultModel({ getClient, }) {
330
+ if (getClient instanceof Error) {
331
+ return undefined;
332
+ }
333
+ // Fetch connected providers to validate any model we return
334
+ const providersResponse = await errore.tryAsync(() => {
335
+ return getClient().provider.list({});
336
+ });
337
+ if (providersResponse instanceof Error) {
338
+ sessionLogger.log(`[MODEL] Failed to fetch providers for default model:`, providersResponse.message);
339
+ return undefined;
340
+ }
341
+ if (!providersResponse.data) {
342
+ return undefined;
343
+ }
344
+ const { connected, default: defaults, all: providers, } = providersResponse.data;
345
+ if (connected.length === 0) {
346
+ sessionLogger.log(`[MODEL] No connected providers found`);
347
+ return undefined;
348
+ }
349
+ // 1. Check OpenCode config.model setting (highest priority after user preference)
350
+ const configResponse = await errore.tryAsync(() => {
351
+ return getClient().config.get({});
352
+ });
353
+ if (!(configResponse instanceof Error) && configResponse.data?.model) {
354
+ const configModel = parseModelString(configResponse.data.model);
355
+ if (configModel && isModelValid(configModel, connected, providers)) {
356
+ sessionLogger.log(`[MODEL] Using config model: ${configModel.providerID}/${configModel.modelID}`);
357
+ return { ...configModel, source: 'opencode-config' };
358
+ }
359
+ if (configModel) {
360
+ sessionLogger.log(`[MODEL] Config model ${configResponse.data.model} not available, checking recent`);
361
+ }
362
+ }
363
+ // 2. Try to use user's recent models from TUI state (iterate until finding valid one)
364
+ const recentModels = getRecentModelsFromTuiState();
365
+ for (const recentModel of recentModels) {
366
+ if (isModelValid(recentModel, connected, providers)) {
367
+ sessionLogger.log(`[MODEL] Using recent TUI model: ${recentModel.providerID}/${recentModel.modelID}`);
368
+ return { ...recentModel, source: 'opencode-recent' };
369
+ }
370
+ }
371
+ if (recentModels.length > 0) {
372
+ sessionLogger.log(`[MODEL] No valid recent TUI models found`);
373
+ }
374
+ // 3. Fall back to first connected provider's default model
375
+ const firstConnected = connected[0];
376
+ if (!firstConnected) {
377
+ return undefined;
378
+ }
379
+ const defaultModelId = defaults[firstConnected];
380
+ if (!defaultModelId) {
381
+ sessionLogger.log(`[MODEL] No default model for provider ${firstConnected}`);
382
+ return undefined;
383
+ }
384
+ sessionLogger.log(`[MODEL] Using provider default: ${firstConnected}/${defaultModelId}`);
385
+ return {
386
+ providerID: firstConnected,
387
+ modelID: defaultModelId,
388
+ source: 'opencode-provider-default',
389
+ };
390
+ }
391
+ /**
392
+ * Abort a running session and retry with the last user message.
393
+ * Used when model preference changes mid-request.
394
+ * Fetches last user message from OpenCode API instead of tracking in memory.
395
+ * @returns true if aborted and retry scheduled, false if no active request
396
+ */
397
+ export async function abortAndRetrySession({ sessionId, thread, projectDirectory, appId, channelId, }) {
398
+ const controller = abortControllers.get(sessionId);
399
+ if (!controller) {
400
+ sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`);
401
+ return false;
402
+ }
403
+ sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
404
+ // Abort with special reason so we don't show "completed" message
405
+ sessionLogger.log(`[ABORT] reason=model-change sessionId=${sessionId} - user changed model mid-request, will retry with new model`);
406
+ controller.abort(new SessionAbortError({ reason: 'model-change' }));
407
+ // Also call the API abort endpoint
408
+ const getClient = await initializeOpencodeForDirectory(projectDirectory, {
409
+ channelId,
410
+ });
411
+ if (getClient instanceof Error) {
412
+ sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message);
413
+ return false;
414
+ }
415
+ sessionLogger.log(`[ABORT-API] reason=model-change sessionId=${sessionId} - sending API abort for model change retry`);
416
+ const abortResult = await errore.tryAsync(() => {
417
+ return getClient().session.abort({ sessionID: sessionId });
418
+ });
419
+ if (abortResult instanceof Error) {
420
+ sessionLogger.log(`[ABORT-API] API abort call failed (may already be done):`, abortResult);
421
+ }
422
+ // Small delay to let the abort propagate
423
+ await new Promise((resolve) => {
424
+ setTimeout(resolve, 300);
425
+ });
426
+ // Fetch last user message from API
427
+ sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`);
428
+ const messagesResponse = await getClient().session.messages({
429
+ sessionID: sessionId,
430
+ });
431
+ const messages = messagesResponse.data || [];
432
+ const lastUserMessage = [...messages]
433
+ .reverse()
434
+ .find((m) => m.info.role === 'user');
435
+ if (!lastUserMessage) {
436
+ sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`);
437
+ return false;
438
+ }
439
+ // Extract text and images from parts (skip synthetic parts like branch context)
440
+ const textPart = lastUserMessage.parts.find((p) => p.type === 'text' && !p.synthetic);
441
+ const prompt = textPart?.text || '';
442
+ const images = lastUserMessage.parts.filter((p) => p.type === 'file');
443
+ sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
444
+ // Use setImmediate to avoid blocking
445
+ setImmediate(() => {
446
+ void errore
447
+ .tryAsync(async () => {
448
+ return handleOpencodeSession({
449
+ prompt,
450
+ thread,
451
+ projectDirectory,
452
+ images,
453
+ appId,
454
+ channelId,
455
+ });
456
+ })
457
+ .then(async (result) => {
458
+ if (!(result instanceof Error)) {
459
+ return;
460
+ }
461
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result);
462
+ void notifyError(result, 'Abort+retry session failed');
463
+ await sendThreadMessage(thread, `✗ Failed to retry with new model: ${result.message.slice(0, 200)}`);
464
+ });
465
+ });
466
+ return true;
467
+ }
468
+ export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, agent, model, username, userId, appId, sessionStartSource, }) {
469
+ voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
470
+ const sessionStartTime = Date.now();
471
+ const directory = projectDirectory || process.cwd();
472
+ sessionLogger.log(`Using directory: ${directory}`);
473
+ // Fire DB lookups in parallel - they're independent and we need both before proceeding.
474
+ // initializeOpencodeForDirectory is NOT included here because it needs worktree info
475
+ // to set originalRepoDirectory permissions on the spawned server (reuse check means
476
+ // a second call with different options won't fix a server already spawned without them).
477
+ const [worktreeInfo, existingSessionId] = await Promise.all([
478
+ getThreadWorktree(thread.id),
479
+ getThreadSession(thread.id),
480
+ ]);
481
+ const worktreeDirectory = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
482
+ ? worktreeInfo.worktree_directory
483
+ : undefined;
484
+ const sdkDirectory = worktreeDirectory || directory;
485
+ if (worktreeDirectory) {
486
+ sessionLogger.log(`Using worktree directory for SDK calls: ${worktreeDirectory}`);
487
+ }
488
+ const originalRepoDirectory = worktreeDirectory
489
+ ? worktreeInfo?.project_directory
490
+ : undefined;
491
+ const getClient = await initializeOpencodeForDirectory(directory, {
492
+ originalRepoDirectory,
493
+ channelId,
494
+ });
495
+ if (getClient instanceof Error) {
496
+ await sendThreadMessage(thread, `✗ ${getClient.message}`);
497
+ return;
498
+ }
499
+ const serverEntry = getOpencodeServers().get(directory);
500
+ const port = serverEntry?.port;
501
+ let sessionId = existingSessionId;
502
+ let session;
503
+ let createdNewSession = false;
504
+ if (sessionId) {
505
+ sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
506
+ const sessionResponse = await errore.tryAsync(() => {
507
+ return getClient().session.get({
508
+ sessionID: sessionId,
509
+ directory: sdkDirectory,
510
+ });
511
+ });
512
+ if (sessionResponse instanceof Error) {
513
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`);
514
+ }
515
+ else {
516
+ session = sessionResponse.data;
517
+ sessionLogger.log(`Successfully reused session ${sessionId}`);
518
+ }
519
+ }
520
+ if (!session) {
521
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
522
+ voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
523
+ const sessionResponse = await getClient().session.create({
524
+ title: sessionTitle,
525
+ directory: sdkDirectory,
526
+ });
527
+ session = sessionResponse.data;
528
+ createdNewSession = true;
529
+ sessionLogger.log(`Created new session ${session?.id}`);
530
+ }
531
+ if (!session) {
532
+ throw new Error('Failed to create or get session');
533
+ }
534
+ await setThreadSession(thread.id, session.id);
535
+ sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`);
536
+ const channelInfo = channelId
537
+ ? await getChannelDirectory(channelId)
538
+ : undefined;
539
+ const resolvedAppId = channelInfo?.appId ?? appId;
540
+ if (createdNewSession && sessionStartSource) {
541
+ const saveStartSourceResult = await errore.tryAsync(() => {
542
+ return setSessionStartSource({
543
+ sessionId: session.id,
544
+ scheduleKind: sessionStartSource.scheduleKind,
545
+ scheduledTaskId: sessionStartSource.scheduledTaskId,
546
+ });
547
+ });
548
+ if (saveStartSourceResult instanceof Error) {
549
+ sessionLogger.warn(`[SESSION] Failed to store start source for session ${session.id}: ${saveStartSourceResult.message}`);
550
+ }
551
+ }
552
+ // Store agent preference if provided
553
+ if (agent) {
554
+ await setSessionAgent(session.id, agent);
555
+ sessionLogger.log(`Set agent preference for session ${session.id}: ${agent}`);
556
+ }
557
+ await ensureSessionPreferencesSnapshot({
558
+ sessionId: session.id,
559
+ channelId,
560
+ appId: resolvedAppId,
561
+ getClient,
562
+ agentOverride: agent,
563
+ modelOverride: model,
564
+ force: createdNewSession,
565
+ });
566
+ const existingController = abortControllers.get(session.id);
567
+ if (existingController) {
568
+ voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
569
+ sessionLogger.log(`[ABORT] reason=new-request sessionId=${session.id} threadId=${thread.id} - new user message arrived while previous request was still running`);
570
+ existingController.abort(new SessionAbortError({ reason: 'new-request' }));
571
+ sessionLogger.log(`[ABORT-API] reason=new-request sessionId=${session.id} - sending API abort because new message arrived`);
572
+ const abortResult = await errore.tryAsync(() => {
573
+ return getClient().session.abort({
574
+ sessionID: session.id,
575
+ directory: sdkDirectory,
576
+ });
577
+ });
578
+ if (abortResult instanceof Error) {
579
+ sessionLogger.log(`[ABORT-API] Server abort failed (may be already done):`, abortResult);
580
+ }
581
+ }
582
+ // Auto-reject ALL pending permissions for this thread
583
+ const threadPermissions = pendingPermissions.get(thread.id);
584
+ if (threadPermissions && threadPermissions.size > 0) {
585
+ const permClient = getOpencodeClient(directory);
586
+ for (const [permId, pendingPerm] of threadPermissions) {
587
+ sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`);
588
+ // Remove the permission buttons from the Discord message
589
+ const removeButtonsResult = await errore.tryAsync(async () => {
590
+ const msg = await thread.messages.fetch(pendingPerm.messageId);
591
+ await msg.edit({ components: [] });
592
+ });
593
+ if (removeButtonsResult instanceof Error) {
594
+ sessionLogger.log(`[PERMISSION] Failed to remove buttons for ${permId}:`, removeButtonsResult);
595
+ }
596
+ if (!permClient) {
597
+ sessionLogger.log(`[PERMISSION] OpenCode client unavailable for permission ${permId}`);
598
+ cleanupPermissionContext(pendingPerm.contextHash);
599
+ continue;
600
+ }
601
+ const rejectResult = await errore.tryAsync(() => {
602
+ return permClient.permission.reply({
603
+ requestID: permId,
604
+ directory: pendingPerm.permissionDirectory,
605
+ reply: 'reject',
606
+ });
607
+ });
608
+ if (rejectResult instanceof Error) {
609
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult);
610
+ }
611
+ cleanupPermissionContext(pendingPerm.contextHash);
612
+ }
613
+ pendingPermissions.delete(thread.id);
614
+ }
615
+ // Answer any pending question tool with the user's message (silently, no thread message)
616
+ const questionAnswered = await cancelPendingQuestion(thread.id, prompt);
617
+ if (questionAnswered) {
618
+ sessionLogger.log(`[QUESTION] Answered pending question with user message`);
619
+ }
620
+ // Cancel any pending file upload (resolves with empty array so plugin tool unblocks)
621
+ const fileUploadCancelled = await cancelPendingFileUpload(thread.id);
622
+ if (fileUploadCancelled) {
623
+ sessionLogger.log(`[FILE-UPLOAD] Cancelled pending file upload due to new message`);
624
+ }
625
+ // Dismiss any pending action buttons (user sent a new message instead of clicking)
626
+ const actionButtonsDismissed = cancelPendingActionButtons(thread.id);
627
+ if (actionButtonsDismissed) {
628
+ sessionLogger.log(`[ACTION] Dismissed pending action buttons due to new message`);
629
+ }
630
+ // Snapshot model+agent early so user changes (e.g. /agent) during the async gap
631
+ // (debounce, previous handler wait, event subscribe) don't affect this request.
632
+ const earlyAgentResult = await errore.tryAsync(() => {
633
+ return resolveValidatedAgentPreference({
634
+ agent,
635
+ sessionId: session.id,
636
+ channelId,
637
+ getClient,
638
+ });
639
+ });
640
+ if (earlyAgentResult instanceof Error) {
641
+ await sendThreadMessage(thread, `Failed to resolve agent: ${earlyAgentResult.message}`);
642
+ return;
643
+ }
644
+ const earlyAgentPreference = earlyAgentResult.agentPreference;
645
+ const earlyAvailableAgents = earlyAgentResult.agents;
646
+ if (earlyAgentPreference) {
647
+ sessionLogger.log(`[AGENT] Resolved agent preference early: ${earlyAgentPreference}`);
648
+ }
649
+ // Model resolution and variant cascade are independent - run in parallel.
650
+ // Variant cascade only needs resolvedAppId (available now). Variant validation
651
+ // against the model happens after both complete.
652
+ const [earlyModelResult, preferredVariant] = await Promise.all([
653
+ errore.tryAsync(async () => {
654
+ if (model) {
655
+ const [providerID, ...modelParts] = model.split('/');
656
+ const modelID = modelParts.join('/');
657
+ if (providerID && modelID) {
658
+ sessionLogger.log(`[MODEL] Using explicit model (early): ${model}`);
659
+ return { providerID, modelID };
660
+ }
661
+ }
662
+ const modelInfo = await getCurrentModelInfo({
663
+ sessionId: session.id,
664
+ channelId,
665
+ appId: resolvedAppId,
666
+ agentPreference: earlyAgentPreference,
667
+ getClient,
668
+ });
669
+ if (modelInfo.type === 'none') {
670
+ sessionLogger.log(`[MODEL] No model available (early resolution)`);
671
+ return undefined;
672
+ }
673
+ sessionLogger.log(`[MODEL] Resolved ${modelInfo.type} early: ${modelInfo.model}`);
674
+ return { providerID: modelInfo.providerID, modelID: modelInfo.modelID };
675
+ }),
676
+ getVariantCascade({
677
+ sessionId: session.id,
678
+ channelId,
679
+ appId: resolvedAppId,
680
+ }),
681
+ ]);
682
+ if (earlyModelResult instanceof Error) {
683
+ await sendThreadMessage(thread, `Failed to resolve model: ${earlyModelResult.message}`);
684
+ return;
685
+ }
686
+ const earlyModelParam = earlyModelResult;
687
+ if (!earlyModelParam) {
688
+ await sendThreadMessage(thread, 'No AI provider connected. Configure a provider in OpenCode with `/connect` command.');
689
+ return;
690
+ }
691
+ // Validate the preferred variant against the current model's available variants.
692
+ // preferredVariant was already fetched in parallel above.
693
+ const earlyThinkingValue = await (async () => {
694
+ if (!preferredVariant) {
695
+ return undefined;
696
+ }
697
+ const providersResponse = await errore.tryAsync(() => {
698
+ return getClient().provider.list({ directory: sdkDirectory });
699
+ });
700
+ if (providersResponse instanceof Error || !providersResponse.data) {
701
+ return undefined;
702
+ }
703
+ const availableValues = getThinkingValuesForModel({
704
+ providers: providersResponse.data.all,
705
+ providerId: earlyModelParam.providerID,
706
+ modelId: earlyModelParam.modelID,
707
+ });
708
+ if (availableValues.length === 0) {
709
+ sessionLogger.log(`[THINK] Model ${earlyModelParam.providerID}/${earlyModelParam.modelID} has no variants, ignoring preference`);
710
+ return undefined;
711
+ }
712
+ const matched = matchThinkingValue({
713
+ requestedValue: preferredVariant,
714
+ availableValues,
715
+ });
716
+ if (!matched) {
717
+ sessionLogger.log(`[THINK] Preference "${preferredVariant}" invalid for current model, ignoring`);
718
+ return undefined;
719
+ }
720
+ sessionLogger.log(`[THINK] Using variant: ${matched}`);
721
+ return matched;
722
+ })();
723
+ const abortController = new AbortController();
724
+ abortControllers.set(session.id, abortController);
725
+ if (existingController) {
726
+ await new Promise((resolve) => {
727
+ setTimeout(resolve, 200);
728
+ });
729
+ if (abortController.signal.aborted) {
730
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
731
+ return;
732
+ }
733
+ }
734
+ if (abortController.signal.aborted) {
735
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
736
+ return;
737
+ }
738
+ const previousHandler = activeEventHandlers.get(thread.id);
739
+ if (previousHandler) {
740
+ sessionLogger.log(`[EVENT] Waiting for previous handler to finish`);
741
+ const previousHandlerResult = await errore.tryAsync(() => {
742
+ return previousHandler;
743
+ });
744
+ if (previousHandlerResult instanceof Error) {
745
+ sessionLogger.warn(`[EVENT] Previous handler exited with error while waiting: ${previousHandlerResult.message}`);
746
+ }
747
+ }
748
+ const eventClient = getOpencodeClient(directory);
749
+ if (!eventClient) {
750
+ throw new Error(`OpenCode client not found for directory: ${directory}`);
751
+ }
752
+ const eventsResult = await eventClient.event.subscribe({ directory: sdkDirectory }, { signal: abortController.signal });
753
+ if (abortController.signal.aborted) {
754
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
755
+ return;
756
+ }
757
+ const events = eventsResult.stream;
758
+ sessionLogger.log(`Subscribed to OpenCode events`);
759
+ const existingPartIds = await getPartMessageIds(thread.id);
760
+ const sentPartIds = new Set(existingPartIds);
761
+ const partBuffer = new Map();
762
+ let usedModel = earlyModelParam.modelID;
763
+ let usedProviderID = earlyModelParam.providerID;
764
+ let usedAgent;
765
+ let tokensUsedInSession = 0;
766
+ let lastDisplayedContextPercentage = 0;
767
+ let lastRateLimitDisplayTime = 0;
768
+ let modelContextLimit;
769
+ let modelContextLimitKey;
770
+ let assistantMessageId;
771
+ let handlerPromise = null;
772
+ let typingInterval = null;
773
+ let typingRestartTimeout = null;
774
+ let handlerClosed = false;
775
+ let hasSentParts = false;
776
+ const mainRunStore = sessionRunState.createMainRunStore();
777
+ const finishMainSessionFromIdle = () => {
778
+ if (abortController.signal.aborted) {
779
+ return;
780
+ }
781
+ sessionRunState.markFinished({ store: mainRunStore });
782
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, ending stream`);
783
+ sessionLogger.log(`[ABORT] reason=finished sessionId=${session.id} threadId=${thread.id} - session completed normally, received idle event after prompt resolved`);
784
+ abortController.abort(new SessionAbortError({ reason: 'finished' }));
785
+ };
786
+ function clearTypingInterval() {
787
+ if (!typingInterval) {
788
+ return;
789
+ }
790
+ clearInterval(typingInterval);
791
+ typingInterval = null;
792
+ }
793
+ function clearTypingRestartTimeout() {
794
+ if (!typingRestartTimeout) {
795
+ return;
796
+ }
797
+ clearTimeout(typingRestartTimeout);
798
+ typingRestartTimeout = null;
799
+ }
800
+ function stopTyping() {
801
+ clearTypingInterval();
802
+ clearTypingRestartTimeout();
803
+ }
804
+ function startTyping() {
805
+ if (abortController.signal.aborted || handlerClosed) {
806
+ discordLogger.log(`Not starting typing, handler already closing`);
807
+ return;
808
+ }
809
+ clearTypingRestartTimeout();
810
+ clearTypingInterval();
811
+ void errore
812
+ .tryAsync(() => thread.sendTyping())
813
+ .then((result) => {
814
+ if (result instanceof Error) {
815
+ discordLogger.log(`Failed to send initial typing: ${result}`);
816
+ }
817
+ });
818
+ typingInterval = setInterval(() => {
819
+ if (abortController.signal.aborted || handlerClosed) {
820
+ clearTypingInterval();
821
+ return;
822
+ }
823
+ void errore
824
+ .tryAsync(() => thread.sendTyping())
825
+ .then((result) => {
826
+ if (result instanceof Error) {
827
+ discordLogger.log(`Failed to send periodic typing: ${result}`);
828
+ }
829
+ });
830
+ }, 8000);
831
+ }
832
+ function scheduleTypingRestart() {
833
+ clearTypingRestartTimeout();
834
+ if (abortController.signal.aborted || handlerClosed) {
835
+ return;
836
+ }
837
+ typingRestartTimeout = setTimeout(() => {
838
+ typingRestartTimeout = null;
839
+ if (abortController.signal.aborted || handlerClosed) {
840
+ return;
841
+ }
842
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => {
843
+ return ctx.thread.id === thread.id;
844
+ });
845
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
846
+ if (hasPendingQuestion || hasPendingPermission) {
847
+ return;
848
+ }
849
+ startTyping();
850
+ }, 300);
851
+ }
852
+ if (!abortController.signal.aborted) {
853
+ abortController.signal.addEventListener('abort', () => {
854
+ stopTyping();
855
+ }, { once: true });
856
+ }
857
+ // Read verbosity dynamically so mid-session /verbosity changes take effect immediately
858
+ const verbosityChannelId = channelId || thread.parentId || thread.id;
859
+ const getVerbosity = async () => {
860
+ return getChannelVerbosity(verbosityChannelId);
861
+ };
862
+ const sendPartMessage = async (part) => {
863
+ const verbosity = await getVerbosity();
864
+ // In text-only mode, only send text parts (the ⬥ diamond messages)
865
+ if (verbosity === 'text-only' && part.type !== 'text') {
866
+ return;
867
+ }
868
+ // In text-and-essential-tools mode, show text + essential tools (edits, custom MCP tools)
869
+ if (verbosity === 'text-and-essential-tools') {
870
+ if (part.type === 'text') {
871
+ // text is always shown
872
+ }
873
+ else if (part.type === 'tool' && isEssentialToolPart(part)) {
874
+ // essential tools are shown
875
+ }
876
+ else {
877
+ return;
878
+ }
879
+ }
880
+ const content = formatPart(part) + '\n\n';
881
+ if (!content.trim() || content.length === 0) {
882
+ // discordLogger.log(`SKIP: Part ${part.id} has no content`)
883
+ return;
884
+ }
885
+ if (sentPartIds.has(part.id)) {
886
+ return;
887
+ }
888
+ // Mark as sent BEFORE the async send to prevent concurrent flushes
889
+ // (from message.updated, step-finish, finally block) from sending the
890
+ // same part while this await is in-flight. If the send fails we remove
891
+ // the id so a retry can pick it up.
892
+ sentPartIds.add(part.id);
893
+ const sendResult = await errore.tryAsync(() => {
894
+ return sendThreadMessage(thread, content);
895
+ });
896
+ if (sendResult instanceof Error) {
897
+ sentPartIds.delete(part.id);
898
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult);
899
+ return;
900
+ }
901
+ hasSentParts = true;
902
+ await setPartMessage(part.id, sendResult.id, thread.id);
903
+ };
904
+ const eventHandler = async () => {
905
+ // Subtask tracking: child sessionId → { label, assistantMessageId }
906
+ const subtaskSessions = new Map();
907
+ // Counts spawned tasks per agent type: "explore" → 2
908
+ const agentSpawnCounts = {};
909
+ const storePart = (part) => {
910
+ const messageParts = partBuffer.get(part.messageID) || new Map();
911
+ messageParts.set(part.id, part);
912
+ partBuffer.set(part.messageID, messageParts);
913
+ };
914
+ const getBufferedParts = (messageID) => {
915
+ return Array.from(partBuffer.get(messageID)?.values() ?? []);
916
+ };
917
+ const shouldSendPart = ({ part, force, }) => {
918
+ if (part.type === 'step-start' || part.type === 'step-finish') {
919
+ return false;
920
+ }
921
+ if (part.type === 'tool' && part.state.status === 'pending') {
922
+ return false;
923
+ }
924
+ if (!force && part.type === 'text' && !part.time?.end) {
925
+ return false;
926
+ }
927
+ if (!force && part.type === 'tool' && part.state.status === 'completed') {
928
+ return false;
929
+ }
930
+ return true;
931
+ };
932
+ const flushBufferedParts = async ({ messageID, force, skipPartId, }) => {
933
+ if (!messageID) {
934
+ return;
935
+ }
936
+ const parts = getBufferedParts(messageID);
937
+ for (const part of parts) {
938
+ if (skipPartId && part.id === skipPartId) {
939
+ continue;
940
+ }
941
+ if (!shouldSendPart({ part, force })) {
942
+ continue;
943
+ }
944
+ await sendPartMessage(part);
945
+ }
946
+ };
947
+ const showInteractiveUi = async ({ skipPartId, flushMessageId, show, }) => {
948
+ stopTyping();
949
+ const targetMessageId = flushMessageId || assistantMessageId;
950
+ if (targetMessageId) {
951
+ await flushBufferedParts({
952
+ messageID: targetMessageId,
953
+ force: true,
954
+ skipPartId,
955
+ });
956
+ }
957
+ await show();
958
+ };
959
+ const ensureModelContextLimit = async () => {
960
+ if (!usedProviderID || !usedModel) {
961
+ return;
962
+ }
963
+ const key = `${usedProviderID}/${usedModel}`;
964
+ if (modelContextLimit && modelContextLimitKey === key) {
965
+ return;
966
+ }
967
+ const providersResponse = await errore.tryAsync(() => {
968
+ return getClient().provider.list({
969
+ directory: sdkDirectory,
970
+ });
971
+ });
972
+ if (providersResponse instanceof Error) {
973
+ sessionLogger.error('Failed to fetch provider info for context limit:', providersResponse);
974
+ return;
975
+ }
976
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
977
+ const model = provider?.models?.[usedModel];
978
+ if (!model?.limit?.context) {
979
+ return;
980
+ }
981
+ modelContextLimit = model.limit.context;
982
+ modelContextLimitKey = key;
983
+ };
984
+ const handleMessageUpdated = async (msg) => {
985
+ const subtaskInfo = subtaskSessions.get(msg.sessionID);
986
+ if (subtaskInfo && msg.role === 'assistant') {
987
+ subtaskInfo.assistantMessageId = msg.id;
988
+ }
989
+ if (msg.sessionID !== session.id) {
990
+ return;
991
+ }
992
+ if (msg.role !== 'assistant') {
993
+ return;
994
+ }
995
+ sessionRunState.markCurrentPromptEvidence({
996
+ store: mainRunStore,
997
+ messageId: msg.id,
998
+ });
999
+ if (msg.tokens) {
1000
+ const newTokensTotal = msg.tokens.input +
1001
+ msg.tokens.output +
1002
+ msg.tokens.reasoning +
1003
+ msg.tokens.cache.read +
1004
+ msg.tokens.cache.write;
1005
+ if (newTokensTotal > 0) {
1006
+ tokensUsedInSession = newTokensTotal;
1007
+ }
1008
+ }
1009
+ assistantMessageId = msg.id;
1010
+ usedModel = msg.modelID;
1011
+ usedProviderID = msg.providerID;
1012
+ usedAgent = msg.mode;
1013
+ await flushBufferedParts({
1014
+ messageID: assistantMessageId,
1015
+ force: false,
1016
+ });
1017
+ if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
1018
+ return;
1019
+ }
1020
+ await ensureModelContextLimit();
1021
+ if (!modelContextLimit) {
1022
+ return;
1023
+ }
1024
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
1025
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
1026
+ if (thresholdCrossed <= lastDisplayedContextPercentage ||
1027
+ thresholdCrossed < 10) {
1028
+ return;
1029
+ }
1030
+ lastDisplayedContextPercentage = thresholdCrossed;
1031
+ const chunk = `⬦ context usage ${currentPercentage}%`;
1032
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
1033
+ };
1034
+ const handleMainPart = async (part) => {
1035
+ const isActiveMessage = assistantMessageId
1036
+ ? part.messageID === assistantMessageId
1037
+ : false;
1038
+ const allowEarlyProcessing = !assistantMessageId &&
1039
+ part.type === 'tool' &&
1040
+ part.state.status === 'running';
1041
+ if (!isActiveMessage && !allowEarlyProcessing) {
1042
+ if (part.type !== 'step-start') {
1043
+ return;
1044
+ }
1045
+ }
1046
+ if (part.type === 'step-start') {
1047
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
1048
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
1049
+ if (!hasPendingQuestion && !hasPendingPermission) {
1050
+ startTyping();
1051
+ }
1052
+ return;
1053
+ }
1054
+ if (part.type === 'tool' && part.state.status === 'running') {
1055
+ await flushBufferedParts({
1056
+ messageID: assistantMessageId || part.messageID,
1057
+ force: true,
1058
+ skipPartId: part.id,
1059
+ });
1060
+ await sendPartMessage(part);
1061
+ if (part.tool === 'task' && !sentPartIds.has(part.id)) {
1062
+ const description = part.state.input?.description || '';
1063
+ const agent = part.state.input?.subagent_type || 'task';
1064
+ const childSessionId = part.state.metadata?.sessionId || '';
1065
+ if (description && childSessionId) {
1066
+ agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1;
1067
+ const label = `${agent}-${agentSpawnCounts[agent]}`;
1068
+ subtaskSessions.set(childSessionId, {
1069
+ label,
1070
+ assistantMessageId: undefined,
1071
+ });
1072
+ // Show task messages in tools-and-text and text-and-essential-tools modes
1073
+ if ((await getVerbosity()) !== 'text-only') {
1074
+ const taskDisplay = `┣ task **${description}**${agent ? ` _${agent}_` : ''}`;
1075
+ await sendThreadMessage(thread, taskDisplay + '\n\n');
1076
+ }
1077
+ sentPartIds.add(part.id);
1078
+ }
1079
+ }
1080
+ return;
1081
+ }
1082
+ // Show large output notifications for tools that are visible in current verbosity mode
1083
+ if (part.type === 'tool' && part.state.status === 'completed') {
1084
+ if (part.tool.endsWith('kimaki_action_buttons')) {
1085
+ await showInteractiveUi({
1086
+ skipPartId: part.id,
1087
+ flushMessageId: assistantMessageId || part.messageID,
1088
+ show: async () => {
1089
+ const request = await waitForQueuedActionButtonsRequest({
1090
+ sessionId: session.id,
1091
+ timeoutMs: 1500,
1092
+ });
1093
+ if (!request) {
1094
+ sessionLogger.warn(`[ACTION] No queued action-buttons request found for session ${session.id}`);
1095
+ return;
1096
+ }
1097
+ if (request.threadId !== thread.id) {
1098
+ sessionLogger.warn(`[ACTION] Ignoring queued action-buttons for different thread (expected: ${thread.id}, got: ${request.threadId})`);
1099
+ return;
1100
+ }
1101
+ const showButtonsResult = await errore.tryAsync(() => {
1102
+ return showActionButtons({
1103
+ thread,
1104
+ sessionId: request.sessionId,
1105
+ directory: request.directory,
1106
+ buttons: request.buttons,
1107
+ });
1108
+ });
1109
+ if (!(showButtonsResult instanceof Error)) {
1110
+ return;
1111
+ }
1112
+ sessionLogger.error('[ACTION] Failed to show action buttons:', showButtonsResult);
1113
+ await sendThreadMessage(thread, `Failed to show action buttons: ${showButtonsResult.message}`);
1114
+ },
1115
+ });
1116
+ return;
1117
+ }
1118
+ const showLargeOutput = await (async () => {
1119
+ const verbosity = await getVerbosity();
1120
+ if (verbosity === 'text-only') {
1121
+ return false;
1122
+ }
1123
+ if (verbosity === 'text-and-essential-tools') {
1124
+ return isEssentialToolPart(part);
1125
+ }
1126
+ return true;
1127
+ })();
1128
+ if (showLargeOutput) {
1129
+ const output = part.state.output || '';
1130
+ const outputTokens = Math.ceil(output.length / 4);
1131
+ const largeOutputThreshold = 3000;
1132
+ if (outputTokens >= largeOutputThreshold) {
1133
+ await ensureModelContextLimit();
1134
+ const formattedTokens = outputTokens >= 1000
1135
+ ? `${(outputTokens / 1000).toFixed(1)}k`
1136
+ : String(outputTokens);
1137
+ const percentageSuffix = (() => {
1138
+ if (!modelContextLimit) {
1139
+ return '';
1140
+ }
1141
+ const pct = (outputTokens / modelContextLimit) * 100;
1142
+ if (pct < 1) {
1143
+ return '';
1144
+ }
1145
+ return ` (${pct.toFixed(1)}%)`;
1146
+ })();
1147
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
1148
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
1149
+ }
1150
+ }
1151
+ }
1152
+ if (part.type === 'reasoning') {
1153
+ await sendPartMessage(part);
1154
+ return;
1155
+ }
1156
+ if (part.type === 'text' && part.time?.end) {
1157
+ await sendPartMessage(part);
1158
+ return;
1159
+ }
1160
+ if (part.type === 'step-finish') {
1161
+ await flushBufferedParts({
1162
+ messageID: assistantMessageId || part.messageID,
1163
+ force: true,
1164
+ });
1165
+ scheduleTypingRestart();
1166
+ }
1167
+ };
1168
+ const handleSubtaskPart = async (part, subtaskInfo) => {
1169
+ const verbosity = await getVerbosity();
1170
+ // In text-only mode, skip all subtask output (they're tool-related)
1171
+ if (verbosity === 'text-only') {
1172
+ return;
1173
+ }
1174
+ // In text-and-essential-tools mode, only show essential tools from subtasks
1175
+ if (verbosity === 'text-and-essential-tools') {
1176
+ if (!isEssentialToolPart(part)) {
1177
+ return;
1178
+ }
1179
+ }
1180
+ if (part.type === 'step-start' || part.type === 'step-finish') {
1181
+ return;
1182
+ }
1183
+ if (part.type === 'tool' && part.state.status === 'pending') {
1184
+ return;
1185
+ }
1186
+ if (part.type === 'text') {
1187
+ return;
1188
+ }
1189
+ if (!subtaskInfo.assistantMessageId ||
1190
+ part.messageID !== subtaskInfo.assistantMessageId) {
1191
+ return;
1192
+ }
1193
+ const content = formatPart(part, subtaskInfo.label);
1194
+ if (!content.trim() || sentPartIds.has(part.id)) {
1195
+ return;
1196
+ }
1197
+ const sendResult = await errore.tryAsync(() => {
1198
+ return sendThreadMessage(thread, content + '\n\n');
1199
+ });
1200
+ if (sendResult instanceof Error) {
1201
+ discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult);
1202
+ return;
1203
+ }
1204
+ sentPartIds.add(part.id);
1205
+ await setPartMessage(part.id, sendResult.id, thread.id);
1206
+ };
1207
+ const handlePartUpdated = async (part) => {
1208
+ storePart(part);
1209
+ const subtaskInfo = subtaskSessions.get(part.sessionID);
1210
+ const isSubtaskEvent = Boolean(subtaskInfo);
1211
+ if (part.sessionID !== session.id && !isSubtaskEvent) {
1212
+ return;
1213
+ }
1214
+ if (part.sessionID === session.id) {
1215
+ sessionRunState.markCurrentPromptEvidence({
1216
+ store: mainRunStore,
1217
+ messageId: part.messageID,
1218
+ });
1219
+ }
1220
+ if (isSubtaskEvent && subtaskInfo) {
1221
+ await handleSubtaskPart(part, subtaskInfo);
1222
+ return;
1223
+ }
1224
+ await handleMainPart(part);
1225
+ };
1226
+ const handleSessionError = async ({ sessionID, error, }) => {
1227
+ if (!sessionID || sessionID !== session.id) {
1228
+ sessionLogger.log(`Ignoring error for different session (expected: ${session.id}, got: ${sessionID})`);
1229
+ return;
1230
+ }
1231
+ // Skip abort errors from the server — these are expected when operations
1232
+ // are cancelled. Checks server error name and the local abort signal.
1233
+ if (error?.name === 'MessageAbortedError' ||
1234
+ abortController.signal.aborted) {
1235
+ sessionLogger.log(`Operation aborted (expected)`);
1236
+ return;
1237
+ }
1238
+ const errorMessage = formatSessionError(error);
1239
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`);
1240
+ const errorPayload = (() => {
1241
+ try {
1242
+ return JSON.stringify(error);
1243
+ }
1244
+ catch {
1245
+ return '[unserializable error payload]';
1246
+ }
1247
+ })();
1248
+ sessionLogger.error(`Session error payload:`, errorPayload);
1249
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`);
1250
+ if (!originalMessage) {
1251
+ return;
1252
+ }
1253
+ const reactionResult = await errore.tryAsync(async () => {
1254
+ await originalMessage.react('❌');
1255
+ });
1256
+ if (reactionResult instanceof Error) {
1257
+ discordLogger.log(`Could not update reaction:`, reactionResult);
1258
+ }
1259
+ else {
1260
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`);
1261
+ }
1262
+ };
1263
+ const handlePermissionAsked = async (permission) => {
1264
+ const isMainSession = permission.sessionID === session.id;
1265
+ const isSubtaskSession = subtaskSessions.has(permission.sessionID);
1266
+ if (!isMainSession && !isSubtaskSession) {
1267
+ voiceLogger.log(`[PERMISSION IGNORED] Permission for unknown session (expected: ${session.id} or subtask, got: ${permission.sessionID})`);
1268
+ return;
1269
+ }
1270
+ const subtaskLabel = isSubtaskSession
1271
+ ? subtaskSessions.get(permission.sessionID)?.label
1272
+ : undefined;
1273
+ const dedupeKey = buildPermissionDedupeKey({ permission, directory });
1274
+ const threadPermissions = pendingPermissions.get(thread.id);
1275
+ const existingPending = threadPermissions
1276
+ ? Array.from(threadPermissions.values()).find((pending) => {
1277
+ if (pending.dedupeKey === dedupeKey) {
1278
+ return true;
1279
+ }
1280
+ if (pending.directory !== directory) {
1281
+ return false;
1282
+ }
1283
+ if (pending.permission.permission !== permission.permission) {
1284
+ return false;
1285
+ }
1286
+ return arePatternsCoveredBy({
1287
+ patterns: permission.patterns,
1288
+ coveringPatterns: pending.permission.patterns,
1289
+ });
1290
+ })
1291
+ : undefined;
1292
+ if (existingPending) {
1293
+ sessionLogger.log(`[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`);
1294
+ stopTyping();
1295
+ if (!pendingPermissions.has(thread.id)) {
1296
+ pendingPermissions.set(thread.id, new Map());
1297
+ }
1298
+ pendingPermissions.get(thread.id).set(permission.id, {
1299
+ permission,
1300
+ messageId: existingPending.messageId,
1301
+ directory,
1302
+ permissionDirectory: existingPending.permissionDirectory,
1303
+ contextHash: existingPending.contextHash,
1304
+ dedupeKey,
1305
+ });
1306
+ const added = addPermissionRequestToContext({
1307
+ contextHash: existingPending.contextHash,
1308
+ requestId: permission.id,
1309
+ });
1310
+ if (!added) {
1311
+ sessionLogger.log(`[PERMISSION] Failed to attach duplicate request ${permission.id} to context`);
1312
+ }
1313
+ return;
1314
+ }
1315
+ sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`);
1316
+ stopTyping();
1317
+ const { messageId, contextHash } = await showPermissionButtons({
1318
+ thread,
1319
+ permission,
1320
+ directory,
1321
+ permissionDirectory: sdkDirectory,
1322
+ subtaskLabel,
1323
+ });
1324
+ if (!pendingPermissions.has(thread.id)) {
1325
+ pendingPermissions.set(thread.id, new Map());
1326
+ }
1327
+ pendingPermissions.get(thread.id).set(permission.id, {
1328
+ permission,
1329
+ messageId,
1330
+ directory,
1331
+ permissionDirectory: sdkDirectory,
1332
+ contextHash,
1333
+ dedupeKey,
1334
+ });
1335
+ };
1336
+ const handlePermissionReplied = ({ requestID, reply, sessionID, }) => {
1337
+ const isMainSession = sessionID === session.id;
1338
+ const isSubtaskSession = subtaskSessions.has(sessionID);
1339
+ if (!isMainSession && !isSubtaskSession) {
1340
+ return;
1341
+ }
1342
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
1343
+ const threadPermissions = pendingPermissions.get(thread.id);
1344
+ if (!threadPermissions) {
1345
+ return;
1346
+ }
1347
+ const pending = threadPermissions.get(requestID);
1348
+ if (!pending) {
1349
+ return;
1350
+ }
1351
+ cleanupPermissionContext(pending.contextHash);
1352
+ threadPermissions.delete(requestID);
1353
+ if (threadPermissions.size === 0) {
1354
+ pendingPermissions.delete(thread.id);
1355
+ }
1356
+ };
1357
+ const handleQuestionAsked = async (questionRequest) => {
1358
+ if (questionRequest.sessionID !== session.id) {
1359
+ sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
1360
+ return;
1361
+ }
1362
+ sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
1363
+ await showInteractiveUi({
1364
+ flushMessageId: assistantMessageId,
1365
+ show: async () => {
1366
+ await showAskUserQuestionDropdowns({
1367
+ thread,
1368
+ sessionId: session.id,
1369
+ directory,
1370
+ requestId: questionRequest.id,
1371
+ input: { questions: questionRequest.questions },
1372
+ });
1373
+ },
1374
+ });
1375
+ const queue = messageQueue.get(thread.id);
1376
+ if (!queue || queue.length === 0) {
1377
+ return;
1378
+ }
1379
+ const nextMessage = queue.shift();
1380
+ if (queue.length === 0) {
1381
+ messageQueue.delete(thread.id);
1382
+ }
1383
+ sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
1384
+ const displayText = nextMessage.command
1385
+ ? `/${nextMessage.command.name}`
1386
+ : `${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`;
1387
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${displayText}`);
1388
+ setImmediate(() => {
1389
+ void errore
1390
+ .tryAsync(async () => {
1391
+ return handleOpencodeSession({
1392
+ prompt: nextMessage.prompt,
1393
+ thread,
1394
+ projectDirectory: directory,
1395
+ images: nextMessage.images,
1396
+ channelId,
1397
+ username: nextMessage.username,
1398
+ appId: nextMessage.appId,
1399
+ command: nextMessage.command,
1400
+ });
1401
+ })
1402
+ .then(async (result) => {
1403
+ if (!(result instanceof Error)) {
1404
+ return;
1405
+ }
1406
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, result);
1407
+ await sendThreadMessage(thread, `✗ Queued message failed: ${result.message.slice(0, 200)}`);
1408
+ });
1409
+ });
1410
+ };
1411
+ const handleSessionStatus = async (properties) => {
1412
+ if (properties.sessionID !== session.id) {
1413
+ return;
1414
+ }
1415
+ if (properties.status.type !== 'retry') {
1416
+ return;
1417
+ }
1418
+ // Throttle to once per 10 seconds
1419
+ const now = Date.now();
1420
+ if (now - lastRateLimitDisplayTime < 10_000) {
1421
+ return;
1422
+ }
1423
+ lastRateLimitDisplayTime = now;
1424
+ const { attempt, message, next } = properties.status;
1425
+ const remainingMs = Math.max(0, next - now);
1426
+ const remainingSec = Math.ceil(remainingMs / 1000);
1427
+ const duration = (() => {
1428
+ if (remainingSec < 60) {
1429
+ return `${remainingSec}s`;
1430
+ }
1431
+ const mins = Math.floor(remainingSec / 60);
1432
+ const secs = remainingSec % 60;
1433
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
1434
+ })();
1435
+ const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`;
1436
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
1437
+ };
1438
+ const handleTuiToast = async (properties) => {
1439
+ if (properties.variant === 'warning') {
1440
+ return;
1441
+ }
1442
+ const message = properties.message.trim();
1443
+ if (!message) {
1444
+ return;
1445
+ }
1446
+ const titlePrefix = properties.title ? `${properties.title.trim()}: ` : '';
1447
+ const chunk = `⬦ ${properties.variant}: ${titlePrefix}${message}`;
1448
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
1449
+ };
1450
+ const handleSessionIdle = (idleSessionId) => {
1451
+ if (idleSessionId === session.id) {
1452
+ const idleDecision = sessionRunState.handleMainSessionIdle({
1453
+ store: mainRunStore,
1454
+ });
1455
+ if (idleDecision === 'deferred') {
1456
+ sessionLogger.log(`[SESSION IDLE] Deferring idle event for ${session.id} until prompt resolves`);
1457
+ return;
1458
+ }
1459
+ if (idleDecision === 'ignore-no-evidence') {
1460
+ sessionLogger.log(`[SESSION IDLE] Ignoring idle event for ${session.id} (no current-prompt events yet)`);
1461
+ return;
1462
+ }
1463
+ finishMainSessionFromIdle();
1464
+ return;
1465
+ }
1466
+ if (!subtaskSessions.has(idleSessionId)) {
1467
+ return;
1468
+ }
1469
+ const subtask = subtaskSessions.get(idleSessionId);
1470
+ sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`);
1471
+ subtaskSessions.delete(idleSessionId);
1472
+ };
1473
+ try {
1474
+ for await (const event of events) {
1475
+ switch (event.type) {
1476
+ case 'message.updated':
1477
+ await handleMessageUpdated(event.properties.info);
1478
+ break;
1479
+ case 'message.part.updated':
1480
+ await handlePartUpdated(event.properties.part);
1481
+ break;
1482
+ case 'session.error':
1483
+ await handleSessionError(event.properties);
1484
+ break;
1485
+ case 'permission.asked':
1486
+ await handlePermissionAsked(event.properties);
1487
+ break;
1488
+ case 'permission.replied':
1489
+ handlePermissionReplied(event.properties);
1490
+ break;
1491
+ case 'question.asked':
1492
+ await handleQuestionAsked(event.properties);
1493
+ break;
1494
+ case 'session.idle':
1495
+ handleSessionIdle(event.properties.sessionID);
1496
+ break;
1497
+ case 'session.status':
1498
+ await handleSessionStatus(event.properties);
1499
+ break;
1500
+ case 'tui.toast.show':
1501
+ await handleTuiToast(event.properties);
1502
+ break;
1503
+ default:
1504
+ break;
1505
+ }
1506
+ }
1507
+ }
1508
+ catch (e) {
1509
+ if (isAbortError(e)) {
1510
+ sessionLogger.log('AbortController aborted event handling (normal exit)');
1511
+ return;
1512
+ }
1513
+ sessionLogger.error(`Unexpected error in event handling code`, e);
1514
+ throw e;
1515
+ }
1516
+ finally {
1517
+ handlerClosed = true;
1518
+ const activeController = abortControllers.get(session.id);
1519
+ if (activeController === abortController) {
1520
+ abortControllers.delete(session.id);
1521
+ }
1522
+ const abortReason = abortController.signal.reason instanceof SessionAbortError
1523
+ ? abortController.signal.reason.reason
1524
+ : undefined;
1525
+ if (abortController.signal.aborted &&
1526
+ mainRunStore.getState().phase !== 'finished') {
1527
+ sessionRunState.markAborted({ store: mainRunStore });
1528
+ }
1529
+ const shouldFlushFinalParts = !abortController.signal.aborted || abortReason === 'finished';
1530
+ if (shouldFlushFinalParts) {
1531
+ const finalMessageId = assistantMessageId;
1532
+ if (finalMessageId) {
1533
+ const parts = getBufferedParts(finalMessageId);
1534
+ for (const part of parts) {
1535
+ if (!sentPartIds.has(part.id)) {
1536
+ await sendPartMessage(part);
1537
+ }
1538
+ }
1539
+ }
1540
+ }
1541
+ stopTyping();
1542
+ if (!abortController.signal.aborted || abortReason === 'finished') {
1543
+ const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime, {
1544
+ secondsDecimalDigits: 0,
1545
+ });
1546
+ const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
1547
+ const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build'
1548
+ ? ` ⋅ **${usedAgent}**`
1549
+ : '';
1550
+ let contextInfo = '';
1551
+ const folderName = path.basename(sdkDirectory);
1552
+ // Run git branch, token fetch, and provider list in parallel to
1553
+ // minimize footer latency (matters for archive-thread 5s delay race)
1554
+ const [branchResult, contextResult] = await Promise.all([
1555
+ errore.tryAsync(() => {
1556
+ return execAsync('git symbolic-ref --short HEAD', {
1557
+ cwd: sdkDirectory,
1558
+ });
1559
+ }),
1560
+ errore.tryAsync(async () => {
1561
+ // Fetch final token count from API since message.updated events can arrive
1562
+ // after session.idle due to race conditions in event ordering
1563
+ const [messagesResult, providersResult] = await Promise.all([
1564
+ tokensUsedInSession === 0
1565
+ ? errore.tryAsync(() => {
1566
+ return getClient().session.messages({
1567
+ sessionID: session.id,
1568
+ directory: sdkDirectory,
1569
+ });
1570
+ })
1571
+ : null,
1572
+ errore.tryAsync(() => {
1573
+ return getClient().provider.list({
1574
+ directory: sdkDirectory,
1575
+ });
1576
+ }),
1577
+ ]);
1578
+ if (messagesResult && !(messagesResult instanceof Error)) {
1579
+ const messages = messagesResult.data || [];
1580
+ const lastAssistant = [...messages]
1581
+ .reverse()
1582
+ .find((m) => m.info.role === 'assistant');
1583
+ if (lastAssistant && 'tokens' in lastAssistant.info) {
1584
+ const tokens = lastAssistant.info.tokens;
1585
+ tokensUsedInSession =
1586
+ tokens.input +
1587
+ tokens.output +
1588
+ tokens.reasoning +
1589
+ tokens.cache.read +
1590
+ tokens.cache.write;
1591
+ }
1592
+ }
1593
+ if (providersResult && !(providersResult instanceof Error)) {
1594
+ const provider = providersResult.data?.all?.find((p) => p.id === usedProviderID);
1595
+ const model = provider?.models?.[usedModel || ''];
1596
+ if (model?.limit?.context) {
1597
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
1598
+ contextInfo = ` ⋅ ${percentage}%`;
1599
+ }
1600
+ }
1601
+ }),
1602
+ ]);
1603
+ const branchName = branchResult instanceof Error ? '' : branchResult.stdout.trim();
1604
+ if (contextResult instanceof Error) {
1605
+ sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult);
1606
+ }
1607
+ const projectInfo = branchName
1608
+ ? `${folderName} ⋅ ${branchName} ⋅ `
1609
+ : `${folderName} ⋅ `;
1610
+ await sendThreadMessage(thread, `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`, { flags: NOTIFY_MESSAGE_FLAGS });
1611
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
1612
+ // Process queued messages after completion
1613
+ const queue = messageQueue.get(thread.id);
1614
+ if (queue && queue.length > 0) {
1615
+ const nextMessage = queue.shift();
1616
+ if (queue.length === 0) {
1617
+ messageQueue.delete(thread.id);
1618
+ }
1619
+ sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`);
1620
+ // Show that queued message is being sent
1621
+ const displayText = nextMessage.command
1622
+ ? `/${nextMessage.command.name}`
1623
+ : `${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`;
1624
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${displayText}`);
1625
+ // Send the queued message as a new prompt (recursive call)
1626
+ // Use setImmediate to avoid blocking and allow this finally to complete
1627
+ setImmediate(() => {
1628
+ handleOpencodeSession({
1629
+ prompt: nextMessage.prompt,
1630
+ thread,
1631
+ projectDirectory,
1632
+ images: nextMessage.images,
1633
+ channelId,
1634
+ username: nextMessage.username,
1635
+ appId: nextMessage.appId,
1636
+ command: nextMessage.command,
1637
+ }).catch(async (e) => {
1638
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
1639
+ void notifyError(e, 'Queued message processing failed');
1640
+ const errorMsg = e instanceof Error ? e.message : String(e);
1641
+ await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`);
1642
+ });
1643
+ });
1644
+ }
1645
+ }
1646
+ else {
1647
+ sessionLogger.log(`Session was aborted (reason: ${abortReason}), skipping duration message`);
1648
+ }
1649
+ }
1650
+ };
1651
+ const promptResult = await errore.tryAsync(async () => {
1652
+ const newHandlerPromise = eventHandler().finally(() => {
1653
+ if (activeEventHandlers.get(thread.id) === newHandlerPromise) {
1654
+ activeEventHandlers.delete(thread.id);
1655
+ }
1656
+ });
1657
+ activeEventHandlers.set(thread.id, newHandlerPromise);
1658
+ handlerPromise = newHandlerPromise;
1659
+ if (abortController.signal.aborted) {
1660
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
1661
+ return;
1662
+ }
1663
+ startTyping();
1664
+ voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
1665
+ const promptWithImagePaths = (() => {
1666
+ if (images.length === 0) {
1667
+ return prompt;
1668
+ }
1669
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({
1670
+ mime: img.mime,
1671
+ filename: img.filename,
1672
+ sourceUrl: img.sourceUrl,
1673
+ })));
1674
+ // List source URLs and clarify these images are already in context (not paths to read)
1675
+ const imageList = images
1676
+ .map((img) => `- ${img.sourceUrl || img.filename}`)
1677
+ .join('\n');
1678
+ 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}`;
1679
+ })();
1680
+ // Synthetic context for the model (hidden in TUI)
1681
+ let syntheticContext = '';
1682
+ if (username) {
1683
+ syntheticContext += `<discord-user name="${username}" />`;
1684
+ }
1685
+ const parts = [
1686
+ { type: 'text', text: promptWithImagePaths },
1687
+ { type: 'text', text: syntheticContext, synthetic: true },
1688
+ ...images,
1689
+ ];
1690
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
1691
+ // Use model+agent snapshotted at message arrival (before debounce/subscribe gap)
1692
+ const agentPreference = earlyAgentPreference;
1693
+ const modelParam = earlyModelParam;
1694
+ // Build worktree info for system message (worktreeInfo was fetched at the start)
1695
+ const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
1696
+ ? {
1697
+ worktreeDirectory: worktreeInfo.worktree_directory,
1698
+ branch: worktreeInfo.worktree_name,
1699
+ mainRepoDirectory: worktreeInfo.project_directory,
1700
+ }
1701
+ : undefined;
1702
+ const channelTopic = await (async () => {
1703
+ if (thread.parent?.type === ChannelType.GuildText) {
1704
+ return thread.parent.topic?.trim() || undefined;
1705
+ }
1706
+ if (!channelId) {
1707
+ return undefined;
1708
+ }
1709
+ const fetched = await errore.tryAsync(() => {
1710
+ return thread.guild.channels.fetch(channelId);
1711
+ });
1712
+ if (fetched instanceof Error || !fetched) {
1713
+ return undefined;
1714
+ }
1715
+ if (fetched.type !== ChannelType.GuildText) {
1716
+ return undefined;
1717
+ }
1718
+ return fetched.topic?.trim() || undefined;
1719
+ })();
1720
+ hasSentParts = false;
1721
+ sessionRunState.beginPromptCycle({ store: mainRunStore });
1722
+ const messagesBeforePromptResult = await errore.tryAsync(() => {
1723
+ return getClient().session.messages({
1724
+ sessionID: session.id,
1725
+ directory: sdkDirectory,
1726
+ });
1727
+ });
1728
+ if (messagesBeforePromptResult instanceof Error) {
1729
+ sessionLogger.log(`[SESSION IDLE] Could not snapshot pre-prompt assistant message for ${session.id}: ${messagesBeforePromptResult.message}`);
1730
+ }
1731
+ else {
1732
+ const messagesBeforePrompt = messagesBeforePromptResult.data || [];
1733
+ const baselineAssistantIds = new Set(messagesBeforePrompt
1734
+ .filter((message) => message.info.role === 'assistant')
1735
+ .map((message) => message.info.id));
1736
+ sessionRunState.setBaselineAssistantIds({
1737
+ store: mainRunStore,
1738
+ messageIds: baselineAssistantIds,
1739
+ });
1740
+ }
1741
+ // variant is accepted by the server API but not yet in the v1 SDK types
1742
+ const variantField = earlyThinkingValue
1743
+ ? { variant: earlyThinkingValue }
1744
+ : {};
1745
+ sessionRunState.markDispatching({ store: mainRunStore });
1746
+ const response = command
1747
+ ? await getClient().session.command({
1748
+ sessionID: session.id,
1749
+ directory: sdkDirectory,
1750
+ command: command.name,
1751
+ arguments: command.arguments,
1752
+ agent: agentPreference,
1753
+ ...variantField,
1754
+ }, { signal: abortController.signal })
1755
+ : await getClient().session.prompt({
1756
+ sessionID: session.id,
1757
+ directory: sdkDirectory,
1758
+ parts,
1759
+ system: getOpencodeSystemMessage({
1760
+ sessionId: session.id,
1761
+ channelId,
1762
+ guildId: thread.guildId,
1763
+ threadId: thread.id,
1764
+ worktree,
1765
+ channelTopic,
1766
+ username,
1767
+ userId,
1768
+ agents: earlyAvailableAgents,
1769
+ }),
1770
+ model: modelParam,
1771
+ agent: agentPreference,
1772
+ ...variantField,
1773
+ }, { signal: abortController.signal });
1774
+ if (response.error) {
1775
+ const errorMessage = (() => {
1776
+ const err = response.error;
1777
+ if (err && typeof err === 'object') {
1778
+ if ('data' in err &&
1779
+ err.data &&
1780
+ typeof err.data === 'object' &&
1781
+ 'message' in err.data) {
1782
+ return String(err.data.message);
1783
+ }
1784
+ if ('errors' in err &&
1785
+ Array.isArray(err.errors) &&
1786
+ err.errors.length > 0) {
1787
+ return JSON.stringify(err.errors);
1788
+ }
1789
+ }
1790
+ return JSON.stringify(err);
1791
+ })();
1792
+ const responseStatus = (() => {
1793
+ const httpStatus = response.response?.status;
1794
+ if (typeof httpStatus === 'number') {
1795
+ return String(httpStatus);
1796
+ }
1797
+ const err = response.error;
1798
+ if (!err || typeof err !== 'object' || !('data' in err)) {
1799
+ return 'unknown';
1800
+ }
1801
+ const data = err.data;
1802
+ if (!data ||
1803
+ typeof data !== 'object' ||
1804
+ !('statusCode' in data) ||
1805
+ typeof data.statusCode !== 'number') {
1806
+ return 'unknown';
1807
+ }
1808
+ return String(data.statusCode);
1809
+ })();
1810
+ throw new Error(`OpenCode API error (${responseStatus}): ${errorMessage}`);
1811
+ }
1812
+ const deferredIdleDecision = sessionRunState.markPromptResolvedAndConsumeDeferredIdle({
1813
+ store: mainRunStore,
1814
+ });
1815
+ if (deferredIdleDecision === 'ignore-no-evidence') {
1816
+ sessionLogger.log(`[SESSION IDLE] Ignoring deferred idle for ${session.id} because no current-prompt events were observed`);
1817
+ }
1818
+ else if (deferredIdleDecision === 'ignore-before-evidence') {
1819
+ sessionLogger.log(`[SESSION IDLE] Ignoring deferred idle for ${session.id} because it arrived before current-prompt evidence`);
1820
+ }
1821
+ else if (deferredIdleDecision === 'process') {
1822
+ sessionLogger.log(`[SESSION IDLE] Processing deferred idle for ${session.id} after prompt resolved`);
1823
+ finishMainSessionFromIdle();
1824
+ }
1825
+ sessionLogger.log(`Successfully sent prompt, got response`);
1826
+ if (originalMessage) {
1827
+ const reactionResult = await errore.tryAsync(async () => {
1828
+ await removeBotErrorReaction({ message: originalMessage });
1829
+ });
1830
+ if (reactionResult instanceof Error) {
1831
+ discordLogger.log(`Could not update reactions:`, reactionResult);
1832
+ }
1833
+ }
1834
+ return { sessionID: session.id, result: response.data, port };
1835
+ });
1836
+ if (handlerPromise) {
1837
+ await Promise.race([
1838
+ handlerPromise,
1839
+ new Promise((resolve) => {
1840
+ setTimeout(resolve, 1000);
1841
+ }),
1842
+ ]);
1843
+ }
1844
+ if (!errore.isError(promptResult)) {
1845
+ return promptResult;
1846
+ }
1847
+ const promptError = promptResult instanceof Error ? promptResult : new Error('Unknown error');
1848
+ if (isAbortError(promptError)) {
1849
+ return;
1850
+ }
1851
+ sessionLogger.error(`ERROR: Failed to send prompt: ${promptError.message}`);
1852
+ void notifyError(promptError, 'Failed to send prompt to OpenCode');
1853
+ sessionLogger.log(`[ABORT] reason=error sessionId=${session.id} threadId=${thread.id} - prompt failed with error: ${promptError.message}`);
1854
+ sessionRunState.markAborted({ store: mainRunStore });
1855
+ abortController.abort(new SessionAbortError({ reason: 'error' }));
1856
+ if (originalMessage) {
1857
+ const reactionResult = await errore.tryAsync(async () => {
1858
+ await originalMessage.react('❌');
1859
+ });
1860
+ if (reactionResult instanceof Error) {
1861
+ discordLogger.log(`Could not update reaction:`, reactionResult);
1862
+ }
1863
+ else {
1864
+ discordLogger.log(`Added error reaction to message`);
1865
+ }
1866
+ }
1867
+ const errorDisplay = (() => {
1868
+ const promptErrorValue = promptError;
1869
+ const name = promptErrorValue.name || 'Error';
1870
+ const message = promptErrorValue.stack || promptErrorValue.message;
1871
+ return `[${name}]\n${message}`;
1872
+ })();
1873
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
1874
+ }