kimaki 0.4.46 → 0.4.48

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 (85) hide show
  1. package/dist/cli.js +69 -21
  2. package/dist/commands/abort.js +4 -2
  3. package/dist/commands/add-project.js +2 -2
  4. package/dist/commands/agent.js +4 -4
  5. package/dist/commands/ask-question.js +9 -8
  6. package/dist/commands/compact.js +126 -0
  7. package/dist/commands/create-new-project.js +60 -30
  8. package/dist/commands/fork.js +3 -3
  9. package/dist/commands/merge-worktree.js +23 -10
  10. package/dist/commands/model.js +5 -5
  11. package/dist/commands/permissions.js +5 -3
  12. package/dist/commands/queue.js +2 -2
  13. package/dist/commands/remove-project.js +2 -2
  14. package/dist/commands/resume.js +2 -2
  15. package/dist/commands/session.js +6 -3
  16. package/dist/commands/share.js +2 -2
  17. package/dist/commands/undo-redo.js +2 -2
  18. package/dist/commands/user-command.js +2 -2
  19. package/dist/commands/verbosity.js +5 -5
  20. package/dist/commands/worktree-settings.js +2 -2
  21. package/dist/commands/worktree.js +18 -8
  22. package/dist/config.js +7 -0
  23. package/dist/database.js +10 -7
  24. package/dist/discord-bot.js +30 -12
  25. package/dist/discord-utils.js +2 -2
  26. package/dist/genai-worker-wrapper.js +3 -3
  27. package/dist/genai-worker.js +2 -2
  28. package/dist/genai.js +2 -2
  29. package/dist/interaction-handler.js +6 -2
  30. package/dist/logger.js +57 -9
  31. package/dist/markdown.js +2 -2
  32. package/dist/message-formatting.js +91 -6
  33. package/dist/openai-realtime.js +2 -2
  34. package/dist/opencode.js +19 -25
  35. package/dist/session-handler.js +89 -29
  36. package/dist/system-message.js +11 -9
  37. package/dist/tools.js +3 -2
  38. package/dist/utils.js +1 -0
  39. package/dist/voice-handler.js +2 -2
  40. package/dist/voice.js +2 -2
  41. package/dist/worktree-utils.js +91 -7
  42. package/dist/xml.js +2 -2
  43. package/package.json +3 -3
  44. package/src/cli.ts +108 -21
  45. package/src/commands/abort.ts +4 -2
  46. package/src/commands/add-project.ts +2 -2
  47. package/src/commands/agent.ts +4 -4
  48. package/src/commands/ask-question.ts +9 -8
  49. package/src/commands/compact.ts +148 -0
  50. package/src/commands/create-new-project.ts +87 -36
  51. package/src/commands/fork.ts +3 -3
  52. package/src/commands/merge-worktree.ts +47 -10
  53. package/src/commands/model.ts +5 -5
  54. package/src/commands/permissions.ts +6 -2
  55. package/src/commands/queue.ts +2 -2
  56. package/src/commands/remove-project.ts +2 -2
  57. package/src/commands/resume.ts +2 -2
  58. package/src/commands/session.ts +6 -3
  59. package/src/commands/share.ts +2 -2
  60. package/src/commands/undo-redo.ts +2 -2
  61. package/src/commands/user-command.ts +2 -2
  62. package/src/commands/verbosity.ts +5 -5
  63. package/src/commands/worktree-settings.ts +2 -2
  64. package/src/commands/worktree.ts +20 -7
  65. package/src/config.ts +14 -0
  66. package/src/database.ts +13 -7
  67. package/src/discord-bot.ts +45 -12
  68. package/src/discord-utils.ts +2 -2
  69. package/src/genai-worker-wrapper.ts +3 -3
  70. package/src/genai-worker.ts +2 -2
  71. package/src/genai.ts +2 -2
  72. package/src/interaction-handler.ts +7 -2
  73. package/src/logger.ts +64 -10
  74. package/src/markdown.ts +2 -2
  75. package/src/message-formatting.ts +100 -6
  76. package/src/openai-realtime.ts +2 -2
  77. package/src/opencode.ts +19 -26
  78. package/src/session-handler.ts +102 -29
  79. package/src/system-message.ts +11 -9
  80. package/src/tools.ts +3 -2
  81. package/src/utils.ts +1 -0
  82. package/src/voice-handler.ts +2 -2
  83. package/src/voice.ts +2 -2
  84. package/src/worktree-utils.ts +111 -7
  85. package/src/xml.ts +2 -2
