disunday 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/ai-tool-to-genai.js +208 -0
  2. package/dist/ai-tool-to-genai.test.js +267 -0
  3. package/dist/channel-management.js +96 -0
  4. package/dist/cli.js +1674 -0
  5. package/dist/commands/abort.js +89 -0
  6. package/dist/commands/add-project.js +117 -0
  7. package/dist/commands/agent.js +250 -0
  8. package/dist/commands/ask-question.js +219 -0
  9. package/dist/commands/compact.js +126 -0
  10. package/dist/commands/context-menu.js +171 -0
  11. package/dist/commands/context.js +89 -0
  12. package/dist/commands/cost.js +93 -0
  13. package/dist/commands/create-new-project.js +111 -0
  14. package/dist/commands/diff.js +77 -0
  15. package/dist/commands/export.js +100 -0
  16. package/dist/commands/files.js +73 -0
  17. package/dist/commands/fork.js +199 -0
  18. package/dist/commands/help.js +54 -0
  19. package/dist/commands/login.js +488 -0
  20. package/dist/commands/merge-worktree.js +165 -0
  21. package/dist/commands/model.js +325 -0
  22. package/dist/commands/permissions.js +140 -0
  23. package/dist/commands/ping.js +13 -0
  24. package/dist/commands/queue.js +133 -0
  25. package/dist/commands/remove-project.js +119 -0
  26. package/dist/commands/rename.js +70 -0
  27. package/dist/commands/restart-opencode-server.js +77 -0
  28. package/dist/commands/resume.js +276 -0
  29. package/dist/commands/run-config.js +79 -0
  30. package/dist/commands/run.js +240 -0
  31. package/dist/commands/schedule.js +170 -0
  32. package/dist/commands/session-info.js +58 -0
  33. package/dist/commands/session.js +191 -0
  34. package/dist/commands/settings.js +84 -0
  35. package/dist/commands/share.js +89 -0
  36. package/dist/commands/status.js +79 -0
  37. package/dist/commands/sync.js +119 -0
  38. package/dist/commands/theme.js +53 -0
  39. package/dist/commands/types.js +2 -0
  40. package/dist/commands/undo-redo.js +170 -0
  41. package/dist/commands/user-command.js +135 -0
  42. package/dist/commands/verbosity.js +59 -0
  43. package/dist/commands/worktree-settings.js +50 -0
  44. package/dist/commands/worktree.js +288 -0
  45. package/dist/config.js +139 -0
  46. package/dist/database.js +585 -0
  47. package/dist/discord-bot.js +700 -0
  48. package/dist/discord-utils.js +336 -0
  49. package/dist/discord-utils.test.js +20 -0
  50. package/dist/errors.js +193 -0
  51. package/dist/escape-backticks.test.js +429 -0
  52. package/dist/format-tables.js +96 -0
  53. package/dist/format-tables.test.js +418 -0
  54. package/dist/genai-worker-wrapper.js +109 -0
  55. package/dist/genai-worker.js +299 -0
  56. package/dist/genai.js +230 -0
  57. package/dist/image-utils.js +107 -0
  58. package/dist/interaction-handler.js +289 -0
  59. package/dist/limit-heading-depth.js +25 -0
  60. package/dist/limit-heading-depth.test.js +105 -0
  61. package/dist/logger.js +111 -0
  62. package/dist/markdown.js +323 -0
  63. package/dist/markdown.test.js +269 -0
  64. package/dist/message-formatting.js +447 -0
  65. package/dist/message-formatting.test.js +73 -0
  66. package/dist/openai-realtime.js +226 -0
  67. package/dist/opencode.js +224 -0
  68. package/dist/reaction-handler.js +128 -0
  69. package/dist/scheduler.js +93 -0
  70. package/dist/security.js +200 -0
  71. package/dist/session-handler.js +1436 -0
  72. package/dist/system-message.js +138 -0
  73. package/dist/tools.js +354 -0
  74. package/dist/unnest-code-blocks.js +117 -0
  75. package/dist/unnest-code-blocks.test.js +432 -0
  76. package/dist/utils.js +95 -0
  77. package/dist/voice-handler.js +569 -0
  78. package/dist/voice.js +344 -0
  79. package/dist/worker-types.js +4 -0
  80. package/dist/worktree-utils.js +134 -0
  81. package/dist/xml.js +90 -0
  82. package/dist/xml.test.js +32 -0
  83. package/package.json +84 -0
