kimaki 0.4.43 → 0.4.45

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 (47) hide show
  1. package/dist/channel-management.js +6 -15
  2. package/dist/cli.js +210 -32
  3. package/dist/commands/merge-worktree.js +152 -0
  4. package/dist/commands/permissions.js +21 -5
  5. package/dist/commands/queue.js +5 -1
  6. package/dist/commands/resume.js +8 -16
  7. package/dist/commands/session.js +18 -42
  8. package/dist/commands/user-command.js +8 -17
  9. package/dist/commands/verbosity.js +53 -0
  10. package/dist/commands/worktree-settings.js +88 -0
  11. package/dist/commands/worktree.js +146 -50
  12. package/dist/database.js +85 -0
  13. package/dist/discord-bot.js +97 -55
  14. package/dist/discord-utils.js +51 -13
  15. package/dist/discord-utils.test.js +20 -0
  16. package/dist/escape-backticks.test.js +14 -3
  17. package/dist/interaction-handler.js +15 -0
  18. package/dist/session-handler.js +549 -412
  19. package/dist/system-message.js +25 -1
  20. package/dist/worktree-utils.js +50 -0
  21. package/package.json +1 -1
  22. package/src/__snapshots__/first-session-no-info.md +1344 -0
  23. package/src/__snapshots__/first-session-with-info.md +1350 -0
  24. package/src/__snapshots__/session-1.md +1344 -0
  25. package/src/__snapshots__/session-2.md +291 -0
  26. package/src/__snapshots__/session-3.md +20324 -0
  27. package/src/__snapshots__/session-with-tools.md +1344 -0
  28. package/src/channel-management.ts +6 -17
  29. package/src/cli.ts +250 -35
  30. package/src/commands/merge-worktree.ts +186 -0
  31. package/src/commands/permissions.ts +31 -5
  32. package/src/commands/queue.ts +5 -1
  33. package/src/commands/resume.ts +8 -18
  34. package/src/commands/session.ts +18 -44
  35. package/src/commands/user-command.ts +8 -19
  36. package/src/commands/verbosity.ts +71 -0
  37. package/src/commands/worktree-settings.ts +122 -0
  38. package/src/commands/worktree.ts +174 -55
  39. package/src/database.ts +108 -0
  40. package/src/discord-bot.ts +119 -63
  41. package/src/discord-utils.test.ts +23 -0
  42. package/src/discord-utils.ts +52 -13
  43. package/src/escape-backticks.test.ts +14 -3
  44. package/src/interaction-handler.ts +22 -0
  45. package/src/session-handler.ts +681 -436
  46. package/src/system-message.ts +37 -0
  47. package/src/worktree-utils.ts +78 -0
@@ -2,7 +2,7 @@
2
2
  // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
3
  // Handles streaming events, permissions, abort signals, and message queuing.
4
4
  import prettyMilliseconds from 'pretty-ms';
5
- import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, } from './database.js';
5
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, getThreadWorktree, getChannelVerbosity, } from './database.js';
6
6
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2, } from './opencode.js';
7
7
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
8
8
  import { formatPart } from './message-formatting.js';
@@ -10,7 +10,7 @@ import { getOpencodeSystemMessage } from './system-message.js';
10
10
  import { createLogger } from './logger.js';
11
11
  import { isAbortError } from './utils.js';
12
12
  import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
13
- import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
13
+ import { showPermissionDropdown, cleanupPermissionContext, addPermissionRequestToContext, } from './commands/permissions.js';
14
14
  import * as errore from 'errore';
15
15
  const sessionLogger = createLogger('SESSION');
16
16
  const voiceLogger = createLogger('VOICE');
@@ -20,6 +20,12 @@ export const abortControllers = new Map();
20
20
  // OpenCode handles blocking/sequencing - we just need to track all pending permissions
21
21
  // to avoid duplicates and properly clean up on auto-reject
22
22
  export const pendingPermissions = new Map();
23
+ function buildPermissionDedupeKey({ permission, directory, }) {
24
+ const normalizedPatterns = [...permission.patterns].sort((a, b) => {
25
+ return a.localeCompare(b);
26
+ });
27
+ return `${directory}::${permission.permission}::${normalizedPatterns.join('|')}`;
28
+ }
23
29
  // Queue of messages waiting to be sent after current response finishes
24
30
  // Key is threadId, value is array of queued messages
25
31
  export const messageQueue = new Map();
@@ -56,11 +62,11 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
56
62
  sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message);
57
63
  return false;
58
64
  }
59
- try {
60
- await getClient().session.abort({ path: { id: sessionId } });
61
- }
62
- catch (e) {
63
- sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e);
65
+ const abortResult = await errore.tryAsync(() => {
66
+ return getClient().session.abort({ path: { id: sessionId } });
67
+ });
68
+ if (abortResult instanceof Error) {
69
+ sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, abortResult);
64
70
  }
65
71
  // Small delay to let the abort propagate