package/dist/logger.js CHANGED
@@ -1,11 +1,49 @@
1
- // Prefixed logging utility using @clack/prompts.
2
- // Creates loggers with consistent prefixes for different subsystems
3
- // (DISCORD, VOICE, SESSION, etc.) for easier debugging.
4
- import { log } from '@clack/prompts';
1
+ // Prefixed logging utility.
2
+ // Uses picocolors for compact frequent logs (log, info, debug).
3
+ // Uses @clack/prompts only for important events (warn, error) with visual distinction.
4
+ import { log as clackLog } from '@clack/prompts';
5
5
  import fs from 'node:fs';
6
6
  import path, { dirname } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import util from 'node:util';
9
+ import pc from 'picocolors';
10
+ // All known log prefixes - add new ones here to keep alignment consistent
11
+ export const LogPrefix = {
12
+ ABORT: 'ABORT',
13
+ ADD_PROJECT: 'ADD_PROJ',
14
+ AGENT: 'AGENT',
15
+ ASK_QUESTION: 'QUESTION',
16
+ CLI: 'CLI',
17
+ COMPACT: 'COMPACT',
18
+ CREATE_PROJECT: 'NEW_PROJ',
19
+ DB: 'DB',
20
+ DISCORD: 'DISCORD',
21
+ FORK: 'FORK',
22
+ FORMATTING: 'FORMAT',
23
+ GENAI: 'GENAI',
24
+ GENAI_WORKER: 'GENAI_W',
25
+ INTERACTION: 'INTERACT',
26
+ MARKDOWN: 'MARKDOWN',
27
+ MODEL: 'MODEL',
28
+ OPENAI: 'OPENAI',
29
+ OPENCODE: 'OPENCODE',
30
+ PERMISSIONS: 'PERMS',
31
+ QUEUE: 'QUEUE',
32
+ REMOVE_PROJECT: 'RM_PROJ',
33
+ RESUME: 'RESUME',
34
+ SESSION: 'SESSION',
35
+ SHARE: 'SHARE',
36
+ TOOLS: 'TOOLS',
37
+ UNDO_REDO: 'UNDO',
38
+ USER_CMD: 'USER_CMD',
39
+ VERBOSITY: 'VERBOSE',
40
+ VOICE: 'VOICE',
41
+ WORKER: 'WORKER',
42
+ WORKTREE: 'WORKTREE',
43
+ XML: 'XML',
44
+ };
45
+ // compute max length from all known prefixes for alignment
46
+ const MAX_PREFIX_LENGTH = Math.max(...Object.values(LogPrefix).map((p) => p.length));
9
47
  const __filename = fileURLToPath(import.meta.url);
10
48
  const __dirname = dirname(__filename);
11
49
  const isDev = !__dirname.includes('node_modules');