@@ -0,0 +1,1436 @@
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 prettyMilliseconds from 'pretty-ms';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { xdgState } from 'xdg-basedir';
8
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, getThreadWorktree, getChannelVerbosity, getChannelTheme, getBotSettings, getChannelDirectory, } from './database.js';
9
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2, } from './opencode.js';
10
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
11
+ import { formatPart } from './message-formatting.js';
12
+ import { getOpencodeSystemMessage, } from './system-message.js';
13
+ import { createLogger, LogPrefix } from './logger.js';
14
+ import { isAbortError } from './utils.js';
15
+ import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
16
+ import { showPermissionDropdown, cleanupPermissionContext, addPermissionRequestToContext, } from './commands/permissions.js';
17
+ import * as errore from 'errore';
18
+ const sessionLogger = createLogger(LogPrefix.SESSION);
19
+ const voiceLogger = createLogger(LogPrefix.VOICE);
20
+ const discordLogger = createLogger(LogPrefix.DISCORD);
21
+ export const abortControllers = new Map();
22
+ export function abortSession(sessionId) {
23
+ const controller = abortControllers.get(sessionId);
24
+ if (!controller) {
25
+ return false;
26
+ }
27
+ sessionLogger.log(`[ABORT] reason=user-reaction sessionId=${sessionId}`);
28
+ controller.abort(new Error('user-abort'));
29
+ return true;
30
+ }
31
+ // Built-in tools that are hidden in text-and-essential-tools verbosity mode.
32
+ // Essential tools (edits, bash with side effects, todos, tasks, custom MCP tools) are shown; these navigation/read tools are hidden.
33
+ const NON_ESSENTIAL_TOOLS = new Set([
34
+ 'read',
35
+ 'list',
36
+ 'glob',
37
+ 'grep',
38
+ 'todoread',
39
+ 'skill',
40
+ 'question',
41
+ 'webfetch',
42
+ ]);
43
+ function isEssentialToolName(toolName) {
44
+ return !NON_ESSENTIAL_TOOLS.has(toolName);
45
+ }
46
+ function isEssentialToolPart(part) {
47
+ if (part.type !== 'tool') {
48
+ return false;
49
+ }
50
+ if (!isEssentialToolName(part.tool)) {
51
+ return false;
52
+ }
53
+ if (part.tool === 'bash') {
54
+ const hasSideEffect = part.state.input?.hasSideEffect;
55
+ return hasSideEffect !== false;
56
+ }
57
+ return true;
58
+ }
59
+ // Track multiple pending permissions per thread (keyed by permission ID)
60
+ // OpenCode handles blocking/sequencing - we just need to track all pending permissions
61
+ // to avoid duplicates and properly clean up on auto-reject
62
+ export const pendingPermissions = new Map();
63
+ function buildPermissionDedupeKey({ permission, directory, }) {
64
+ const normalizedPatterns = [...permission.patterns].sort((a, b) => {
65
+ return a.localeCompare(b);
66
+ });
67
+ return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`;
68
+ }
69
+ // Queue of messages waiting to be sent after current response finishes
70
+ // Key is threadId, value is array of queued messages
71
+ export const messageQueue = new Map();
72
+ const activeEventHandlers = new Map();
73
+ export function addToQueue({ threadId, message, }) {
74
+ const queue = messageQueue.get(threadId) || [];
75
+ queue.push(message);
76
+ messageQueue.set(threadId, queue);
77
+ return queue.length;
78
+ }
79
+ export function getQueueLength(threadId) {
80
+ return messageQueue.get(threadId)?.length || 0;
81
+ }
82
+ export function clearQueue(threadId) {
83
+ messageQueue.delete(threadId);
84
+ }
85
+ /**
86
+ * Read user's recent models from OpenCode TUI's state file.
87
+ * Uses same path as OpenCode: path.join(xdgState, "opencode", "model.json")
88
+ * Returns all recent models so we can iterate until finding a valid one.
89
+ * See: opensrc/repos/github.com/sst/opencode/packages/opencode/src/global/index.ts
90
+ */
91
+ function getRecentModelsFromTuiState() {
92
+ if (!xdgState) {
93
+ return [];
94
+ }
95
+ // Same path as OpenCode TUI: path.join(Global.Path.state, "model.json")
96
+ const modelJsonPath = path.join(xdgState, 'opencode', 'model.json');
97
+ const result = errore.tryFn(() => {
98
+ const content = fs.readFileSync(modelJsonPath, 'utf-8');
99
+ const data = JSON.parse(content);
100
+ return data.recent ?? [];
101
+ });
102
+ if (result instanceof Error) {
103
+ // File doesn't exist or is invalid - this is normal for fresh installs
104
+ return [];
105
+ }
106
+ return result;
107
+ }
108
+ /**
109
+ * Parse a model string in format "provider/model" into providerID and modelID.
110
+ */
111
+ function parseModelString(model) {
112
+ const [providerID, ...modelParts] = model.split('/');
113
+ const modelID = modelParts.join('/');
114
+ if (!providerID || !modelID) {
115
+ return undefined;
116
+ }
117
+ return { providerID, modelID };
118
+ }
119
+ /**
120
+ * Validate that a model is available (provider connected + model exists).
121
+ */
122
+ function isModelValid(model, connected, providers) {
123
+ const isConnected = connected.includes(model.providerID);
124
+ const provider = providers.find((p) => p.id === model.providerID);
125
+ const modelExists = provider?.models && model.modelID in provider.models;
126
+ return isConnected && !!modelExists;
127
+ }
128
+ /**
129
+ * Get the default model from OpenCode when no user preference is set.
130
+ * Priority (matches OpenCode TUI behavior):
131
+ * 1. OpenCode config.model setting
132
+ * 2. User's recent models from TUI state (~/.local/state/opencode/model.json)
133
+ * 3. First connected provider's default model from API
134
+ */
135
+ async function getDefaultModel({ getClient, directory, }) {
136
+ if (getClient instanceof Error) {
137
+ return undefined;
138
+ }
139
+ // Fetch connected providers to validate any model we return
140
+ const providersResponse = await errore.tryAsync(() => {
141
+ return getClient().provider.list({ query: { directory } });
142
+ });
143
+ if (providersResponse instanceof Error) {
144
+ sessionLogger.log(`[MODEL] Failed to fetch providers for default model:`, providersResponse.message);
145
+ return undefined;
146
+ }
147
+ if (!providersResponse.data) {
148
+ return undefined;
149
+ }
150
+ const { connected, default: defaults, all: providers, } = providersResponse.data;
151
+ if (connected.length === 0) {
152
+ sessionLogger.log(`[MODEL] No connected providers found`);
153
+ return undefined;
154
+ }
155
+ // 1. Check OpenCode config.model setting (highest priority after user preference)
156
+ const configResponse = await errore.tryAsync(() => {
157
+ return getClient().config.get({ query: { directory } });
158
+ });
159
+ if (!(configResponse instanceof Error) && configResponse.data?.model) {
160
+ const configModel = parseModelString(configResponse.data.model);
161
+ if (configModel && isModelValid(configModel, connected, providers)) {
162
+ sessionLogger.log(`[MODEL] Using config model: ${configModel.providerID}/${configModel.modelID}`);
163
+ return configModel;
164
+ }
165
+ if (configModel) {
166
+ sessionLogger.log(`[MODEL] Config model ${configResponse.data.model} not available, checking recent`);
167
+ }
168
+ }
169
+ // 2. Try to use user's recent models from TUI state (iterate until finding valid one)
170
+ const recentModels = getRecentModelsFromTuiState();
171
+ for (const recentModel of recentModels) {
172
+ if (isModelValid(recentModel, connected, providers)) {
173
+ sessionLogger.log(`[MODEL] Using recent TUI model: ${recentModel.providerID}/${recentModel.modelID}`);
174
+ return recentModel;
175
+ }
176
+ }
177
+ if (recentModels.length > 0) {
178
+ sessionLogger.log(`[MODEL] No valid recent TUI models found`);
179
+ }
180
+ // 3. Fall back to first connected provider's default model
181
+ const firstConnected = connected[0];
182
+ if (!firstConnected) {
183
+ return undefined;
184
+ }
185
+ const defaultModelId = defaults[firstConnected];
186
+ if (!defaultModelId) {
187
+ sessionLogger.log(`[MODEL] No default model for provider ${firstConnected}`);
188
+ return undefined;
189
+ }
190
+ sessionLogger.log(`[MODEL] Using provider default: ${firstConnected}/${defaultModelId}`);
191
+ return { providerID: firstConnected, modelID: defaultModelId };
192
+ }
193
+ /**
194
+ * Abort a running session and retry with the last user message.
195
+ * Used when model preference changes mid-request.
196
+ * Fetches last user message from OpenCode API instead of tracking in memory.
197
+ * @returns true if aborted and retry scheduled, false if no active request
198
+ */
199
+ export async function abortAndRetrySession({ sessionId, thread, projectDirectory, }) {
200
+ const controller = abortControllers.get(sessionId);
201
+ if (!controller) {
202
+ sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`);
203
+ return false;
204
+ }
205
+ sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
206
+ // Abort with special reason so we don't show "completed" message
207
+ sessionLogger.log(`[ABORT] reason=model-change sessionId=${sessionId} - user changed model mid-request, will retry with new model`);
208
+ controller.abort(new Error('model-change'));
209
+ // Also call the API abort endpoint
210
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
211
+ if (getClient instanceof Error) {
212
+ sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message);
213
+ return false;
214
+ }
215
+ sessionLogger.log(`[ABORT-API] reason=model-change sessionId=${sessionId} - sending API abort for model change retry`);
216
+ const abortResult = await errore.tryAsync(() => {
217
+ return getClient().session.abort({ path: { id: sessionId } });
218
+ });
219
+ if (abortResult instanceof Error) {
220
+ sessionLogger.log(`[ABORT-API] API abort call failed (may already be done):`, abortResult);
221
+ }
222
+ // Small delay to let the abort propagate
223
+ await new Promise((resolve) => {
224
+ setTimeout(resolve, 300);
225
+ });
226
+ // Fetch last user message from API
227
+ sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`);
228
+ const messagesResponse = await getClient().session.messages({
229
+ path: { id: sessionId },
230
+ });
231
+ const messages = messagesResponse.data || [];
232
+ const lastUserMessage = [...messages]
233
+ .reverse()
234
+ .find((m) => m.info.role === 'user');
235
+ if (!lastUserMessage) {
236
+ sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`);
237
+ return false;
238
+ }
239
+ // Extract text and images from parts
240
+ const textPart = lastUserMessage.parts.find((p) => p.type === 'text');
241
+ const prompt = textPart?.text || '';
242
+ const images = lastUserMessage.parts.filter((p) => p.type === 'file');
243
+ sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
244
+ // Use setImmediate to avoid blocking
245
+ setImmediate(() => {
246
+ void errore
247
+ .tryAsync(async () => {
248
+ return handleOpencodeSession({
249
+ prompt,
250
+ thread,
251
+ projectDirectory,
252
+ images,
253
+ });
254
+ })
255
+ .then(async (result) => {
256
+ if (!(result instanceof Error)) {
257
+ return;
258
+ }
259
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result);
260
+ await sendThreadMessage(thread, `✗ Failed to retry with new model: ${result.message.slice(0, 200)}`);
261
+ });
262
+ });
263
+ return true;
264
+ }
265
+ export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, agent, }) {
266
+ voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
267
+ const sessionStartTime = Date.now();
268
+ const directory = projectDirectory || process.cwd();
269
+ sessionLogger.log(`Using directory: ${directory}`);
270
+ // Get worktree info early so we can use the correct directory for events and prompts
271
+ const worktreeInfo = getThreadWorktree(thread.id);
272
+ const worktreeDirectory = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
273
+ ? worktreeInfo.worktree_directory
274
+ : undefined;
275
+ // Use worktree directory for SDK calls if available, otherwise project directory
276
+ const sdkDirectory = worktreeDirectory || directory;
277
+ if (worktreeDirectory) {
278
+ sessionLogger.log(`Using worktree directory for SDK calls: ${worktreeDirectory}`);
279
+ }
280
+ const getClient = await initializeOpencodeForDirectory(directory);
281
+ if (getClient instanceof Error) {
282
+ await sendThreadMessage(thread, `✗ ${getClient.message}`);
283
+ return;
284
+ }
285
+ const serverEntry = getOpencodeServers().get(directory);
286
+ const port = serverEntry?.port;
287
+ const row = getDatabase()
288
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
289
+ .get(thread.id);
290
+ let sessionId = row?.session_id;
291
+ let session;
292
+ if (sessionId) {
293
+ sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
294
+ const sessionResponse = await errore.tryAsync(() => {
295
+ return getClient().session.get({
296
+ path: { id: sessionId },
297
+ query: { directory: sdkDirectory },
298
+ });
299
+ });
300
+ if (sessionResponse instanceof Error) {
301
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`);
302
+ }
303
+ else {
304
+ session = sessionResponse.data;
305
+ sessionLogger.log(`Successfully reused session ${sessionId}`);
306
+ }
307
+ }
308
+ let isNewSession = false;
309
+ if (!session) {
310
+ isNewSession = true;
311
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
312
+ voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
313
+ const sessionResponse = await getClient().session.create({
314
+ body: { title: sessionTitle },
315
+ query: { directory: sdkDirectory },
316
+ });
317
+ session = sessionResponse.data;
318
+ sessionLogger.log(`Created new session ${session?.id}`);
319
+ }
320
+ if (!session) {
321
+ throw new Error('Failed to create or get session');
322
+ }
323
+ getDatabase()
324
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
325
+ .run(thread.id, session.id);
326
+ sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`);
327
+ if (isNewSession) {
328
+ const terminalCmd = `opencode -s ${session.id} ${sdkDirectory}`;
329
+ const sessionInfoContent = `📋 **Session Info**\n**ID:** \`${session.id}\`\n**Terminal:**\n\`\`\`\n${terminalCmd}\n\`\`\``;
330
+ const infoMessage = await sendThreadMessage(thread, sessionInfoContent);
331
+ await infoMessage.pin().catch(() => { });
332
+ }
333
+ // Store agent preference if provided
334
+ if (agent) {
335
+ setSessionAgent(session.id, agent);
336
+ sessionLogger.log(`Set agent preference for session ${session.id}: ${agent}`);
337
+ }
338
+ const existingController = abortControllers.get(session.id);
339
+ if (existingController) {
340
+ voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
341
+ sessionLogger.log(`[ABORT] reason=new-request sessionId=${session.id} threadId=${thread.id} - new user message arrived while previous request was still running`);
342
+ existingController.abort(new Error('New request started'));
343
+ sessionLogger.log(`[ABORT-API] reason=new-request sessionId=${session.id} - sending API abort because new message arrived`);
344
+ const abortResult = await errore.tryAsync(() => {
345
+ return getClient().session.abort({
346
+ path: { id: session.id },
347
+ query: { directory: sdkDirectory },
348
+ });
349
+ });
350
+ if (abortResult instanceof Error) {
351
+ sessionLogger.log(`[ABORT-API] Server abort failed (may be already done):`, abortResult);
352
+ }
353
+ }
354
+ // Auto-reject ALL pending permissions for this thread
355
+ const threadPermissions = pendingPermissions.get(thread.id);
356
+ if (threadPermissions && threadPermissions.size > 0) {
357
+ const clientV2 = getOpencodeClientV2(directory);
358
+ let rejectedCount = 0;
359
+ for (const [permId, pendingPerm] of threadPermissions) {
360
+ sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`);
361
+ if (!clientV2) {
362
+ sessionLogger.log(`[PERMISSION] OpenCode v2 client unavailable for permission ${permId}`);
363
+ cleanupPermissionContext(pendingPerm.contextHash);
364
+ rejectedCount++;
365
+ continue;
366
+ }
367
+ const rejectResult = await errore.tryAsync(() => {
368
+ return clientV2.permission.reply({
369
+ requestID: permId,
370
+ reply: 'reject',
371
+ });
372
+ });
373
+ if (rejectResult instanceof Error) {
374
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult);
375
+ }
376
+ else {
377
+ rejectedCount++;
378
+ }
379
+ cleanupPermissionContext(pendingPerm.contextHash);
380
+ }
381
+ pendingPermissions.delete(thread.id);
382
+ if (rejectedCount > 0) {
383
+ const plural = rejectedCount > 1 ? 's' : '';
384
+ await sendThreadMessage(thread, `⚠️ ${rejectedCount} pending permission request${plural} auto-rejected due to new message`);
385
+ }
386
+ }
387
+ // Answer any pending question tool with the user's message (silently, no thread message)
388
+ const questionAnswered = await cancelPendingQuestion(thread.id, prompt);
389
+ if (questionAnswered) {
390
+ sessionLogger.log(`[QUESTION] Answered pending question with user message`);
391
+ }
392
+ const abortController = new AbortController();
393
+ abortControllers.set(session.id, abortController);
394
+ if (existingController) {
395
+ await new Promise((resolve) => {
396
+ setTimeout(resolve, 200);
397
+ });
398
+ if (abortController.signal.aborted) {
399
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
400
+ return;
401
+ }
402
+ }
403
+ if (abortController.signal.aborted) {
404
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
405
+ return;
406
+ }
407
+ const previousHandler = activeEventHandlers.get(thread.id);
408
+ if (previousHandler) {
409
+ sessionLogger.log(`[EVENT] Waiting for previous handler to finish`);
410
+ await Promise.race([
411
+ previousHandler,
412
+ new Promise((resolve) => {
413
+ setTimeout(resolve, 1000);
414
+ }),
415
+ ]);
416
+ }
417
+ // Use v2 client for event subscription (has proper types for question.asked events)
418
+ const clientV2 = getOpencodeClientV2(directory);
419
+ if (!clientV2) {
420
+ throw new Error(`OpenCode v2 client not found for directory: ${directory}`);
421
+ }
422
+ const eventsResult = await clientV2.event.subscribe({ directory: sdkDirectory }, { signal: abortController.signal });
423
+ if (abortController.signal.aborted) {
424
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
425
+ return;
426
+ }
427
+ const events = eventsResult.stream;
428
+ sessionLogger.log(`Subscribed to OpenCode events`);
429
+ const sentPartIds = new Set(getDatabase()
430
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
431
+ .all(thread.id).map((row) => row.part_id));
432
+ const partBuffer = new Map();
433
+ let stopTyping = null;
434
+ let stopProgress = null;
435
+ let usedModel;
436
+ let usedProviderID;
437
+ let usedAgent;
438
+ let tokensUsedInSession = 0;
439
+ let lastDisplayedContextPercentage = 0;
440
+ let lastRateLimitDisplayTime = 0;
441
+ let modelContextLimit;
442
+ let assistantMessageId;
443
+ let handlerPromise = null;
444
+ let typingInterval = null;
445
+ let progressInterval = null;
446
+ let hasSentParts = false;
447
+ let promptResolved = false;
448
+ let hasReceivedEvent = false;
449
+ function startProgressTimer() {
450
+ if (abortController.signal.aborted) {
451
+ return () => { };
452
+ }
453
+ if (progressInterval) {
454
+ clearInterval(progressInterval);
455
+ progressInterval = null;
456
+ }
457
+ progressInterval = setInterval(() => {
458
+ if (abortController.signal.aborted) {
459
+ if (progressInterval) {
460
+ clearInterval(progressInterval);
461
+ progressInterval = null;
462
+ }
463
+ return;
464
+ }
465
+ const elapsed = Date.now() - sessionStartTime;
466
+ const elapsedSec = Math.floor(elapsed / 1000);
467
+ const duration = (() => {
468
+ if (elapsedSec < 60) {
469
+ return `${elapsedSec}s`;
470
+ }
471
+ const mins = Math.floor(elapsedSec / 60);
472
+ const secs = elapsedSec % 60;
473
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
474
+ })();
475
+ void thread.send({ content: `⏳ Working... (${duration})`, flags: SILENT_MESSAGE_FLAGS });
476
+ }, 30_000);
477
+ abortController.signal.addEventListener('abort', () => {
478
+ if (progressInterval) {
479
+ clearInterval(progressInterval);
480
+ progressInterval = null;
481
+ }
482
+ }, { once: true });
483
+ return () => {
484
+ if (progressInterval) {
485
+ clearInterval(progressInterval);
486
+ progressInterval = null;
487
+ }
488
+ };
489
+ }
490
+ function startTyping() {
491
+ if (abortController.signal.aborted) {
492
+ discordLogger.log(`Not starting typing, already aborted`);
493
+ return () => { };
494
+ }
495
+ if (typingInterval) {
496
+ clearInterval(typingInterval);
497
+ typingInterval = null;
498
+ }
499
+ void errore
500
+ .tryAsync(() => thread.sendTyping())
501
+ .then((result) => {
502
+ if (result instanceof Error) {
503
+ discordLogger.log(`Failed to send initial typing: ${result}`);
504
+ }
505
+ });
506
+ typingInterval = setInterval(() => {
507
+ void errore
508
+ .tryAsync(() => thread.sendTyping())
509
+ .then((result) => {
510
+ if (result instanceof Error) {
511
+ discordLogger.log(`Failed to send periodic typing: ${result}`);
512
+ }
513
+ });
514
+ }, 8000);
515
+ if (!abortController.signal.aborted) {
516
+ abortController.signal.addEventListener('abort', () => {
517
+ if (typingInterval) {
518
+ clearInterval(typingInterval);
519
+ typingInterval = null;
520
+ }
521
+ }, { once: true });
522
+ }
523
+ return () => {
524
+ if (typingInterval) {
525
+ clearInterval(typingInterval);
526
+ typingInterval = null;
527
+ }
528
+ };
529
+ }
530
+ // Read verbosity and theme dynamically so mid-session changes take effect immediately
531
+ const verbosityChannelId = channelId || thread.parentId || thread.id;
532
+ const getVerbosity = () => {
533
+ return getChannelVerbosity(verbosityChannelId);
534
+ };
535
+ const getTheme = () => {
536
+ return getChannelTheme(verbosityChannelId);
537
+ };
538
+ const sendPartMessage = async (part) => {
539
+ const verbosity = getVerbosity();
540
+ // In text-only mode, only send text parts (the ⬥ diamond messages)
541
+ if (verbosity === 'text-only' && part.type !== 'text') {
542
+ return;
543
+ }
544
+ // In text-and-essential-tools mode, show text + essential tools (edits, custom MCP tools)
545
+ if (verbosity === 'text-and-essential-tools') {
546
+ if (part.type === 'text') {
547
+ // text is always shown
548
+ }
549
+ else if (part.type === 'tool' && isEssentialToolPart(part)) {
550
+ // essential tools are shown
551
+ }
552
+ else {
553
+ return;
554
+ }
555
+ }
556
+ const content = formatPart(part, undefined, getTheme()) + '\n\n';
557
+ if (!content.trim() || content.length === 0) {
558
+ return;
559
+ }
560
+ if (sentPartIds.has(part.id)) {
561
+ return;
562
+ }
563
+ const sendResult = await errore.tryAsync(() => {
564
+ return sendThreadMessage(thread, content);
565
+ });
566
+ if (sendResult instanceof Error) {
567
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult);
568
+ return;
569
+ }
570
+ hasSentParts = true;
571
+ sentPartIds.add(part.id);
572
+ getDatabase()
573
+ .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
574
+ .run(part.id, sendResult.id, thread.id);
575
+ };
576
+ const eventHandler = async () => {
577
+ // Subtask tracking: child sessionId → { label, assistantMessageId }
578
+ const subtaskSessions = new Map();
579
+ // Counts spawned tasks per agent type: "explore" → 2
580
+ const agentSpawnCounts = {};
581
+ const storePart = (part) => {
582
+ const messageParts = partBuffer.get(part.messageID) || new Map();
583
+ messageParts.set(part.id, part);
584
+ partBuffer.set(part.messageID, messageParts);
585
+ };
586
+ const getBufferedParts = (messageID) => {
587
+ return Array.from(partBuffer.get(messageID)?.values() ?? []);
588
+ };
589
+ const shouldSendPart = ({ part, force, }) => {
590
+ if (part.type === 'step-start' || part.type === 'step-finish') {
591
+ return false;
592
+ }
593
+ if (part.type === 'tool' && part.state.status === 'pending') {
594
+ return false;
595
+ }
596
+ if (!force && part.type === 'text' && !part.time?.end) {
597
+ return false;
598
+ }
599
+ if (!force && part.type === 'tool' && part.state.status === 'completed') {
600
+ return false;
601
+ }
602
+ return true;
603
+ };
604
+ const flushBufferedParts = async ({ messageID, force, skipPartId, }) => {
605
+ if (!messageID) {
606
+ return;
607
+ }
608
+ const parts = getBufferedParts(messageID);
609
+ for (const part of parts) {
610
+ if (skipPartId && part.id === skipPartId) {
611
+ continue;
612
+ }
613
+ if (!shouldSendPart({ part, force })) {
614
+ continue;
615
+ }
616
+ await sendPartMessage(part);
617
+ }
618
+ };
619
+ const handleMessageUpdated = async (msg) => {
620
+ const subtaskInfo = subtaskSessions.get(msg.sessionID);
621
+ if (subtaskInfo && msg.role === 'assistant') {
622
+ subtaskInfo.assistantMessageId = msg.id;
623
+ }
624
+ if (msg.sessionID !== session.id) {
625
+ return;
626
+ }
627
+ hasReceivedEvent = true;
628
+ if (msg.role !== 'assistant') {
629
+ return;
630
+ }
631
+ if (msg.tokens) {
632
+ const newTokensTotal = msg.tokens.input +
633
+ msg.tokens.output +
634
+ msg.tokens.reasoning +
635
+ msg.tokens.cache.read +
636
+ msg.tokens.cache.write;
637
+ if (newTokensTotal > 0) {
638
+ tokensUsedInSession = newTokensTotal;
639
+ }
640
+ }
641
+ assistantMessageId = msg.id;
642
+ usedModel = msg.modelID;
643
+ usedProviderID = msg.providerID;
644
+ usedAgent = msg.mode;
645
+ await flushBufferedParts({
646
+ messageID: assistantMessageId,
647
+ force: false,
648
+ });
649
+ if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
650
+ return;
651
+ }
652
+ if (!modelContextLimit) {
653
+ const providersResponse = await errore.tryAsync(() => {
654
+ return getClient().provider.list({
655
+ query: { directory: sdkDirectory },
656
+ });
657
+ });
658
+ if (providersResponse instanceof Error) {
659
+ sessionLogger.error('Failed to fetch provider info for context limit:', providersResponse);
660
+ }
661
+ else {
662
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
663
+ const model = provider?.models?.[usedModel];
664
+ if (model?.limit?.context) {
665
+ modelContextLimit = model.limit.context;
666
+ }
667
+ }
668
+ }
669
+ if (!modelContextLimit) {
670
+ return;
671
+ }
672
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
673
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
674
+ if (thresholdCrossed <= lastDisplayedContextPercentage ||
675
+ thresholdCrossed < 10) {
676
+ return;
677
+ }
678
+ lastDisplayedContextPercentage = thresholdCrossed;
679
+ const chunk = `⬦ context usage ${currentPercentage}%`;
680
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
681
+ };
682
+ const handleMainPart = async (part) => {
683
+ const isActiveMessage = assistantMessageId
684
+ ? part.messageID === assistantMessageId
685
+ : false;
686
+ const allowEarlyProcessing = !assistantMessageId &&
687
+ part.type === 'tool' &&
688
+ part.state.status === 'running';
689
+ if (!isActiveMessage && !allowEarlyProcessing) {
690
+ if (part.type !== 'step-start') {
691
+ return;
692
+ }
693
+ }
694
+ if (part.type === 'step-start') {
695
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
696
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
697
+ if (!hasPendingQuestion && !hasPendingPermission) {
698
+ stopTyping = startTyping();
699
+ }
700
+ return;
701
+ }
702
+ if (part.type === 'tool' && part.state.status === 'running') {
703
+ await flushBufferedParts({
704
+ messageID: assistantMessageId || part.messageID,
705
+ force: true,
706
+ skipPartId: part.id,
707
+ });
708
+ await sendPartMessage(part);
709
+ if (part.tool === 'task' && !sentPartIds.has(part.id)) {
710
+ const description = part.state.input?.description || '';
711
+ const agent = part.state.input?.subagent_type || 'task';
712
+ const childSessionId = part.state.metadata?.sessionId || '';
713
+ if (description && childSessionId) {
714
+ agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1;
715
+ const label = `${agent}-${agentSpawnCounts[agent]}`;
716
+ subtaskSessions.set(childSessionId, {
717
+ label,
718
+ assistantMessageId: undefined,
719
+ });
720
+ // Show task messages in tools-and-text and text-and-essential-tools modes
721
+ if (getVerbosity() !== 'text-only') {
722
+ const taskDisplay = `┣ task **${label}** _${description}_`;
723
+ await sendThreadMessage(thread, taskDisplay + '\n\n');
724
+ }
725
+ sentPartIds.add(part.id);
726
+ }
727
+ }
728
+ return;
729
+ }
730
+ // Show large output notifications for tools that are visible in current verbosity mode
731
+ if (part.type === 'tool' && part.state.status === 'completed') {
732
+ const showLargeOutput = (() => {
733
+ const verbosity = getVerbosity();
734
+ if (verbosity === 'text-only') {
735
+ return false;
736
+ }
737
+ if (verbosity === 'text-and-essential-tools') {
738
+ return isEssentialToolPart(part);
739
+ }
740
+ return true;
741
+ })();
742
+ if (showLargeOutput) {
743
+ const output = part.state.output || '';
744
+ const outputTokens = Math.ceil(output.length / 4);
745
+ const largeOutputThreshold = 3000;
746
+ if (outputTokens >= largeOutputThreshold) {
747
+ const formattedTokens = outputTokens >= 1000
748
+ ? `${(outputTokens / 1000).toFixed(1)}k`
749
+ : String(outputTokens);
750
+ const percentageSuffix = (() => {
751
+ if (!modelContextLimit) {
752
+ return '';
753
+ }
754
+ const pct = (outputTokens / modelContextLimit) * 100;
755
+ if (pct < 1) {
756
+ return '';
757
+ }
758
+ return ` (${pct.toFixed(1)}%)`;
759
+ })();
760
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
761
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
762
+ }
763
+ }
764
+ }
765
+ if (part.type === 'reasoning') {
766
+ await sendPartMessage(part);
767
+ return;
768
+ }
769
+ if (part.type === 'text' && part.time?.end) {
770
+ await sendPartMessage(part);
771
+ return;
772
+ }
773
+ if (part.type === 'step-finish') {
774
+ await flushBufferedParts({
775
+ messageID: assistantMessageId || part.messageID,
776
+ force: true,
777
+ });
778
+ setTimeout(() => {
779
+ if (abortController.signal.aborted)
780
+ return;
781
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
782
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
783
+ if (hasPendingQuestion || hasPendingPermission)
784
+ return;
785
+ stopTyping = startTyping();
786
+ }, 300);
787
+ }
788
+ };
789
+ const handleSubtaskPart = async (part, subtaskInfo) => {
790
+ const verbosity = getVerbosity();
791
+ // In text-only mode, skip all subtask output (they're tool-related)
792
+ if (verbosity === 'text-only') {
793
+ return;
794
+ }
795
+ // In text-and-essential-tools mode, only show essential tools from subtasks
796
+ if (verbosity === 'text-and-essential-tools') {
797
+ if (!isEssentialToolPart(part)) {
798
+ return;
799
+ }
800
+ }
801
+ if (part.type === 'step-start' || part.type === 'step-finish') {
802
+ return;
803
+ }
804
+ if (part.type === 'tool' && part.state.status === 'pending') {
805
+ return;
806
+ }
807
+ if (part.type === 'text') {
808
+ return;
809
+ }
810
+ if (!subtaskInfo.assistantMessageId ||
811
+ part.messageID !== subtaskInfo.assistantMessageId) {
812
+ return;
813
+ }
814
+ const content = formatPart(part, subtaskInfo.label, getTheme());
815
+ if (!content.trim() || sentPartIds.has(part.id)) {
816
+ return;
817
+ }
818
+ const sendResult = await errore.tryAsync(() => {
819
+ return sendThreadMessage(thread, content + '\n\n');
820
+ });
821
+ if (sendResult instanceof Error) {
822
+ discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult);
823
+ return;
824
+ }
825
+ sentPartIds.add(part.id);
826
+ getDatabase()
827
+ .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
828
+ .run(part.id, sendResult.id, thread.id);
829
+ };
830
+ const handlePartUpdated = async (part) => {
831
+ storePart(part);
832
+ const subtaskInfo = subtaskSessions.get(part.sessionID);
833
+ const isSubtaskEvent = Boolean(subtaskInfo);
834
+ if (part.sessionID !== session.id && !isSubtaskEvent) {
835
+ return;
836
+ }
837
+ if (isSubtaskEvent && subtaskInfo) {
838
+ await handleSubtaskPart(part, subtaskInfo);
839
+ return;
840
+ }
841
+ await handleMainPart(part);
842
+ };
843
+ const handleSessionError = async ({ sessionID, error, }) => {
844
+ if (!sessionID || sessionID !== session.id) {
845
+ voiceLogger.log(`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${sessionID})`);
846
+ return;
847
+ }
848
+ const errorMessage = error?.data?.message || 'Unknown error';
849
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`);
850
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`);
851
+ if (!originalMessage) {
852
+ return;
853
+ }
854
+ const reactionResult = await errore.tryAsync(async () => {
855
+ await originalMessage.reactions.removeAll();
856
+ await originalMessage.react('❌');
857
+ });
858
+ if (reactionResult instanceof Error) {
859
+ discordLogger.log(`Could not update reaction:`, reactionResult);
860
+ }
861
+ else {
862
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`);
863
+ }
864
+ };
865
+ const handlePermissionAsked = async (permission) => {
866
+ const isMainSession = permission.sessionID === session.id;
867
+ const isSubtaskSession = subtaskSessions.has(permission.sessionID);
868
+ if (!isMainSession && !isSubtaskSession) {
869
+ voiceLogger.log(`[PERMISSION IGNORED] Permission for unknown session (expected: ${session.id} or subtask, got: ${permission.sessionID})`);
870
+ return;
871
+ }
872
+ const subtaskLabel = isSubtaskSession
873
+ ? subtaskSessions.get(permission.sessionID)?.label
874
+ : undefined;
875
+ const dedupeKey = buildPermissionDedupeKey({ permission, directory });
876
+ const threadPermissions = pendingPermissions.get(thread.id);
877
+ const existingPending = threadPermissions
878
+ ? Array.from(threadPermissions.values()).find((pending) => {
879
+ return pending.dedupeKey === dedupeKey;
880
+ })
881
+ : undefined;
882
+ if (existingPending) {
883
+ sessionLogger.log(`[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`);
884
+ if (stopTyping) {
885
+ stopTyping();
886
+ stopTyping = null;
887
+ }
888
+ if (stopProgress) {
889
+ stopProgress();
890
+ stopProgress = null;
891
+ }
892
+ if (!pendingPermissions.has(thread.id)) {
893
+ pendingPermissions.set(thread.id, new Map());
894
+ }
895
+ pendingPermissions.get(thread.id).set(permission.id, {
896
+ permission,
897
+ messageId: existingPending.messageId,
898
+ directory,
899
+ contextHash: existingPending.contextHash,
900
+ dedupeKey,
901
+ });
902
+ const added = addPermissionRequestToContext({
903
+ contextHash: existingPending.contextHash,
904
+ requestId: permission.id,
905
+ });
906
+ if (!added) {
907
+ sessionLogger.log(`[PERMISSION] Failed to attach duplicate request ${permission.id} to context`);
908
+ }
909
+ return;
910
+ }
911
+ sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`);
912
+ if (stopTyping) {
913
+ stopTyping();
914
+ stopTyping = null;
915
+ }
916
+ if (stopProgress) {
917
+ stopProgress();
918
+ stopProgress = null;
919
+ }
920
+ const { messageId, contextHash } = await showPermissionDropdown({
921
+ thread,
922
+ permission,
923
+ directory,
924
+ subtaskLabel,
925
+ });
926
+ if (!pendingPermissions.has(thread.id)) {
927
+ pendingPermissions.set(thread.id, new Map());
928
+ }
929
+ pendingPermissions.get(thread.id).set(permission.id, {
930
+ permission,
931
+ messageId,
932
+ directory,
933
+ contextHash,
934
+ dedupeKey,
935
+ });
936
+ };
937
+ const handlePermissionReplied = ({ requestID, reply, sessionID, }) => {
938
+ const isMainSession = sessionID === session.id;
939
+ const isSubtaskSession = subtaskSessions.has(sessionID);
940
+ if (!isMainSession && !isSubtaskSession) {
941
+ return;
942
+ }
943
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
944
+ const threadPermissions = pendingPermissions.get(thread.id);
945
+ if (!threadPermissions) {
946
+ return;
947
+ }
948
+ const pending = threadPermissions.get(requestID);
949
+ if (!pending) {
950
+ return;
951
+ }
952
+ cleanupPermissionContext(pending.contextHash);
953
+ threadPermissions.delete(requestID);
954
+ if (threadPermissions.size === 0) {
955
+ pendingPermissions.delete(thread.id);
956
+ }
957
+ };
958
+ const handleQuestionAsked = async (questionRequest) => {
959
+ if (questionRequest.sessionID !== session.id) {
960
+ sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
961
+ return;
962
+ }
963
+ sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
964
+ if (stopTyping) {
965
+ stopTyping();
966
+ stopTyping = null;
967
+ }
968
+ if (stopProgress) {
969
+ stopProgress();
970
+ stopProgress = null;
971
+ }
972
+ await flushBufferedParts({
973
+ messageID: assistantMessageId || '',
974
+ force: true,
975
+ });
976
+ await showAskUserQuestionDropdowns({
977
+ thread,
978
+ sessionId: session.id,
979
+ directory,
980
+ requestId: questionRequest.id,
981
+ input: { questions: questionRequest.questions },
982
+ });
983
+ const queue = messageQueue.get(thread.id);
984
+ if (!queue || queue.length === 0) {
985
+ return;
986
+ }
987
+ const nextMessage = queue.shift();
988
+ if (queue.length === 0) {
989
+ messageQueue.delete(thread.id);
990
+ }
991
+ sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
992
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
993
+ setImmediate(() => {
994
+ const prefixedPrompt = `${nextMessage.prompt}\n<discord-user name="${nextMessage.username}" />`;
995
+ void errore
996
+ .tryAsync(async () => {
997
+ return handleOpencodeSession({
998
+ prompt: prefixedPrompt,
999
+ thread,
1000
+ projectDirectory: directory,
1001
+ images: nextMessage.images,
1002
+ channelId,
1003
+ });
1004
+ })
1005
+ .then(async (result) => {
1006
+ if (!(result instanceof Error)) {
1007
+ return;
1008
+ }
1009
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, result);
1010
+ await sendThreadMessage(thread, `✗ Queued message failed: ${result.message.slice(0, 200)}`);
1011
+ });
1012
+ });
1013
+ };
1014
+ const handleSessionStatus = async (properties) => {
1015
+ if (properties.sessionID !== session.id) {
1016
+ return;
1017
+ }
1018
+ if (properties.status.type !== 'retry') {
1019
+ return;
1020
+ }
1021
+ // Throttle to once per 10 seconds
1022
+ const now = Date.now();
1023
+ if (now - lastRateLimitDisplayTime < 10_000) {
1024
+ return;
1025
+ }
1026
+ lastRateLimitDisplayTime = now;
1027
+ const { attempt, message, next } = properties.status;
1028
+ const remainingMs = Math.max(0, next - now);
1029
+ const remainingSec = Math.ceil(remainingMs / 1000);
1030
+ const duration = (() => {
1031
+ if (remainingSec < 60) {
1032
+ return `${remainingSec}s`;
1033
+ }
1034
+ const mins = Math.floor(remainingSec / 60);
1035
+ const secs = remainingSec % 60;
1036
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
1037
+ })();
1038
+ const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`;
1039
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
1040
+ };
1041
+ const handleSessionIdle = (idleSessionId) => {
1042
+ if (idleSessionId === session.id) {
1043
+ if (!promptResolved || !hasReceivedEvent) {
1044
+ sessionLogger.log(`[SESSION IDLE] Ignoring idle event for ${session.id} (prompt not resolved or no events yet)`);
1045
+ return;
1046
+ }
1047
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, ending stream`);
1048
+ sessionLogger.log(`[ABORT] reason=finished sessionId=${session.id} threadId=${thread.id} - session completed normally, received idle event after prompt resolved`);
1049
+ abortController.abort(new Error('finished'));
1050
+ return;
1051
+ }
1052
+ if (!subtaskSessions.has(idleSessionId)) {
1053
+ return;
1054
+ }
1055
+ const subtask = subtaskSessions.get(idleSessionId);
1056
+ sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`);
1057
+ subtaskSessions.delete(idleSessionId);
1058
+ };
1059
+ try {
1060
+ for await (const event of events) {
1061
+ // Log all events for debugging
1062
+ if (event.type !== 'message.updated' && event.type !== 'message.part.updated') {
1063
+ sessionLogger.log(`[EVENT] type=${event.type} sessionId=${session.id}`);
1064
+ }
1065
+ switch (event.type) {
1066
+ case 'message.updated':
1067
+ await handleMessageUpdated(event.properties.info);
1068
+ break;
1069
+ case 'message.part.updated':
1070
+ await handlePartUpdated(event.properties.part);
1071
+ break;
1072
+ case 'session.error':
1073
+ sessionLogger.error(`ERROR:`, event.properties);
1074
+ await handleSessionError(event.properties);
1075
+ break;
1076
+ case 'permission.asked':
1077
+ await handlePermissionAsked(event.properties);
1078
+ break;
1079
+ case 'permission.replied':
1080
+ handlePermissionReplied(event.properties);
1081
+ break;
1082
+ case 'question.asked':
1083
+ sessionLogger.log(`[QUESTION.ASKED] Received question event: ${JSON.stringify(event.properties).slice(0, 500)}`);
1084
+ await handleQuestionAsked(event.properties);
1085
+ break;
1086
+ case 'session.idle':
1087
+ handleSessionIdle(event.properties.sessionID);
1088
+ break;
1089
+ case 'session.status':
1090
+ await handleSessionStatus(event.properties);
1091
+ break;
1092
+ default:
1093
+ sessionLogger.log(`[EVENT] Unknown event type: ${event.type}`);
1094
+ break;
1095
+ }
1096
+ }
1097
+ }
1098
+ catch (e) {
1099
+ if (isAbortError(e, abortController.signal)) {
1100
+ sessionLogger.log('AbortController aborted event handling (normal exit)');
1101
+ return;
1102
+ }
1103
+ sessionLogger.error(`Unexpected error in event handling code`, e);
1104
+ throw e;
1105
+ }
1106
+ finally {
1107
+ abortControllers.delete(session.id);
1108
+ const finalMessageId = assistantMessageId;
1109
+ if (finalMessageId) {
1110
+ const parts = getBufferedParts(finalMessageId);
1111
+ for (const part of parts) {
1112
+ if (!sentPartIds.has(part.id)) {
1113
+ await sendPartMessage(part);
1114
+ }
1115
+ }
1116
+ }
1117
+ if (stopTyping) {
1118
+ stopTyping();
1119
+ stopTyping = null;
1120
+ }
1121
+ if (stopProgress) {
1122
+ stopProgress();
1123
+ stopProgress = null;
1124
+ }
1125
+ const abortReason = abortController.signal.reason?.message;
1126
+ if (!abortController.signal.aborted || abortReason === 'finished') {
1127
+ const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
1128
+ const attachCommand = port ? ` ⋅ ${session.id}` : '';
1129
+ const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
1130
+ const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build'
1131
+ ? ` ⋅ **${usedAgent}**`
1132
+ : '';
1133
+ let contextInfo = '';
1134
+ const contextResult = await errore.tryAsync(async () => {
1135
+ // Fetch final token count from API since message.updated events can arrive
1136
+ // after session.idle due to race conditions in event ordering
1137
+ if (tokensUsedInSession === 0) {
1138
+ const messagesResponse = await getClient().session.messages({
1139
+ path: { id: session.id },
1140
+ query: { directory: sdkDirectory },
1141
+ });
1142
+ const messages = messagesResponse.data || [];
1143
+ const lastAssistant = [...messages]
1144
+ .reverse()
1145
+ .find((m) => m.info.role === 'assistant');
1146
+ if (lastAssistant && 'tokens' in lastAssistant.info) {
1147
+ const tokens = lastAssistant.info.tokens;
1148
+ tokensUsedInSession =
1149
+ tokens.input +
1150
+ tokens.output +
1151
+ tokens.reasoning +
1152
+ tokens.cache.read +
1153
+ tokens.cache.write;
1154
+ }
1155
+ }
1156
+ const providersResponse = await getClient().provider.list({
1157
+ query: { directory: sdkDirectory },
1158
+ });
1159
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
1160
+ const model = provider?.models?.[usedModel || ''];
1161
+ if (model?.limit?.context) {
1162
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
1163
+ contextInfo = ` ⋅ ${percentage}%`;
1164
+ }
1165
+ });
1166
+ if (contextResult instanceof Error) {
1167
+ sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult);
1168
+ }
1169
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
1170
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
1171
+ const channelConfig = getChannelDirectory(channelId || thread.parentId || thread.id);
1172
+ if (channelConfig?.appId) {
1173
+ await sendHubNotification({
1174
+ appId: channelConfig.appId,
1175
+ thread,
1176
+ directory,
1177
+ sessionDuration,
1178
+ contextInfo,
1179
+ modelInfo,
1180
+ });
1181
+ }
1182
+ // Process queued messages after completion
1183
+ const queue = messageQueue.get(thread.id);
1184
+ if (queue && queue.length > 0) {
1185
+ const nextMessage = queue.shift();
1186
+ if (queue.length === 0) {
1187
+ messageQueue.delete(thread.id);
1188
+ }
1189
+ sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`);
1190
+ // Show that queued message is being sent
1191
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
1192
+ // Send the queued message as a new prompt (recursive call)
1193
+ // Use setImmediate to avoid blocking and allow this finally to complete
1194
+ setImmediate(() => {
1195
+ const prefixedPrompt = `${nextMessage.prompt}\n<discord-user name="${nextMessage.username}" />`;
1196
+ handleOpencodeSession({
1197
+ prompt: prefixedPrompt,
1198
+ thread,
1199
+ projectDirectory,
1200
+ images: nextMessage.images,
1201
+ channelId,
1202
+ }).catch(async (e) => {
1203
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
1204
+ const errorMsg = e instanceof Error ? e.message : String(e);
1205
+ await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`);
1206
+ });
1207
+ });
1208
+ }
1209
+ }
1210
+ else {
1211
+ sessionLogger.log(`Session was aborted (reason: ${abortReason}), skipping duration message`);
1212
+ }
1213
+ }
1214
+ };
1215
+ const promptResult = await errore.tryAsync(async () => {
1216
+ const newHandlerPromise = eventHandler().finally(() => {
1217
+ if (activeEventHandlers.get(thread.id) === newHandlerPromise) {
1218
+ activeEventHandlers.delete(thread.id);
1219
+ }
1220
+ });
1221
+ activeEventHandlers.set(thread.id, newHandlerPromise);
1222
+ handlerPromise = newHandlerPromise;
1223
+ if (abortController.signal.aborted) {
1224
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
1225
+ return;
1226
+ }
1227
+ stopTyping = startTyping();
1228
+ stopProgress = startProgressTimer();
1229
+ voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
1230
+ const promptWithImagePaths = (() => {
1231
+ if (images.length === 0) {
1232
+ return prompt;
1233
+ }
1234
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({
1235
+ mime: img.mime,
1236
+ filename: img.filename,
1237
+ sourceUrl: img.sourceUrl,
1238
+ })));
1239
+ // List source URLs and clarify these images are already in context (not paths to read)
1240
+ const imageList = images
1241
+ .map((img) => `- ${img.sourceUrl || img.filename}`)
1242
+ .join('\n');
1243
+ 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}`;
1244
+ })();
1245
+ const parts = [
1246
+ { type: 'text', text: promptWithImagePaths },
1247
+ ...images,
1248
+ ];
1249
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
1250
+ const agentPreference = getSessionAgent(session.id) ||
1251
+ (channelId ? getChannelAgent(channelId) : undefined);
1252
+ if (agentPreference) {
1253
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
1254
+ }
1255
+ // Model priority: session model > agent model > channel model > default
1256
+ const sessionModelPreference = getSessionModel(session.id);
1257
+ const channelModelPreference = channelId
1258
+ ? getChannelModel(channelId)
1259
+ : undefined;
1260
+ const modelParam = await (async () => {
1261
+ // 1. Session model preference (highest priority, explicit user override)
1262
+ if (sessionModelPreference) {
1263
+ const [providerID, ...modelParts] = sessionModelPreference.split('/');
1264
+ const modelID = modelParts.join('/');
1265
+ if (providerID && modelID) {
1266
+ sessionLogger.log(`[MODEL] Using session preference: ${sessionModelPreference}`);
1267
+ return { providerID, modelID };
1268
+ }
1269
+ }
1270
+ // 2. Agent's configured model
1271
+ if (agentPreference) {
1272
+ const agentsResponse = await errore.tryAsync(() => {
1273
+ return getClient().app.agents({ query: { directory: sdkDirectory } });
1274
+ });
1275
+ if (!(agentsResponse instanceof Error) && agentsResponse.data) {
1276
+ const agent = agentsResponse.data.find((a) => a.name === agentPreference);
1277
+ if (agent?.model) {
1278
+ sessionLogger.log(`[MODEL] Using agent model: ${agent.model.providerID}/${agent.model.modelID}`);
1279
+ return agent.model;
1280
+ }
1281
+ sessionLogger.log(`[MODEL] Agent "${agentPreference}" has no model configured`);
1282
+ }
1283
+ }
1284
+ // 3. Channel model preference
1285
+ if (channelModelPreference) {
1286
+ const [providerID, ...modelParts] = channelModelPreference.split('/');
1287
+ const modelID = modelParts.join('/');
1288
+ if (providerID && modelID) {
1289
+ sessionLogger.log(`[MODEL] Using channel preference: ${channelModelPreference}`);
1290
+ return { providerID, modelID };
1291
+ }
1292
+ }
1293
+ // 4. Default model from OpenCode (like TUI does)
1294
+ const defaultModel = await getDefaultModel({
1295
+ getClient,
1296
+ directory: sdkDirectory,
1297
+ });
1298
+ if (defaultModel) {
1299
+ sessionLogger.log(`[MODEL] Using default: ${defaultModel.providerID}/${defaultModel.modelID}`);
1300
+ return defaultModel;
1301
+ }
1302
+ // No model available - this will likely cause an error from OpenCode
1303
+ sessionLogger.log(`[MODEL] No model available (no preference, no default)`);
1304
+ return undefined;
1305
+ })();
1306
+ // Fail early if no model available
1307
+ if (!modelParam) {
1308
+ throw new Error('No AI provider connected. Configure a provider in OpenCode with `/connect` command.');
1309
+ }
1310
+ // Build worktree info for system message (worktreeInfo was fetched at the start)
1311
+ const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
1312
+ ? {
1313
+ worktreeDirectory: worktreeInfo.worktree_directory,
1314
+ branch: worktreeInfo.worktree_name,
1315
+ mainRepoDirectory: worktreeInfo.project_directory,
1316
+ }
1317
+ : undefined;
1318
+ hasSentParts = false;
1319
+ const response = command
1320
+ ? await getClient().session.command({
1321
+ path: { id: session.id },
1322
+ query: { directory: sdkDirectory },
1323
+ body: {
1324
+ command: command.name,
1325
+ arguments: command.arguments,
1326
+ agent: agentPreference,
1327
+ },
1328
+ signal: abortController.signal,
1329
+ })
1330
+ : await getClient().session.prompt({
1331
+ path: { id: session.id },
1332
+ query: { directory: sdkDirectory },
1333
+ body: {
1334
+ parts,
1335
+ system: getOpencodeSystemMessage({
1336
+ sessionId: session.id,
1337
+ channelId,
1338
+ worktree,
1339
+ }),
1340
+ model: modelParam,
1341
+ agent: agentPreference,
1342
+ },
1343
+ signal: abortController.signal,
1344
+ });
1345
+ if (response.error) {
1346
+ const errorMessage = (() => {
1347
+ const err = response.error;
1348
+ if (err && typeof err === 'object') {
1349
+ if ('data' in err &&
1350
+ err.data &&
1351
+ typeof err.data === 'object' &&
1352
+ 'message' in err.data) {
1353
+ return String(err.data.message);
1354
+ }
1355
+ if ('errors' in err &&
1356
+ Array.isArray(err.errors) &&
1357
+ err.errors.length > 0) {
1358
+ return JSON.stringify(err.errors);
1359
+ }
1360
+ }
1361
+ return JSON.stringify(err);
1362
+ })();
1363
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
1364
+ }
1365
+ promptResolved = true;
1366
+ sessionLogger.log(`Successfully sent prompt, got response`);
1367
+ if (originalMessage) {
1368
+ const reactionResult = await errore.tryAsync(async () => {
1369
+ await originalMessage.reactions.removeAll();
1370
+ await originalMessage.react('✅');
1371
+ });
1372
+ if (reactionResult instanceof Error) {
1373
+ discordLogger.log(`Could not update reactions:`, reactionResult);
1374
+ }
1375
+ }
1376
+ return { sessionID: session.id, result: response.data, port };
1377
+ });
1378
+ if (handlerPromise) {
1379
+ await Promise.race([
1380
+ handlerPromise,
1381
+ new Promise((resolve) => {
1382
+ setTimeout(resolve, 1000);
1383
+ }),
1384
+ ]);
1385
+ }
1386
+ if (!errore.isError(promptResult)) {
1387
+ return promptResult;
1388
+ }
1389
+ const promptError = promptResult instanceof Error ? promptResult : new Error('Unknown error');
1390
+ if (isAbortError(promptError, abortController.signal)) {
1391
+ return;
1392
+ }
1393
+ sessionLogger.error(`ERROR: Failed to send prompt:`, promptError);
1394
+ sessionLogger.log(`[ABORT] reason=error sessionId=${session.id} threadId=${thread.id} - prompt failed with error: ${promptError.message}`);
1395
+ abortController.abort(new Error('error'));
1396
+ if (originalMessage) {
1397
+ const reactionResult = await errore.tryAsync(async () => {
1398
+ await originalMessage.reactions.removeAll();
1399
+ await originalMessage.react('❌');
1400
+ });
1401
+ if (reactionResult instanceof Error) {
1402
+ discordLogger.log(`Could not update reaction:`, reactionResult);
1403
+ }
1404
+ else {
1405
+ discordLogger.log(`Added error reaction to message`);
1406
+ }
1407
+ }
1408
+ const errorDisplay = (() => {
1409
+ const promptErrorValue = promptError;
1410
+ const name = promptErrorValue.name || 'Error';
1411
+ const message = promptErrorValue.stack || promptErrorValue.message;
1412
+ return `[${name}]\n${message}`;
1413
+ })();
1414
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
1415
+ }
1416
+ async function sendHubNotification({ appId, thread, directory, sessionDuration, contextInfo, modelInfo, }) {
1417
+ const result = await errore.tryAsync(async () => {
1418
+ const settings = getBotSettings(appId);
1419
+ if (!settings.hub_channel_id) {
1420
+ return;
1421
+ }
1422
+ const hubChannel = await thread.client.channels.fetch(settings.hub_channel_id);
1423
+ if (!hubChannel?.isTextBased() || !('send' in hubChannel)) {
1424
+ return;
1425
+ }
1426
+ const projectName = directory.split('/').pop() || directory;
1427
+ const threadUrl = `https://discord.com/channels/${thread.guildId}/${thread.id}`;
1428
+ await hubChannel.send({
1429
+ content: `✅ **${projectName}** completed\n⏱ ${sessionDuration}${contextInfo}${modelInfo}\n🧵 [${thread.name}](${threadUrl})`,
1430
+ flags: NOTIFY_MESSAGE_FLAGS,
1431
+ });
1432
+ });
1433
+ if (result instanceof Error) {
1434
+ sessionLogger.error('Failed to send hub notification:', result);
1435
+ }
1436
+ }