66
72
  await new Promise((resolve) => {
@@ -82,15 +88,21 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
82
88
  sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
83
89
  // Use setImmediate to avoid blocking
84
90
  setImmediate(() => {
85
- handleOpencodeSession({
86
- prompt,
87
- thread,
88
- projectDirectory,
89
- images,
90
- }).catch(async (e) => {
91
- sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e);
92
- const errorMsg = e instanceof Error ? e.message : String(e);
93
- await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`);
91
+ void errore
92
+ .tryAsync(async () => {
93
+ return handleOpencodeSession({
94
+ prompt,
95
+ thread,
96
+ projectDirectory,
97
+ images,
98
+ });
99
+ })
100
+ .then(async (result) => {
101
+ if (!(result instanceof Error)) {
102
+ return;
103
+ }
104
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, result);
105
+ await sendThreadMessage(thread, `✗ Failed to retry with new model: ${result.message.slice(0, 200)}`);
94
106
  });
95
107
  });
96
108
  return true;
@@ -100,6 +112,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
100
112
  const sessionStartTime = Date.now();
101
113
  const directory = projectDirectory || process.cwd();
102
114
  sessionLogger.log(`Using directory: ${directory}`);
115
+ // Get worktree info early so we can use the correct directory for events and prompts
116
+ const worktreeInfo = getThreadWorktree(thread.id);
117
+ const worktreeDirectory = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
118
+ ? worktreeInfo.worktree_directory
119
+ : undefined;
120
+ // Use worktree directory for SDK calls if available, otherwise project directory
121
+ const sdkDirectory = worktreeDirectory || directory;
122
+ if (worktreeDirectory) {
123
+ sessionLogger.log(`Using worktree directory for SDK calls: ${worktreeDirectory}`);
124
+ }
103
125
  const getClient = await initializeOpencodeForDirectory(directory);
104
126
  if (getClient instanceof Error) {
105
127
  await sendThreadMessage(thread, `✗ ${getClient.message}`);
@@ -114,22 +136,26 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
114
136
  let session;
115
137
  if (sessionId) {
116
138
  sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
117
- try {
118
- const sessionResponse = await getClient().session.get({
139
+ const sessionResponse = await errore.tryAsync(() => {
140
+ return getClient().session.get({
119
141
  path: { id: sessionId },
142
+ query: { directory: sdkDirectory },
120
143
  });
144
+ });
145
+ if (sessionResponse instanceof Error) {
146
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`);
147
+ }
148
+ else {
121
149
  session = sessionResponse.data;
122
150
  sessionLogger.log(`Successfully reused session ${sessionId}`);
123
151
  }
124
- catch (error) {
125
- voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`);
126
- }
127
152
  }
128
153
  if (!session) {
129
154
  const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
130
155
  voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
131
156
  const sessionResponse = await getClient().session.create({
132
157
  body: { title: sessionTitle },
158
+ query: { directory: sdkDirectory },
133
159
  });
134
160
  session = sessionResponse.data;
135
161
  sessionLogger.log(`Created new session ${session?.id}`);
@@ -157,21 +183,26 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
157
183
  const clientV2 = getOpencodeClientV2(directory);
158
184
  let rejectedCount = 0;
159
185
  for (const [permId, pendingPerm] of threadPermissions) {
160
- try {
161
- sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`);
162
- if (clientV2) {
163
- await clientV2.permission.reply({
164
- requestID: permId,
165
- reply: 'reject',
166
- });
167
- }
186
+ sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`);
187
+ if (!clientV2) {
188
+ sessionLogger.log(`[PERMISSION] OpenCode v2 client unavailable for permission ${permId}`);
168
189
  cleanupPermissionContext(pendingPerm.contextHash);
169
190
  rejectedCount++;
191
+ continue;
170
192
  }
171
- catch (e) {
172
- sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, e);
173
- cleanupPermissionContext(pendingPerm.contextHash);
193
+ const rejectResult = await errore.tryAsync(() => {
194
+ return clientV2.permission.reply({
195
+ requestID: permId,
196
+ reply: 'reject',
197
+ });
198
+ });
199
+ if (rejectResult instanceof Error) {
200
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult);
201
+ }
202
+ else {
203
+ rejectedCount++;
174
204
  }
205
+ cleanupPermissionContext(pendingPerm.contextHash);
175
206
  }
176
207
  pendingPermissions.delete(thread.id);
177
208
  if (rejectedCount > 0) {
@@ -204,7 +235,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
204
235
  if (!clientV2) {
205
236
  throw new Error(`OpenCode v2 client not found for directory: ${directory}`);
206
237
  }
207
- const eventsResult = await clientV2.event.subscribe({ directory }, { signal: abortController.signal });
238
+ const eventsResult = await clientV2.event.subscribe({ directory: sdkDirectory }, { signal: abortController.signal });
208
239
  if (abortController.signal.aborted) {
209
240
  sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
210
241
  return;
@@ -214,7 +245,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
214
245
  const sentPartIds = new Set(getDatabase()
215
246
  .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
216
247
  .all(thread.id).map((row) => row.part_id));
217
- let currentParts = [];
248
+ const partBuffer = new Map();
218
249
  let stopTyping = null;
219
250
  let usedModel;
220
251
  let usedProviderID;
@@ -222,6 +253,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
222
253
  let tokensUsedInSession = 0;
223
254
  let lastDisplayedContextPercentage = 0;
224
255
  let modelContextLimit;
256
+ let assistantMessageId;
225
257
  let typingInterval = null;
226
258
  function startTyping() {
227
259
  if (abortController.signal.aborted) {
@@ -232,12 +264,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
232
264
  clearInterval(typingInterval);
233
265
  typingInterval = null;
234
266
  }
235
- thread.sendTyping().catch((e) => {
236
- discordLogger.log(`Failed to send initial typing: ${e}`);
267
+ void errore.tryAsync(() => thread.sendTyping()).then((result) => {
268
+ if (result instanceof Error) {
269
+ discordLogger.log(`Failed to send initial typing: ${result}`);
270
+ }
237
271
  });
238
272
  typingInterval = setInterval(() => {
239
- thread.sendTyping().catch((e) => {
240
- discordLogger.log(`Failed to send periodic typing: ${e}`);
273
+ void errore.tryAsync(() => thread.sendTyping()).then((result) => {
274
+ if (result instanceof Error) {
275
+ discordLogger.log(`Failed to send periodic typing: ${result}`);
276
+ }
241
277
  });
242
278
  }, 8000);
243
279
  if (!abortController.signal.aborted) {
@@ -255,7 +291,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
255
291
  }
256
292
  };
257
293
  }
294
+ // Get verbosity setting for this channel (use parent channel for threads)
295
+ const verbosityChannelId = channelId || thread.parentId || thread.id;
296
+ const verbosity = getChannelVerbosity(verbosityChannelId);
258
297
  const sendPartMessage = async (part) => {
298
+ // In text-only mode, only send text parts (the ⬥ diamond messages)
299
+ if (verbosity === 'text-only' && part.type !== 'text') {
300
+ return;
301
+ }
259
302
  const content = formatPart(part) + '\n\n';
260
303
  if (!content.trim() || content.length === 0) {
261
304
  // discordLogger.log(`SKIP: Part ${part.id} has no content`)
@@ -264,351 +307,439 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
264
307
  if (sentPartIds.has(part.id)) {
265
308
  return;
266
309
  }
267
- try {
268
- const firstMessage = await sendThreadMessage(thread, content);
269
- sentPartIds.add(part.id);
270
- getDatabase()
271
- .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
272
- .run(part.id, firstMessage.id, thread.id);
273
- }
274
- catch (error) {
275
- discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error);
310
+ const sendResult = await errore.tryAsync(() => {
311
+ return sendThreadMessage(thread, content);
312
+ });
313
+ if (sendResult instanceof Error) {
314
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult);
315
+ return;
276
316
  }
317
+ sentPartIds.add(part.id);
318
+ getDatabase()
319
+ .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
320
+ .run(part.id, sendResult.id, thread.id);
277
321
  };
278
322
  const eventHandler = async () => {
279
323
  // Subtask tracking: child sessionId → { label, assistantMessageId }
280
324
  const subtaskSessions = new Map();
281
325
  // Counts spawned tasks per agent type: "explore" → 2
282
326
  const agentSpawnCounts = {};
283
- try {
284
- let assistantMessageId;
285
- for await (const event of events) {
286
- if (event.type === 'message.updated') {
287
- const msg = event.properties.info;
288
- // Track assistant message IDs for subtask sessions
289
- const subtaskInfo = subtaskSessions.get(msg.sessionID);
290
- if (subtaskInfo && msg.role === 'assistant') {
291
- subtaskInfo.assistantMessageId = msg.id;
292
- }
293
- if (msg.sessionID !== session.id) {
294
- continue;
295
- }
296
- if (msg.role === 'assistant') {
297
- const newTokensTotal = msg.tokens.input +
298
- msg.tokens.output +
299
- msg.tokens.reasoning +
300
- msg.tokens.cache.read +
301
- msg.tokens.cache.write;
302
- if (newTokensTotal > 0) {
303
- tokensUsedInSession = newTokensTotal;
304
- }
305
- assistantMessageId = msg.id;
306
- usedModel = msg.modelID;
307
- usedProviderID = msg.providerID;
308
- usedAgent = msg.mode;
309
- if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
310
- if (!modelContextLimit) {
311
- try {
312
- const providersResponse = await getClient().provider.list({
313
- query: { directory },
314
- });
315
- const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
316
- const model = provider?.models?.[usedModel];
317
- if (model?.limit?.context) {
318
- modelContextLimit = model.limit.context;
319
- }
320
- }
321
- catch (e) {
322
- sessionLogger.error('Failed to fetch provider info for context limit:', e);
323
- }
324
- }
325
- if (modelContextLimit) {
326
- const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
327
- const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
328
- if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
329
- lastDisplayedContextPercentage = thresholdCrossed;
330
- const chunk = `⬦ context usage ${currentPercentage}%`;
331
- await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
332
- }
333
- }
334
- }
335
- }
327
+ const storePart = (part) => {
328
+ const messageParts = partBuffer.get(part.messageID) || new Map();
329
+ messageParts.set(part.id, part);
330
+ partBuffer.set(part.messageID, messageParts);
331
+ };
332
+ const getBufferedParts = (messageID) => {
333
+ return Array.from(partBuffer.get(messageID)?.values() ?? []);
334
+ };
335
+ const shouldSendPart = ({ part, force }) => {
336
+ if (part.type === 'step-start' || part.type === 'step-finish') {
337
+ return false;
338
+ }
339
+ if (part.type === 'tool' && part.state.status === 'pending') {
340
+ return false;
341
+ }
342
+ if (!force && part.type === 'text' && !part.time?.end) {
343
+ return false;
344
+ }
345
+ if (!force && part.type === 'tool' && part.state.status === 'completed') {
346
+ return false;
347
+ }
348
+ return true;
349
+ };
350
+ const flushBufferedParts = async ({ messageID, force, skipPartId, }) => {
351
+ if (!messageID) {
352
+ return;
353
+ }
354
+ const parts = getBufferedParts(messageID);
355
+ for (const part of parts) {
356
+ if (skipPartId && part.id === skipPartId) {
357
+ continue;
336
358
  }
337
- else if (event.type === 'message.part.updated') {
338
- const part = event.properties.part;
339
- // Check if this is a subtask event (child session we're tracking)
340
- const subtaskInfo = subtaskSessions.get(part.sessionID);
341
- const isSubtaskEvent = Boolean(subtaskInfo);
342
- // Accept events from main session OR tracked subtask sessions
343
- if (part.sessionID !== session.id && !isSubtaskEvent) {
344
- continue;
345
- }
346
- // For subtask events, send them immediately with prefix (don't buffer in currentParts)
347
- if (isSubtaskEvent && subtaskInfo) {
348
- // Skip parts that aren't useful to show (step-start, step-finish, pending tools)
349
- if (part.type === 'step-start' || part.type === 'step-finish') {
350
- continue;
351
- }
352
- if (part.type === 'tool' && part.state.status === 'pending') {
353
- continue;
354
- }
355
- // Skip text parts - the outer agent will report the task result anyway
356
- if (part.type === 'text') {
357
- continue;
358
- }
359
- // Only show parts from assistant messages (not user prompts sent to subtask)
360
- // Skip if we haven't seen an assistant message yet, or if this part is from a different message
361
- if (!subtaskInfo.assistantMessageId ||
362
- part.messageID !== subtaskInfo.assistantMessageId) {
363
- continue;
364
- }
365
- const content = formatPart(part, subtaskInfo.label);
366
- if (content.trim() && !sentPartIds.has(part.id)) {
367
- try {
368
- const msg = await sendThreadMessage(thread, content + '\n\n');
369
- sentPartIds.add(part.id);
370
- getDatabase()
371
- .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
372
- .run(part.id, msg.id, thread.id);
373
- }
374
- catch (error) {
375
- discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, error);
376
- }
377
- }
378
- continue;
379
- }
380
- // Main session events: require matching assistantMessageId
381
- if (part.messageID !== assistantMessageId) {
382
- continue;
383
- }
384
- const existingIndex = currentParts.findIndex((p) => p.id === part.id);
385
- if (existingIndex >= 0) {
386
- currentParts[existingIndex] = part;
387
- }
388
- else {
389
- currentParts.push(part);
390
- }
391
- if (part.type === 'step-start') {
392
- // Don't start typing if user needs to respond to a question or permission
393
- const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
394
- const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
395
- if (!hasPendingQuestion && !hasPendingPermission) {
396
- stopTyping = startTyping();
397
- }
398
- }
399
- if (part.type === 'tool' && part.state.status === 'running') {
400
- // Flush any pending text/reasoning parts before showing the tool
401
- // This ensures text the LLM generated before the tool call is shown first
402
- for (const p of currentParts) {
403
- if (p.type !== 'step-start' && p.type !== 'step-finish' && p.id !== part.id) {
404
- await sendPartMessage(p);
405
- }
406
- }
407
- await sendPartMessage(part);
408
- // Track task tool and register child session when sessionId is available
409
- if (part.tool === 'task' && !sentPartIds.has(part.id)) {
410
- const description = part.state.input?.description || '';
411
- const agent = part.state.input?.subagent_type || 'task';
412
- const childSessionId = part.state.metadata?.sessionId || '';
413
- if (description && childSessionId) {
414
- agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1;
415
- const label = `${agent}-${agentSpawnCounts[agent]}`;
416
- subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined });
417
- const taskDisplay = `┣ task **${label}** _${description}_`;
418
- await sendThreadMessage(thread, taskDisplay + '\n\n');
419
- sentPartIds.add(part.id);
420
- }
421
- }
422
- }
423
- // Show token usage for completed tools with large output (>5k tokens)
424
- if (part.type === 'tool' && part.state.status === 'completed') {
425
- const output = part.state.output || '';
426
- const outputTokens = Math.ceil(output.length / 4);
427
- const LARGE_OUTPUT_THRESHOLD = 3000;
428
- if (outputTokens >= LARGE_OUTPUT_THRESHOLD) {
429
- const formattedTokens = outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens);
430
- const percentageSuffix = (() => {
431
- if (!modelContextLimit) {
432
- return '';
433
- }
434
- const pct = (outputTokens / modelContextLimit) * 100;
435
- if (pct < 1) {
436
- return '';
437
- }
438
- return ` (${pct.toFixed(1)}%)`;
439
- })();
440
- const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
441
- await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
442
- }
443
- }
444
- if (part.type === 'reasoning') {
445
- await sendPartMessage(part);
446
- }
447
- // Send text parts when complete (time.end is set)
448
- // Text parts stream incrementally; only send when finished to avoid partial text
449
- if (part.type === 'text' && part.time?.end) {
450
- await sendPartMessage(part);
451
- }
452
- if (part.type === 'step-finish') {
453
- for (const p of currentParts) {
454
- if (p.type !== 'step-start' && p.type !== 'step-finish') {
455
- await sendPartMessage(p);
456
- }
457
- }
458
- setTimeout(() => {
459
- if (abortController.signal.aborted)
460
- return;
461
- // Don't restart typing if user needs to respond to a question or permission
462
- const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
463
- const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
464
- if (hasPendingQuestion || hasPendingPermission)
465
- return;
466
- stopTyping = startTyping();
467
- }, 300);
468
- }
359
+ if (!shouldSendPart({ part, force })) {
360
+ continue;
469
361
  }
470
- else if (event.type === 'session.error') {
471
- sessionLogger.error(`ERROR:`, event.properties);
472
- if (event.properties.sessionID === session.id) {
473
- const errorData = event.properties.error;
474
- const errorMessage = errorData?.data?.message || 'Unknown error';
475
- sessionLogger.error(`Sending error to thread: ${errorMessage}`);
476
- await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`);
477
- if (originalMessage) {
478
- try {
479
- await originalMessage.reactions.removeAll();
480
- await originalMessage.react('❌');
481
- voiceLogger.log(`[REACTION] Added error reaction due to session error`);
482
- }
483
- catch (e) {
484
- discordLogger.log(`Could not update reaction:`, e);
485
- }
486
- }
487
- }
488
- else {
489
- voiceLogger.log(`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`);
490
- }
491
- break;
362
+ await sendPartMessage(part);
363
+ }
364
+ };
365
+ const handleMessageUpdated = async (msg) => {
366
+ const subtaskInfo = subtaskSessions.get(msg.sessionID);
367
+ if (subtaskInfo && msg.role === 'assistant') {
368
+ subtaskInfo.assistantMessageId = msg.id;
369
+ }
370
+ if (msg.sessionID !== session.id) {
371
+ return;
372
+ }
373
+ if (msg.role !== 'assistant') {
374
+ return;
375
+ }
376
+ if (msg.tokens) {
377
+ const newTokensTotal = msg.tokens.input +
378
+ msg.tokens.output +
379
+ msg.tokens.reasoning +
380
+ msg.tokens.cache.read +
381
+ msg.tokens.cache.write;
382
+ if (newTokensTotal > 0) {
383
+ tokensUsedInSession = newTokensTotal;
492
384
  }
493
- else if (event.type === 'permission.asked') {
494
- const permission = event.properties;
495
- if (permission.sessionID !== session.id) {
496
- voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
497
- continue;
498
- }
499
- // Skip if this exact permission ID is already pending (dedupe)
500
- const threadPermissions = pendingPermissions.get(thread.id);
501
- if (threadPermissions?.has(permission.id)) {
502
- sessionLogger.log(`[PERMISSION] Skipping duplicate permission ${permission.id} (already pending)`);
503
- continue;
504
- }
505
- sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
506
- // Stop typing - user needs to respond now, not the bot
507
- if (stopTyping) {
508
- stopTyping();
509
- stopTyping = null;
510
- }
511
- // Show dropdown instead of text message
512
- const { messageId, contextHash } = await showPermissionDropdown({
513
- thread,
514
- permission,
515
- directory,
516
- });
517
- // Track permission in nested map (threadId -> permissionId -> data)
518
- if (!pendingPermissions.has(thread.id)) {
519
- pendingPermissions.set(thread.id, new Map());
520
- }
521
- pendingPermissions.get(thread.id).set(permission.id, {
522
- permission,
523
- messageId,
524
- directory,
525
- contextHash,
385
+ }
386
+ assistantMessageId = msg.id;
387
+ usedModel = msg.modelID;
388
+ usedProviderID = msg.providerID;
389
+ usedAgent = msg.mode;
390
+ await flushBufferedParts({
391
+ messageID: assistantMessageId,
392
+ force: false,
393
+ });
394
+ if (tokensUsedInSession === 0 || !usedProviderID || !usedModel) {
395
+ return;
396
+ }
397
+ if (!modelContextLimit) {
398
+ const providersResponse = await errore.tryAsync(() => {
399
+ return getClient().provider.list({
400
+ query: { directory: sdkDirectory },
526
401
  });
402
+ });
403
+ if (providersResponse instanceof Error) {
404
+ sessionLogger.error('Failed to fetch provider info for context limit:', providersResponse);
527
405
  }
528
- else if (event.type === 'permission.replied') {
529
- const { requestID, reply, sessionID } = event.properties;
530
- if (sessionID !== session.id) {
531
- continue;
406
+ else {
407
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
408
+ const model = provider?.models?.[usedModel];
409
+ if (model?.limit?.context) {
410
+ modelContextLimit = model.limit.context;
532
411
  }
533
- sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
534
- // Clean up the specific permission from nested map
535
- const threadPermissions = pendingPermissions.get(thread.id);
536
- if (threadPermissions) {
537
- const pending = threadPermissions.get(requestID);
538
- if (pending) {
539
- cleanupPermissionContext(pending.contextHash);
540
- threadPermissions.delete(requestID);
541
- // Remove thread entry if no more pending permissions
542
- if (threadPermissions.size === 0) {
543
- pendingPermissions.delete(thread.id);
544
- }
412
+ }
413
+ }
414
+ if (!modelContextLimit) {
415
+ return;
416
+ }
417
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
418
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
419
+ if (thresholdCrossed <= lastDisplayedContextPercentage || thresholdCrossed < 10) {
420
+ return;
421
+ }
422
+ lastDisplayedContextPercentage = thresholdCrossed;
423
+ const chunk = `⬦ context usage ${currentPercentage}%`;
424
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
425
+ };
426
+ const handleMainPart = async (part) => {
427
+ const isActiveMessage = assistantMessageId ? part.messageID === assistantMessageId : false;
428
+ const allowEarlyProcessing = !assistantMessageId && part.type === 'tool' && part.state.status === 'running';
429
+ if (!isActiveMessage && !allowEarlyProcessing) {
430
+ if (part.type !== 'step-start') {
431
+ return;
432
+ }
433
+ }
434
+ if (part.type === 'step-start') {
435
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
436
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
437
+ if (!hasPendingQuestion && !hasPendingPermission) {
438
+ stopTyping = startTyping();
439
+ }
440
+ return;
441
+ }
442
+ if (part.type === 'tool' && part.state.status === 'running') {
443
+ await flushBufferedParts({
444
+ messageID: assistantMessageId || part.messageID,
445
+ force: true,
446
+ skipPartId: part.id,
447
+ });
448
+ await sendPartMessage(part);
449
+ if (part.tool === 'task' && !sentPartIds.has(part.id)) {
450
+ const description = part.state.input?.description || '';
451
+ const agent = part.state.input?.subagent_type || 'task';
452
+ const childSessionId = part.state.metadata?.sessionId || '';
453
+ if (description && childSessionId) {
454
+ agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1;
455
+ const label = `${agent}-${agentSpawnCounts[agent]}`;
456
+ subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined });
457
+ // Skip task messages in text-only mode
458
+ if (verbosity !== 'text-only') {
459
+ const taskDisplay = `┣ task **${label}** _${description}_`;
460
+ await sendThreadMessage(thread, taskDisplay + '\n\n');
545
461
  }
462
+ sentPartIds.add(part.id);
546
463
  }
547
464
  }
548
- else if (event.type === 'question.asked') {
549
- const questionRequest = event.properties;
550
- if (questionRequest.sessionID !== session.id) {
551
- sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
552
- continue;
553
- }
554
- sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
555
- // Stop typing - user needs to respond now, not the bot
556
- if (stopTyping) {
557
- stopTyping();
558
- stopTyping = null;
559
- }
560
- // Flush any pending text/reasoning parts before showing the dropdown
561
- // This ensures text the LLM generated before the question tool is shown first
562
- for (const p of currentParts) {
563
- if (p.type !== 'step-start' && p.type !== 'step-finish') {
564
- await sendPartMessage(p);
465
+ return;
466
+ }
467
+ if (part.type === 'tool' && part.state.status === 'completed') {
468
+ const output = part.state.output || '';
469
+ const outputTokens = Math.ceil(output.length / 4);
470
+ const largeOutputThreshold = 3000;
471
+ if (outputTokens >= largeOutputThreshold) {
472
+ const formattedTokens = outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens);
473
+ const percentageSuffix = (() => {
474
+ if (!modelContextLimit) {
475
+ return '';
565
476
  }
566
- }
567
- await showAskUserQuestionDropdowns({
568
- thread,
569
- sessionId: session.id,
570
- directory,
571
- requestId: questionRequest.id,
572
- input: { questions: questionRequest.questions },
573
- });
574
- // Process queued messages if any - queued message will cancel the pending question
575
- const queue = messageQueue.get(thread.id);
576
- if (queue && queue.length > 0) {
577
- const nextMessage = queue.shift();
578
- if (queue.length === 0) {
579
- messageQueue.delete(thread.id);
477
+ const pct = (outputTokens / modelContextLimit) * 100;
478
+ if (pct < 1) {
479
+ return '';
580
480
  }
581
- sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
582
- await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
583
- // handleOpencodeSession will call cancelPendingQuestion, which cancels the dropdown
584
- setImmediate(() => {
585
- handleOpencodeSession({
586
- prompt: nextMessage.prompt,
587
- thread,
588
- projectDirectory: directory,
589
- images: nextMessage.images,
590
- channelId,
591
- }).catch(async (e) => {
592
- sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
593
- const errorMsg = e instanceof Error ? e.message : String(e);
594
- await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`);
595
- });
596
- });
597
- }
481
+ return ` (${pct.toFixed(1)}%)`;
482
+ })();
483
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
484
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
598
485
  }
599
- else if (event.type === 'session.idle') {
600
- const idleSessionId = event.properties.sessionID;
601
- // Session is done processing - abort to signal completion
602
- if (idleSessionId === session.id) {
603
- sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
604
- abortController.abort('finished');
605
- }
606
- else if (subtaskSessions.has(idleSessionId)) {
607
- // Child session completed - clean up tracking
608
- const subtask = subtaskSessions.get(idleSessionId);
609
- sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`);
610
- subtaskSessions.delete(idleSessionId);
611
- }
486
+ }
487
+ if (part.type === 'reasoning') {
488
+ await sendPartMessage(part);
489
+ return;
490
+ }
491
+ if (part.type === 'text' && part.time?.end) {
492
+ await sendPartMessage(part);
493
+ return;
494
+ }
495
+ if (part.type === 'step-finish') {
496
+ await flushBufferedParts({
497
+ messageID: assistantMessageId || part.messageID,
498
+ force: true,
499
+ });
500
+ setTimeout(() => {
501
+ if (abortController.signal.aborted)
502
+ return;
503
+ const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
504
+ const hasPendingPermission = (pendingPermissions.get(thread.id)?.size ?? 0) > 0;
505
+ if (hasPendingQuestion || hasPendingPermission)
506
+ return;
507
+ stopTyping = startTyping();
508
+ }, 300);
509
+ }
510
+ };
511
+ const handleSubtaskPart = async (part, subtaskInfo) => {
512
+ if (part.type === 'step-start' || part.type === 'step-finish') {
513
+ return;
514
+ }
515
+ if (part.type === 'tool' && part.state.status === 'pending') {
516
+ return;
517
+ }
518
+ if (part.type === 'text') {
519
+ return;
520
+ }
521
+ if (!subtaskInfo.assistantMessageId || part.messageID !== subtaskInfo.assistantMessageId) {
522
+ return;
523
+ }
524
+ const content = formatPart(part, subtaskInfo.label);
525
+ if (!content.trim() || sentPartIds.has(part.id)) {
526
+ return;
527
+ }
528
+ const sendResult = await errore.tryAsync(() => {
529
+ return sendThreadMessage(thread, content + '\n\n');
530
+ });
531
+ if (sendResult instanceof Error) {
532
+ discordLogger.error(`ERROR: Failed to send subtask part ${part.id}:`, sendResult);
533
+ return;
534
+ }
535
+ sentPartIds.add(part.id);
536
+ getDatabase()
537
+ .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
538
+ .run(part.id, sendResult.id, thread.id);
539
+ };
540
+ const handlePartUpdated = async (part) => {
541
+ storePart(part);
542
+ const subtaskInfo = subtaskSessions.get(part.sessionID);
543
+ const isSubtaskEvent = Boolean(subtaskInfo);
544
+ if (part.sessionID !== session.id && !isSubtaskEvent) {
545
+ return;
546
+ }
547
+ if (isSubtaskEvent && subtaskInfo) {
548
+ await handleSubtaskPart(part, subtaskInfo);
549
+ return;
550
+ }
551
+ await handleMainPart(part);
552
+ };
553
+ const handleSessionError = async ({ sessionID, error, }) => {
554
+ if (!sessionID || sessionID !== session.id) {
555
+ voiceLogger.log(`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${sessionID})`);
556
+ return;
557
+ }
558
+ const errorMessage = error?.data?.message || 'Unknown error';
559
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`);
560
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`);
561
+ if (!originalMessage) {
562
+ return;
563
+ }
564
+ const reactionResult = await errore.tryAsync(async () => {
565
+ await originalMessage.reactions.removeAll();
566
+ await originalMessage.react('❌');
567
+ });
568
+ if (reactionResult instanceof Error) {
569
+ discordLogger.log(`Could not update reaction:`, reactionResult);
570
+ }
571
+ else {
572
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`);
573
+ }
574
+ };
575
+ const handlePermissionAsked = async (permission) => {
576
+ if (permission.sessionID !== session.id) {
577
+ voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
578
+ return;
579
+ }
580
+ const dedupeKey = buildPermissionDedupeKey({ permission, directory });
581
+ const threadPermissions = pendingPermissions.get(thread.id);
582
+ const existingPending = threadPermissions
583
+ ? Array.from(threadPermissions.values()).find((pending) => {
584
+ return pending.dedupeKey === dedupeKey;
585
+ })
586
+ : undefined;
587
+ if (existingPending) {
588
+ sessionLogger.log(`[PERMISSION] Deduped permission ${permission.id} (matches pending ${existingPending.permission.id})`);
589
+ if (stopTyping) {
590
+ stopTyping();
591
+ stopTyping = null;
592
+ }
593
+ if (!pendingPermissions.has(thread.id)) {
594
+ pendingPermissions.set(thread.id, new Map());
595
+ }
596
+ pendingPermissions.get(thread.id).set(permission.id, {
597
+ permission,
598
+ messageId: existingPending.messageId,
599
+ directory,
600
+ contextHash: existingPending.contextHash,
601
+ dedupeKey,
602
+ });
603
+ const added = addPermissionRequestToContext({
604
+ contextHash: existingPending.contextHash,
605
+ requestId: permission.id,
606
+ });
607
+ if (!added) {
608
+ sessionLogger.log(`[PERMISSION] Failed to attach duplicate request ${permission.id} to context`);
609
+ }
610
+ return;
611
+ }
612
+ sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
613
+ if (stopTyping) {
614
+ stopTyping();
615
+ stopTyping = null;
616
+ }
617
+ const { messageId, contextHash } = await showPermissionDropdown({
618
+ thread,
619
+ permission,
620
+ directory,
621
+ });
622
+ if (!pendingPermissions.has(thread.id)) {
623
+ pendingPermissions.set(thread.id, new Map());
624
+ }
625
+ pendingPermissions.get(thread.id).set(permission.id, {
626
+ permission,
627
+ messageId,
628
+ directory,
629
+ contextHash,
630
+ dedupeKey,
631
+ });
632
+ };
633
+ const handlePermissionReplied = ({ requestID, reply, sessionID, }) => {
634
+ if (sessionID !== session.id) {
635
+ return;
636
+ }
637
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
638
+ const threadPermissions = pendingPermissions.get(thread.id);
639
+ if (!threadPermissions) {
640
+ return;
641
+ }
642
+ const pending = threadPermissions.get(requestID);
643
+ if (!pending) {
644
+ return;
645
+ }
646
+ cleanupPermissionContext(pending.contextHash);
647
+ threadPermissions.delete(requestID);
648
+ if (threadPermissions.size === 0) {
649
+ pendingPermissions.delete(thread.id);
650
+ }
651
+ };
652
+ const handleQuestionAsked = async (questionRequest) => {
653
+ if (questionRequest.sessionID !== session.id) {
654
+ sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
655
+ return;
656
+ }
657
+ sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
658
+ if (stopTyping) {
659
+ stopTyping();
660
+ stopTyping = null;
661
+ }
662
+ await flushBufferedParts({
663
+ messageID: assistantMessageId || '',
664
+ force: true,
665
+ });
666
+ await showAskUserQuestionDropdowns({
667
+ thread,
668
+ sessionId: session.id,
669
+ directory,
670
+ requestId: questionRequest.id,
671
+ input: { questions: questionRequest.questions },
672
+ });
673
+ const queue = messageQueue.get(thread.id);
674
+ if (!queue || queue.length === 0) {
675
+ return;
676
+ }
677
+ const nextMessage = queue.shift();
678
+ if (queue.length === 0) {
679
+ messageQueue.delete(thread.id);
680
+ }
681
+ sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
682
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
683
+ setImmediate(() => {
684
+ void errore
685
+ .tryAsync(async () => {
686
+ return handleOpencodeSession({
687
+ prompt: nextMessage.prompt,
688
+ thread,
689
+ projectDirectory: directory,
690
+ images: nextMessage.images,
691
+ channelId,
692
+ });
693
+ })
694
+ .then(async (result) => {
695
+ if (!(result instanceof Error)) {
696
+ return;
697
+ }
698
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, result);
699
+ await sendThreadMessage(thread, `✗ Queued message failed: ${result.message.slice(0, 200)}`);
700
+ });
701
+ });
702
+ };
703
+ const handleSessionIdle = (idleSessionId) => {
704
+ if (idleSessionId === session.id) {
705
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
706
+ abortController.abort('finished');
707
+ return;
708
+ }
709
+ if (!subtaskSessions.has(idleSessionId)) {
710
+ return;
711
+ }
712
+ const subtask = subtaskSessions.get(idleSessionId);
713
+ sessionLogger.log(`[SUBTASK IDLE] Subtask "${subtask?.label}" completed`);
714
+ subtaskSessions.delete(idleSessionId);
715
+ };
716
+ try {
717
+ for await (const event of events) {
718
+ switch (event.type) {
719
+ case 'message.updated':
720
+ await handleMessageUpdated(event.properties.info);
721
+ break;
722
+ case 'message.part.updated':
723
+ await handlePartUpdated(event.properties.part);
724
+ break;
725
+ case 'session.error':
726
+ sessionLogger.error(`ERROR:`, event.properties);
727
+ await handleSessionError(event.properties);
728
+ break;
729
+ case 'permission.asked':
730
+ await handlePermissionAsked(event.properties);
731
+ break;
732
+ case 'permission.replied':
733
+ handlePermissionReplied(event.properties);
734
+ break;
735
+ case 'question.asked':
736
+ await handleQuestionAsked(event.properties);
737
+ break;
738
+ case 'session.idle':
739
+ handleSessionIdle(event.properties.sessionID);
740
+ break;
741
+ default:
742
+ break;
612
743
  }
613
744
  }
614
745
  }
@@ -621,14 +752,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
621
752
  throw e;
622
753
  }
623
754
  finally {
624
- for (const part of currentParts) {
625
- if (!sentPartIds.has(part.id)) {
626
- try {
755
+ abortControllers.delete(session.id);
756
+ const finalMessageId = assistantMessageId;
757
+ if (finalMessageId) {
758
+ const parts = getBufferedParts(finalMessageId);
759
+ for (const part of parts) {
760
+ if (!sentPartIds.has(part.id)) {
627
761
  await sendPartMessage(part);
628
762
  }
629
- catch (error) {
630
- sessionLogger.error(`Failed to send part ${part.id}:`, error);
631
- }
632
763
  }
633
764
  }
634
765
  if (stopTyping) {
@@ -641,12 +772,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
641
772
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
642
773
  const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
643
774
  let contextInfo = '';
644
- try {
775
+ const contextResult = await errore.tryAsync(async () => {
645
776
  // Fetch final token count from API since message.updated events can arrive
646
777
  // after session.idle due to race conditions in event ordering
647
778
  if (tokensUsedInSession === 0) {
648
779
  const messagesResponse = await getClient().session.messages({
649
780
  path: { id: session.id },
781
+ query: { directory: sdkDirectory },
650
782
  });
651
783
  const messages = messagesResponse.data || [];
652
784
  const lastAssistant = [...messages]
@@ -662,16 +794,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
662
794
  tokens.cache.write;
663
795
  }
664
796
  }
665
- const providersResponse = await getClient().provider.list({ query: { directory } });
797
+ const providersResponse = await getClient().provider.list({ query: { directory: sdkDirectory } });
666
798
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
667
799
  const model = provider?.models?.[usedModel || ''];
668
800
  if (model?.limit?.context) {
669
801
  const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
670
802
  contextInfo = ` ⋅ ${percentage}%`;
671
803
  }
672
- }
673
- catch (e) {
674
- sessionLogger.error('Failed to fetch provider info for context percentage:', e);
804
+ });
805
+ if (contextResult instanceof Error) {
806
+ sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult);
675
807
  }
676
808
  await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
677
809
  sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
@@ -707,7 +839,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
707
839
  }
708
840
  }
709
841
  };
710
- try {
842
+ const promptResult = await errore.tryAsync(async () => {
711
843
  const eventHandlerPromise = eventHandler();
712
844
  if (abortController.signal.aborted) {
713
845
  sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
@@ -715,7 +847,6 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
715
847
  }
716
848
  stopTyping = startTyping();
717
849
  voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
718
- // append image paths to prompt so ai knows where they are on disk
719
850
  const promptWithImagePaths = (() => {
720
851
  if (images.length === 0) {
721
852
  return prompt;
@@ -730,16 +861,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
730
861
  })();
731
862
  const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
732
863
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
733
- // Get agent preference: session-level overrides channel-level
734
864
  const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
735
865
  if (agentPreference) {
736
866
  sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
737
867
  }
738
- // Get model preference: session-level overrides channel-level
739
- // BUT: if an agent is set, don't pass model param so the agent's model takes effect
740
868
  const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
741
869
  const modelParam = (() => {
742
- // When an agent is set, let the agent's model config take effect
743
870
  if (agentPreference) {
744
871
  sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`);
745
872
  return undefined;
@@ -755,10 +882,18 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
755
882
  sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
756
883
  return { providerID, modelID };
757
884
  })();
758
- // Use session.command API for slash commands, session.prompt for regular messages
885
+ // Build worktree info for system message (worktreeInfo was fetched at the start)
886
+ const worktree = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
887
+ ? {
888
+ worktreeDirectory: worktreeInfo.worktree_directory,
889
+ branch: worktreeInfo.worktree_name,
890
+ mainRepoDirectory: worktreeInfo.project_directory,
891
+ }
892
+ : undefined;
759
893
  const response = command
760
894
  ? await getClient().session.command({
761
895
  path: { id: session.id },
896
+ query: { directory: sdkDirectory },
762
897
  body: {
763
898
  command: command.name,
764
899
  arguments: command.arguments,
@@ -768,9 +903,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
768
903
  })
769
904
  : await getClient().session.prompt({
770
905
  path: { id: session.id },
906
+ query: { directory: sdkDirectory },
771
907
  body: {
772
908
  parts,
773
- system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
909
+ system: getOpencodeSystemMessage({ sessionId: session.id, channelId, worktree }),
774
910
  model: modelParam,
775
911
  agent: agentPreference,
776
912
  },
@@ -794,41 +930,42 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
794
930
  abortController.abort('finished');
795
931
  sessionLogger.log(`Successfully sent prompt, got response`);
796
932
  if (originalMessage) {
797
- try {
933
+ const reactionResult = await errore.tryAsync(async () => {
798
934
  await originalMessage.reactions.removeAll();
799
935
  await originalMessage.react('✅');
800
- }
801
- catch (e) {
802
- discordLogger.log(`Could not update reactions:`, e);
936
+ });
937
+ if (reactionResult instanceof Error) {
938
+ discordLogger.log(`Could not update reactions:`, reactionResult);
803
939
  }
804
940
  }
805
941
  return { sessionID: session.id, result: response.data, port };
942
+ });
943
+ if (!errore.isError(promptResult)) {
944
+ return promptResult;
806
945
  }
807
- catch (error) {
808
- if (!isAbortError(error, abortController.signal)) {
809
- sessionLogger.error(`ERROR: Failed to send prompt:`, error);
810
- abortController.abort('error');
811
- if (originalMessage) {
812
- try {
813
- await originalMessage.reactions.removeAll();
814
- await originalMessage.react('❌');
815
- discordLogger.log(`Added error reaction to message`);
816
- }
817
- catch (e) {
818
- discordLogger.log(`Could not update reaction:`, e);
819
- }
820
- }
821
- const errorDisplay = (() => {
822
- if (error instanceof Error) {
823
- const name = error.constructor.name || 'Error';
824
- return `[${name}]\n${error.stack || error.message}`;
825
- }
826
- if (typeof error === 'string') {
827
- return error;
828
- }
829
- return String(error);
830
- })();
831
- await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
946
+ const promptError = promptResult instanceof Error ? promptResult : new Error('Unknown error');
947
+ if (isAbortError(promptError, abortController.signal)) {
948
+ return;
949
+ }
950
+ sessionLogger.error(`ERROR: Failed to send prompt:`, promptError);
951
+ abortController.abort('error');
952
+ if (originalMessage) {
953
+ const reactionResult = await errore.tryAsync(async () => {
954
+ await originalMessage.reactions.removeAll();
955
+ await originalMessage.react('❌');
956
+ });
957
+ if (reactionResult instanceof Error) {
958
+ discordLogger.log(`Could not update reaction:`, reactionResult);
959
+ }
960
+ else {
961
+ discordLogger.log(`Added error reaction to message`);
832
962
  }
833
963
  }
964
+ const errorDisplay = (() => {
965
+ const promptErrorValue = promptError;
966
+ const name = promptErrorValue.name || 'Error';
967
+ const message = promptErrorValue.stack || promptErrorValue.message;
968
+ return `[${name}]\n${message}`;
969
+ })();
970
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
834
971
  }