@@ -32,27 +70,37 @@ function writeToFile(level, prefix, args) {
32
70
  const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`;
33
71
  fs.appendFileSync(logFilePath, message);
34
72
  }
73
+ function getTimestamp() {
74
+ const now = new Date();
75
+ return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
76
+ }
77
+ function padPrefix(prefix) {
78
+ return prefix.padEnd(MAX_PREFIX_LENGTH);
79
+ }
35
80
  export function createLogger(prefix) {
81
+ const paddedPrefix = padPrefix(prefix);
36
82
  return {
37
83
  log: (...args) => {
38
84
  writeToFile('INFO', prefix, args);
39
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
85
+ console.log(pc.dim(getTimestamp()), pc.cyan(paddedPrefix), ...args.map(formatArg));
40
86
  },
41
87
  error: (...args) => {
42
88
  writeToFile('ERROR', prefix, args);
43
- log.error([`[${prefix}]`, ...args.map(formatArg)].join(' '));
89
+ // use clack for errors - visually distinct
90
+ clackLog.error([paddedPrefix, ...args.map(formatArg)].join(' '));
44
91
  },
45
92
  warn: (...args) => {
46
93
  writeToFile('WARN', prefix, args);
47
- log.warn([`[${prefix}]`, ...args.map(formatArg)].join(' '));
94
+ // use clack for warnings - visually distinct
95
+ clackLog.warn([paddedPrefix, ...args.map(formatArg)].join(' '));
48
96
  },
49
97
  info: (...args) => {
50
98
  writeToFile('INFO', prefix, args);
51
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
99
+ console.log(pc.dim(getTimestamp()), pc.blue(paddedPrefix), ...args.map(formatArg));
52
100
  },
53
101
  debug: (...args) => {
54
102
  writeToFile('DEBUG', prefix, args);
55
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
103
+ console.log(pc.dim(getTimestamp()), pc.dim(paddedPrefix), ...args.map(formatArg));
56
104
  },
57
105
  };
58
106
  }
package/dist/markdown.js CHANGED
@@ -7,7 +7,7 @@ import { createTaggedError } from 'errore';
7
7
  import * as yaml from 'js-yaml';
8
8
  import { formatDateTime } from './utils.js';
9
9
  import { extractNonXmlContent } from './xml.js';
10
- import { createLogger } from './logger.js';
10
+ import { createLogger, LogPrefix } from './logger.js';
11
11
  import { SessionNotFoundError, MessagesNotFoundError } from './errors.js';
12
12
  // Generic error for unexpected exceptions in async operations
13
13
  class UnexpectedError extends createTaggedError({
@@ -15,7 +15,7 @@ class UnexpectedError extends createTaggedError({
15
15
  message: '$message',
16
16
  }) {
17
17
  }
18
- const markdownLogger = createLogger('MARKDOWN');
18
+ const markdownLogger = createLogger(LogPrefix.MARKDOWN);
19
19
  export class ShareMarkdown {
20
20
  client;
21
21
  constructor(client) {
@@ -4,10 +4,10 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import * as errore from 'errore';
7
- import { createLogger } from './logger.js';
7
+ import { createLogger, LogPrefix } from './logger.js';
8
8
  import { FetchError } from './errors.js';
9
9
  const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments');
10
- const logger = createLogger('FORMATTING');
10
+ const logger = createLogger(LogPrefix.FORMATTING);
11
11
  /**
12
12
  * Escapes Discord inline markdown characters so dynamic content
13
13
  * doesn't break formatting when wrapped in *, _, **, etc.
@@ -15,6 +15,73 @@ const logger = createLogger('FORMATTING');
15
15
  function escapeInlineMarkdown(text) {
16
16
  return text.replace(/([*_~|`\\])/g, '\\$1');
17
17
  }
18
+ /**
19
+ * Parses a patchText string (apply_patch format) and counts additions/deletions per file.
20
+ * Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
21
+ * with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
22
+ */
23
+ function parsePatchCounts(patchText) {
24
+ const counts = new Map();
25
+ const lines = patchText.split('\n');
26
+ let currentFile = '';
27
+ let currentType = '';
28
+ let inHunk = false;
29
+ for (const line of lines) {
30
+ const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/);
31
+ const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/);
32
+ const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/);
33
+ if (addMatch || updateMatch || deleteMatch) {
34
+ const match = addMatch || updateMatch || deleteMatch;
35
+ currentFile = (match?.[1] ?? '').trim();
36
+ currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete';
37
+ counts.set(currentFile, { additions: 0, deletions: 0 });
38
+ inHunk = false;
39
+ continue;
40
+ }
41
+ if (line.startsWith('@@')) {
42
+ inHunk = true;
43
+ continue;
44
+ }
45
+ if (line.startsWith('*** ')) {
46
+ inHunk = false;
47
+ continue;
48
+ }
49
+ if (!currentFile) {
50
+ continue;
51
+ }
52
+ const entry = counts.get(currentFile);
53
+ if (!entry) {
54
+ continue;
55
+ }
56
+ if (currentType === 'add') {
57
+ // all content lines in Add File are additions
58
+ if (line.length > 0 && !line.startsWith('*** ')) {
59
+ entry.additions++;
60
+ }
61
+ }
62
+ else if (currentType === 'delete') {
63
+ // all content lines in Delete File are deletions
64
+ if (line.length > 0 && !line.startsWith('*** ')) {
65
+ entry.deletions++;
66
+ }
67
+ }
68
+ else if (inHunk) {
69
+ if (line.startsWith('+')) {
70
+ entry.additions++;
71
+ }
72
+ else if (line.startsWith('-')) {
73
+ entry.deletions++;
74
+ }
75
+ }
76
+ }
77
+ return counts;
78
+ }
79
+ /**
80
+ * Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
81
+ */
82
+ function normalizeWhitespace(text) {
83
+ return text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ');
84
+ }
18
85
  /**
19
86
  * Collects and formats the last N assistant parts from session messages.
20
87
  * Used by both /resume and /fork to show recent assistant context.
@@ -125,6 +192,22 @@ export function getToolSummaryText(part) {
125
192
  ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})`
126
193
  : `(+${added}-${removed})`;
127
194
  }
195
+ if (part.tool === 'apply_patch') {
196
+ // Only inputs are available when parts are sent during streaming (output/metadata not yet populated)
197
+ const patchText = part.state.input?.patchText || '';
198
+ if (!patchText) {
199
+ return '';
200
+ }
201
+ const patchCounts = parsePatchCounts(patchText);
202
+ return [...patchCounts.entries()]
203
+ .map(([filePath, { additions, deletions }]) => {
204
+ const fileName = filePath.split('/').pop() || '';
205
+ return fileName
206
+ ? `*${escapeInlineMarkdown(fileName)}* (+${additions}-${deletions})`
207
+ : `(+${additions}-${deletions})`;
208
+ })
209
+ .join(', ');
210
+ }
128
211
  if (part.tool === 'write') {
129
212
  const filePath = part.state.input?.filePath || '';
130
213
  const content = part.state.input?.content || '';
@@ -175,7 +258,8 @@ export function getToolSummaryText(part) {
175
258
  if (value === null || value === undefined)
176
259
  return null;
177
260
  const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
178
- const truncatedValue = stringValue.length > 50 ? stringValue.slice(0, 50) + '…' : stringValue;
261
+ const normalized = normalizeWhitespace(stringValue);
262
+ const truncatedValue = normalized.length > 50 ? normalized.slice(0, 50) + '…' : normalized;
179
263
  return `${key}: ${truncatedValue}`;
180
264
  })
181
265
  .filter(Boolean);
@@ -201,7 +285,7 @@ export function formatTodoList(part) {
201
285
  return `${num} **${escapeInlineMarkdown(content)}**`;
202
286
  }
203
287
  export function formatPart(part, prefix) {
204
- const pfx = prefix ? `${prefix}: ` : '';
288
+ const pfx = prefix ? `${prefix} ` : '';
205
289
  if (part.type === 'text') {
206
290
  if (!part.text?.trim())
207
291
  return '';
@@ -278,12 +362,13 @@ export function formatPart(part, prefix) {
278
362
  if (part.state.status === 'error') {
279
363
  return '⨯';
280
364
  }
281
- if (part.tool === 'edit' || part.tool === 'write') {
365
+ if (part.tool === 'edit' || part.tool === 'write' || part.tool === 'apply_patch') {
282
366
  return '◼︎';
283
367
  }
284
368
  return '┣';
285
369
  })();
286
- return `${icon} ${pfx}${part.tool} ${toolTitle} ${summaryText}`.trim();
370
+ const toolParts = [part.tool, toolTitle, summaryText].filter(Boolean).join(' ');
371
+ return `${icon} ${pfx}${toolParts}`;
287
372
  }
288
373
  logger.warn('Unknown part type:', part);
289
374
  return '';
@@ -3,8 +3,8 @@
3
3
  // @ts-nocheck
4
4
  import { RealtimeClient } from '@openai/realtime-api-beta';
5
5
  import { writeFile } from 'fs';
6
- import { createLogger } from './logger.js';
7
- const openaiLogger = createLogger('OPENAI');
6
+ import { createLogger, LogPrefix } from './logger.js';
7
+ const openaiLogger = createLogger(LogPrefix.OPENAI);
8
8
  const audioParts = [];
9
9
  function saveBinaryFile(fileName, content) {
10
10
  writeFile(fileName, content, 'utf8', (err) => {
package/dist/opencode.js CHANGED
@@ -8,9 +8,9 @@ import net from 'node:net';
8
8
  import { createOpencodeClient } from '@opencode-ai/sdk';
9
9
  import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
10
10
  import * as errore from 'errore';
11
- import { createLogger } from './logger.js';
11
+ import { createLogger, LogPrefix } from './logger.js';
12
12
  import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
13
- const opencodeLogger = createLogger('OPENCODE');
13
+ const opencodeLogger = createLogger(LogPrefix.OPENCODE);
14
14
  const opencodeServers = new Map();
15
15
  const serverRetryCount = new Map();
16
16
  async function getOpenPort() {
@@ -32,30 +32,24 @@ async function getOpenPort() {
32
32
  });
33
33
  }
34
34
  async function waitForServer(port, maxAttempts = 30) {
35
+ const endpoint = `http://127.0.0.1:${port}/api/health`;
35
36
  for (let i = 0; i < maxAttempts; i++) {
36
- const endpoints = [
37
- `http://127.0.0.1:${port}/api/health`,
38
- `http://127.0.0.1:${port}/`,
39
- `http://127.0.0.1:${port}/api`,
40
- ];
41
- for (const endpoint of endpoints) {
42
- const response = await errore.tryAsync({
43
- try: () => fetch(endpoint),
44
- catch: (e) => new FetchError({ url: endpoint, cause: e }),
45
- });
46
- if (response instanceof Error) {
47
- // Connection refused or other transient errors - continue polling
48
- opencodeLogger.debug(`Server polling attempt failed: ${response.message}`);
49
- continue;
50
- }
51
- if (response.status < 500) {
52
- return true;
53
- }
54
- const body = await response.text();
55
- // Fatal errors that won't resolve with retrying
56
- if (body.includes('BunInstallFailedError')) {
57
- return new ServerStartError({ port, reason: body.slice(0, 200) });
58
- }
37
+ const response = await errore.tryAsync({
38
+ try: () => fetch(endpoint),
39
+ catch: (e) => new FetchError({ url: endpoint, cause: e }),
40
+ });
41
+ if (response instanceof Error) {
42
+ // Connection refused or other transient errors - continue polling
43
+ await new Promise((resolve) => setTimeout(resolve, 1000));
44
+ continue;
45
+ }
46
+ if (response.status < 500) {
47
+ return true;
48
+ }
49
+ const body = await response.text();
50
+ // Fatal errors that won't resolve with retrying
51
+ if (body.includes('BunInstallFailedError')) {
52
+ return new ServerStartError({ port, reason: body.slice(0, 200) });
59
53
  }
60
54
  await new Promise((resolve) => setTimeout(resolve, 1000));
61
55
  }
@@ -7,14 +7,14 @@ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2
7
7
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
8
8
  import { formatPart } from './message-formatting.js';
9
9
  import { getOpencodeSystemMessage } from './system-message.js';
10
- import { createLogger } from './logger.js';
10
+ import { createLogger, LogPrefix } from './logger.js';
11
11
  import { isAbortError } from './utils.js';
12
12
  import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
13
13
  import { showPermissionDropdown, cleanupPermissionContext, addPermissionRequestToContext, } from './commands/permissions.js';
14
14
  import * as errore from 'errore';
15
- const sessionLogger = createLogger('SESSION');
16
- const voiceLogger = createLogger('VOICE');
17
- const discordLogger = createLogger('DISCORD');
15
+ const sessionLogger = createLogger(LogPrefix.SESSION);
16
+ const voiceLogger = createLogger(LogPrefix.VOICE);
17
+ const discordLogger = createLogger(LogPrefix.DISCORD);
18
18
  export const abortControllers = new Map();
19
19
  // Track multiple pending permissions per thread (keyed by permission ID)
20
20
  // OpenCode handles blocking/sequencing - we just need to track all pending permissions
@@ -56,18 +56,20 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
56
56
  }
57
57
  sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
58
58
  // Abort with special reason so we don't show "completed" message
59
- controller.abort('model-change');
59
+ sessionLogger.log(`[ABORT] reason=model-change sessionId=${sessionId} - user changed model mid-request, will retry with new model`);
60
+ controller.abort(new Error('model-change'));
60
61
  // Also call the API abort endpoint
61
62
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
62
63
  if (getClient instanceof Error) {
63
64
  sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message);
64
65
  return false;
65
66
  }
67
+ sessionLogger.log(`[ABORT-API] reason=model-change sessionId=${sessionId} - sending API abort for model change retry`);
66
68
  const abortResult = await errore.tryAsync(() => {
67
69
  return getClient().session.abort({ path: { id: sessionId } });
68
70
  });
69
71
  if (abortResult instanceof Error) {
70
- sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, abortResult);
72
+ sessionLogger.log(`[ABORT-API] API abort call failed (may already be done):`, abortResult);
71
73
  }
72
74
  // Small delay to let the abort propagate
73
75
  await new Promise((resolve) => {
@@ -176,7 +178,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
176
178
  const existingController = abortControllers.get(session.id);
177
179
  if (existingController) {
178
180
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
181
+ sessionLogger.log(`[ABORT] reason=new-request sessionId=${session.id} threadId=${thread.id} - new user message arrived while previous request was still running`);
179
182
  existingController.abort(new Error('New request started'));
183
+ sessionLogger.log(`[ABORT-API] reason=new-request sessionId=${session.id} - sending API abort because new message arrived`);
180
184
  const abortResult = await errore.tryAsync(() => {
181
185
  return getClient().session.abort({
182
186
  path: { id: session.id },
@@ -184,7 +188,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
184
188
  });
185
189
  });
186
190
  if (abortResult instanceof Error) {
187
- sessionLogger.log(`[ABORT] Server abort failed (may be already done):`, abortResult);
191
+ sessionLogger.log(`[ABORT-API] Server abort failed (may be already done):`, abortResult);
188
192
  }
189
193
  }
190
194
  // Auto-reject ALL pending permissions for this thread
@@ -220,10 +224,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
220
224
  await sendThreadMessage(thread, `⚠️ ${rejectedCount} pending permission request${plural} auto-rejected due to new message`);
221
225
  }
222
226
  }
223
- // Cancel any pending question tool if user sends a new message (silently, no thread message)
224
- const questionCancelled = await cancelPendingQuestion(thread.id);
225
- if (questionCancelled) {
226
- sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`);
227
+ // Answer any pending question tool with the user's message (silently, no thread message)
228
+ const questionAnswered = await cancelPendingQuestion(thread.id, prompt);
229
+ if (questionAnswered) {
230
+ sessionLogger.log(`[QUESTION] Answered pending question with user message`);
227
231
  }
228
232
  const abortController = new AbortController();
229
233
  abortControllers.set(session.id, abortController);
@@ -272,10 +276,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
272
276
  let usedAgent;
273
277
  let tokensUsedInSession = 0;
274
278
  let lastDisplayedContextPercentage = 0;
279
+ let lastRateLimitDisplayTime = 0;
275
280
  let modelContextLimit;
276
281
  let assistantMessageId;
277
282
  let handlerPromise = null;
278
283
  let typingInterval = null;
284
+ let hasSentParts = false;
285
+ let promptResolved = false;
286
+ let hasReceivedEvent = false;
279
287
  function startTyping() {
280
288
  if (abortController.signal.aborted) {
281
289
  discordLogger.log(`Not starting typing, already aborted`);
@@ -312,12 +320,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
312
320
  }
313
321
  };
314
322
  }
315
- // Get verbosity setting for this channel (use parent channel for threads)
323
+ // Read verbosity dynamically so mid-session /verbosity changes take effect immediately
316
324
  const verbosityChannelId = channelId || thread.parentId || thread.id;
317
- const verbosity = getChannelVerbosity(verbosityChannelId);
325
+ const getVerbosity = () => {
326
+ return getChannelVerbosity(verbosityChannelId);
327
+ };
318
328
  const sendPartMessage = async (part) => {
319
329
  // In text-only mode, only send text parts (the ⬥ diamond messages)
320
- if (verbosity === 'text-only' && part.type !== 'text') {
330
+ if (getVerbosity() === 'text-only' && part.type !== 'text') {
321
331
  return;
322
332
  }
323
333
  const content = formatPart(part) + '\n\n';
@@ -335,6 +345,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
335
345
  discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult);
336
346
  return;
337
347
  }
348
+ hasSentParts = true;
338
349
  sentPartIds.add(part.id);
339
350
  getDatabase()
340
351
  .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
@@ -391,6 +402,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
391
402
  if (msg.sessionID !== session.id) {
392
403
  return;
393
404
  }
405
+ hasReceivedEvent = true;
394
406
  if (msg.role !== 'assistant') {
395
407
  return;
396
408
  }
@@ -476,7 +488,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
476
488
  const label = `${agent}-${agentSpawnCounts[agent]}`;
477
489
  subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined });
478
490
  // Skip task messages in text-only mode
479
- if (verbosity !== 'text-only') {
491
+ if (getVerbosity() !== 'text-only') {
480
492
  const taskDisplay = `┣ task **${label}** _${description}_`;
481
493
  await sendThreadMessage(thread, taskDisplay + '\n\n');
482
494
  }
@@ -485,7 +497,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
485
497
  }
486
498
  return;
487
499
  }
488
- if (part.type === 'tool' && part.state.status === 'completed') {
500
+ if (part.type === 'tool' && part.state.status === 'completed' && getVerbosity() !== 'text-only') {
489
501
  const output = part.state.output || '';
490
502
  const outputTokens = Math.ceil(output.length / 4);
491
503
  const largeOutputThreshold = 3000;
@@ -531,7 +543,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
531
543
  };
532
544
  const handleSubtaskPart = async (part, subtaskInfo) => {
533
545
  // In text-only mode, skip all subtask output (they're tool-related)
534
- if (verbosity === 'text-only') {
546
+ if (getVerbosity() === 'text-only') {
535
547
  return;
536
548
  }
537
549
  if (part.type === 'step-start' || part.type === 'step-finish') {
@@ -598,10 +610,15 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
598
610
  }
599
611
  };
600
612
  const handlePermissionAsked = async (permission) => {
601
- if (permission.sessionID !== session.id) {
602
- voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
613
+ const isMainSession = permission.sessionID === session.id;
614
+ const isSubtaskSession = subtaskSessions.has(permission.sessionID);
615
+ if (!isMainSession && !isSubtaskSession) {
616
+ voiceLogger.log(`[PERMISSION IGNORED] Permission for unknown session (expected: ${session.id} or subtask, got: ${permission.sessionID})`);
603
617
  return;
604
618
  }
619
+ const subtaskLabel = isSubtaskSession
620
+ ? subtaskSessions.get(permission.sessionID)?.label
621
+ : undefined;
605
622
  const dedupeKey = buildPermissionDedupeKey({ permission, directory });
606
623
  const threadPermissions = pendingPermissions.get(thread.id);
607
624
  const existingPending = threadPermissions
@@ -634,7 +651,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
634
651
  }
635
652
  return;
636
653
  }
637
- sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
654
+ sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`);
638
655
  if (stopTyping) {
639
656
  stopTyping();
640
657
  stopTyping = null;
@@ -643,6 +660,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
643
660
  thread,
644
661
  permission,
645
662
  directory,
663
+ subtaskLabel,
646
664
  });
647
665
  if (!pendingPermissions.has(thread.id)) {
648
666
  pendingPermissions.set(thread.id, new Map());
@@ -656,7 +674,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
656
674
  });
657
675
  };
658
676
  const handlePermissionReplied = ({ requestID, reply, sessionID, }) => {
659
- if (sessionID !== session.id) {
677
+ const isMainSession = sessionID === session.id;
678
+ const isSubtaskSession = subtaskSessions.has(sessionID);
679
+ if (!isMainSession && !isSubtaskSession) {
660
680
  return;
661
681
  }
662
682
  sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
@@ -706,10 +726,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
706
726
  sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
707
727
  await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
708
728
  setImmediate(() => {
729
+ const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`;
709
730
  void errore
710
731
  .tryAsync(async () => {
711
732
  return handleOpencodeSession({
712
- prompt: nextMessage.prompt,
733
+ prompt: prefixedPrompt,
713
734
  thread,
714
735
  projectDirectory: directory,
715
736
  images: nextMessage.images,
@@ -725,10 +746,42 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
725
746
  });
726
747
  });
727
748
  };
749
+ const handleSessionStatus = async (properties) => {
750
+ if (properties.sessionID !== session.id) {
751
+ return;
752
+ }
753
+ if (properties.status.type !== 'retry') {
754
+ return;
755
+ }
756
+ // Throttle to once per 10 seconds
757
+ const now = Date.now();
758
+ if (now - lastRateLimitDisplayTime < 10_000) {
759
+ return;
760
+ }
761
+ lastRateLimitDisplayTime = now;
762
+ const { attempt, message, next } = properties.status;
763
+ const remainingMs = Math.max(0, next - now);
764
+ const remainingSec = Math.ceil(remainingMs / 1000);
765
+ const duration = (() => {
766
+ if (remainingSec < 60) {
767
+ return `${remainingSec}s`;
768
+ }
769
+ const mins = Math.floor(remainingSec / 60);
770
+ const secs = remainingSec % 60;
771
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
772
+ })();
773
+ const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`;
774
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
775
+ };
728
776
  const handleSessionIdle = (idleSessionId) => {
729
777
  if (idleSessionId === session.id) {
730
- sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
731
- abortController.abort('finished');
778
+ if (!promptResolved || !hasReceivedEvent) {
779
+ sessionLogger.log(`[SESSION IDLE] Ignoring idle event for ${session.id} (prompt not resolved or no events yet)`);
780
+ return;
781
+ }
782
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, ending stream`);
783
+ sessionLogger.log(`[ABORT] reason=finished sessionId=${session.id} threadId=${thread.id} - session completed normally, received idle event after prompt resolved`);
784
+ abortController.abort(new Error('finished'));
732
785
  return;
733
786
  }
734
787
  if (!subtaskSessions.has(idleSessionId)) {
@@ -763,6 +816,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
763
816
  case 'session.idle':
764
817
  handleSessionIdle(event.properties.sessionID);
765
818
  break;
819
+ case 'session.status':
820
+ await handleSessionStatus(event.properties);
821
+ break;
766
822
  default:
767
823
  break;
768
824
  }
@@ -791,7 +847,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
791
847
  stopTyping();
792
848
  stopTyping = null;
793
849
  }
794
- if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
850
+ const abortReason = abortController.signal.reason?.message;
851
+ if (!abortController.signal.aborted || abortReason === 'finished') {
795
852
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
796
853
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
797
854
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
@@ -845,8 +902,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
845
902
  // Send the queued message as a new prompt (recursive call)
846
903
  // Use setImmediate to avoid blocking and allow this finally to complete
847
904
  setImmediate(() => {
905
+ const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`;
848
906
  handleOpencodeSession({
849
- prompt: nextMessage.prompt,
907
+ prompt: prefixedPrompt,
850
908
  thread,
851
909
  projectDirectory,
852
910
  images: nextMessage.images,
@@ -860,7 +918,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
860
918
  }
861
919
  }
862
920
  else {
863
- sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
921
+ sessionLogger.log(`Session was aborted (reason: ${abortReason}), skipping duration message`);
864
922
  }
865
923
  }
866
924
  };
@@ -921,6 +979,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
921
979
  mainRepoDirectory: worktreeInfo.project_directory,
922
980
  }
923
981
  : undefined;
982
+ hasSentParts = false;
924
983
  const response = command
925
984
  ? await getClient().session.command({
926
985
  path: { id: session.id },
@@ -958,7 +1017,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
958
1017
  })();
959
1018
  throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
960
1019
  }
961
- abortController.abort('finished');
1020
+ promptResolved = true;
962
1021
  sessionLogger.log(`Successfully sent prompt, got response`);
963
1022
  if (originalMessage) {
964
1023
  const reactionResult = await errore.tryAsync(async () => {
@@ -987,7 +1046,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
987
1046
  return;
988
1047
  }
989
1048
  sessionLogger.error(`ERROR: Failed to send prompt:`, promptError);
990
- abortController.abort('error');
1049
+ sessionLogger.log(`[ABORT] reason=error sessionId=${session.id} threadId=${thread.id} - prompt failed with error: ${promptError.message}`);
1050
+ abortController.abort(new Error('error'));
991
1051
  if (originalMessage) {
992
1052
  const reactionResult = await errore.tryAsync(async () => {
993
1053
  await originalMessage.reactions.removeAll();