kimaki 0.4.45 → 0.4.47

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 (79) hide show
  1. package/dist/cli.js +27 -2
  2. package/dist/commands/abort.js +2 -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 +5 -3
  8. package/dist/commands/fork.js +5 -3
  9. package/dist/commands/merge-worktree.js +2 -2
  10. package/dist/commands/model.js +5 -5
  11. package/dist/commands/permissions.js +2 -2
  12. package/dist/commands/queue.js +2 -2
  13. package/dist/commands/remove-project.js +2 -2
  14. package/dist/commands/resume.js +4 -2
  15. package/dist/commands/session.js +4 -2
  16. package/dist/commands/share.js +2 -2
  17. package/dist/commands/undo-redo.js +2 -2
  18. package/dist/commands/user-command.js +4 -2
  19. package/dist/commands/verbosity.js +3 -3
  20. package/dist/commands/worktree-settings.js +2 -2
  21. package/dist/commands/worktree.js +20 -8
  22. package/dist/database.js +2 -2
  23. package/dist/discord-bot.js +5 -3
  24. package/dist/discord-utils.js +2 -2
  25. package/dist/genai-worker-wrapper.js +3 -3
  26. package/dist/genai-worker.js +2 -2
  27. package/dist/genai.js +2 -2
  28. package/dist/interaction-handler.js +6 -2
  29. package/dist/logger.js +57 -9
  30. package/dist/markdown.js +2 -2
  31. package/dist/message-formatting.js +69 -6
  32. package/dist/openai-realtime.js +2 -2
  33. package/dist/opencode.js +2 -2
  34. package/dist/session-handler.js +93 -15
  35. package/dist/tools.js +2 -2
  36. package/dist/voice-handler.js +2 -2
  37. package/dist/voice.js +2 -2
  38. package/dist/worktree-utils.js +91 -7
  39. package/dist/xml.js +2 -2
  40. package/package.json +1 -1
  41. package/src/cli.ts +28 -2
  42. package/src/commands/abort.ts +2 -2
  43. package/src/commands/add-project.ts +2 -2
  44. package/src/commands/agent.ts +4 -4
  45. package/src/commands/ask-question.ts +9 -8
  46. package/src/commands/compact.ts +148 -0
  47. package/src/commands/create-new-project.ts +6 -3
  48. package/src/commands/fork.ts +6 -3
  49. package/src/commands/merge-worktree.ts +2 -2
  50. package/src/commands/model.ts +5 -5
  51. package/src/commands/permissions.ts +2 -2
  52. package/src/commands/queue.ts +2 -2
  53. package/src/commands/remove-project.ts +2 -2
  54. package/src/commands/resume.ts +5 -2
  55. package/src/commands/session.ts +5 -2
  56. package/src/commands/share.ts +2 -2
  57. package/src/commands/undo-redo.ts +2 -2
  58. package/src/commands/user-command.ts +5 -2
  59. package/src/commands/verbosity.ts +3 -3
  60. package/src/commands/worktree-settings.ts +2 -2
  61. package/src/commands/worktree.ts +23 -7
  62. package/src/database.ts +2 -2
  63. package/src/discord-bot.ts +6 -3
  64. package/src/discord-utils.ts +2 -2
  65. package/src/genai-worker-wrapper.ts +3 -3
  66. package/src/genai-worker.ts +2 -2
  67. package/src/genai.ts +2 -2
  68. package/src/interaction-handler.ts +7 -2
  69. package/src/logger.ts +64 -10
  70. package/src/markdown.ts +2 -2
  71. package/src/message-formatting.ts +82 -6
  72. package/src/openai-realtime.ts +2 -2
  73. package/src/opencode.ts +2 -2
  74. package/src/session-handler.ts +105 -15
  75. package/src/tools.ts +2 -2
  76. package/src/voice-handler.ts +2 -2
  77. package/src/voice.ts +2 -2
  78. package/src/worktree-utils.ts +111 -7
  79. package/src/xml.ts +2 -2
package/dist/database.js CHANGED
@@ -5,9 +5,9 @@ import Database from 'better-sqlite3';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import * as errore from 'errore';
8
- import { createLogger } from './logger.js';
8
+ import { createLogger, LogPrefix } from './logger.js';
9
9
  import { getDataDir } from './config.js';
10
- const dbLogger = createLogger('DB');
10
+ const dbLogger = createLogger(LogPrefix.DB);
11
11
  let db = null;
12
12
  export function getDatabase() {
13
13
  if (!db) {
@@ -22,14 +22,14 @@ export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels,
22
22
  import { ChannelType, Client, Events, GatewayIntentBits, Partials, PermissionsBitField, ThreadAutoArchiveDuration, } from 'discord.js';
23
23
  import fs from 'node:fs';
24
24
  import * as errore from 'errore';
25
- import { createLogger } from './logger.js';
25
+ import { createLogger, LogPrefix } from './logger.js';
26
26
  import { setGlobalDispatcher, Agent } from 'undici';
27
27
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
28
28
  // Each session's event.subscribe() holds a connection; without enough connections,
29
29
  // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
30
30
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }));
31
- const discordLogger = createLogger('DISCORD');
32
- const voiceLogger = createLogger('VOICE');
31
+ const discordLogger = createLogger(LogPrefix.DISCORD);
32
+ const voiceLogger = createLogger(LogPrefix.VOICE);
33
33
  export async function createDiscordClient() {
34
34
  return new Client({
35
35
  intents: [
@@ -309,6 +309,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
309
309
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
310
310
  reason: 'Start Claude session',
311
311
  });
312
+ // Add user to thread so it appears in their sidebar
313
+ await thread.members.add(message.author.id);
312
314
  discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
313
315
  // Create worktree if worktrees are enabled (CLI flag OR channel setting)
314
316
  let sessionDirectory = projectDirectory;
@@ -7,11 +7,11 @@ import { formatMarkdownTables } from './format-tables.js';
7
7
  import { getChannelDirectory } from './database.js';
8
8
  import { limitHeadingDepth } from './limit-heading-depth.js';
9
9
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
10
- import { createLogger } from './logger.js';
10
+ import { createLogger, LogPrefix } from './logger.js';
11
11
  import mime from 'mime';
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
- const discordLogger = createLogger('DISCORD');
14
+ const discordLogger = createLogger(LogPrefix.DISCORD);
15
15
  export const SILENT_MESSAGE_FLAGS = 4 | 4096;
16
16
  // Same as SILENT but without SuppressNotifications - triggers badge/notification
17
17
  export const NOTIFY_MESSAGE_FLAGS = 4;
@@ -2,9 +2,9 @@
2
2
  // Spawns and manages the worker thread, handling message passing for
3
3
  // audio input/output, tool call completions, and graceful shutdown.
4
4
  import { Worker } from 'node:worker_threads';
5
- import { createLogger } from './logger.js';
6
- const genaiWorkerLogger = createLogger('GENAI WORKER');
7
- const genaiWrapperLogger = createLogger('GENAI WORKER WRAPPER');
5
+ import { createLogger, LogPrefix } from './logger.js';
6
+ const genaiWorkerLogger = createLogger(LogPrefix.GENAI_WORKER);
7
+ const genaiWrapperLogger = createLogger(LogPrefix.GENAI_WORKER);
8
8
  export function createGenAIWorker(options) {
9
9
  return new Promise((resolve, reject) => {
10
10
  const worker = new Worker(new URL('../dist/genai-worker.js', import.meta.url));
@@ -10,11 +10,11 @@ import * as prism from 'prism-media';
10
10
  import { startGenAiSession } from './genai.js';
11
11
  import { getTools } from './tools.js';
12
12
  import { mkdir } from 'node:fs/promises';
13
- import { createLogger } from './logger.js';
13
+ import { createLogger, LogPrefix } from './logger.js';
14
14
  if (!parentPort) {
15
15
  throw new Error('This module must be run as a worker thread');
16
16
  }
17
- const workerLogger = createLogger(`WORKER ${threadId}`);
17
+ const workerLogger = createLogger(`${LogPrefix.WORKER}_${threadId}`);
18
18
  workerLogger.log('GenAI worker started');
19
19
  // Define sendError early so it can be used by global handlers
20
20
  function sendError(error) {
package/dist/genai.js CHANGED
@@ -3,9 +3,9 @@
3
3
  // and manages the assistant's audio output for Discord voice channels.
4
4
  import { GoogleGenAI, LiveServerMessage, MediaResolution, Modality, Session } from '@google/genai';
5
5
  import { writeFile } from 'fs';
6
- import { createLogger } from './logger.js';
6
+ import { createLogger, LogPrefix } from './logger.js';
7
7
  import { aiToolToCallableTool } from './ai-tool-to-genai.js';
8
- const genaiLogger = createLogger('GENAI');
8
+ const genaiLogger = createLogger(LogPrefix.GENAI);
9
9
  const audioParts = [];
10
10
  function saveBinaryFile(fileName, content) {
11
11
  writeFile(fileName, content, 'utf8', (err) => {
@@ -12,6 +12,7 @@ import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './
12
12
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js';
13
13
  import { handlePermissionSelectMenu } from './commands/permissions.js';
14
14
  import { handleAbortCommand } from './commands/abort.js';
15
+ import { handleCompactCommand } from './commands/compact.js';
15
16
  import { handleShareCommand } from './commands/share.js';
16
17
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
17
18
  import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, } from './commands/model.js';
@@ -21,8 +22,8 @@ import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js
21
22
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
22
23
  import { handleUserCommand } from './commands/user-command.js';
23
24
  import { handleVerbosityCommand } from './commands/verbosity.js';
24
- import { createLogger } from './logger.js';
25
- const interactionLogger = createLogger('INTERACTION');
25
+ import { createLogger, LogPrefix } from './logger.js';
26
+ const interactionLogger = createLogger(LogPrefix.INTERACTION);
26
27
  export function registerInteractionHandler({ discordClient, appId, }) {
27
28
  interactionLogger.log('[REGISTER] Interaction handler registered');
28
29
  discordClient.on(Events.InteractionCreate, async (interaction) => {
@@ -85,6 +86,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
85
86
  case 'stop':
86
87
  await handleAbortCommand({ command: interaction, appId });
87
88
  return;
89
+ case 'compact':
90
+ await handleCompactCommand({ command: interaction, appId });
91
+ return;
88
92
  case 'share':
89
93
  await handleShareCommand({ command: interaction, appId });
90
94
  return;
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,12 @@ const logger = createLogger('FORMATTING');
15
15
  function escapeInlineMarkdown(text) {
16
16
  return text.replace(/([*_~|`\\])/g, '\\$1');
17
17
  }
18
+ /**
19
+ * Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
20
+ */
21
+ function normalizeWhitespace(text) {
22
+ return text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ');
23
+ }
18
24
  /**
19
25
  * Collects and formats the last N assistant parts from session messages.
20
26
  * Used by both /resume and /fork to show recent assistant context.
@@ -125,6 +131,61 @@ export function getToolSummaryText(part) {
125
131
  ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})`
126
132
  : `(+${added}-${removed})`;
127
133
  }
134
+ if (part.tool === 'apply_patch') {
135
+ const state = part.state;
136
+ const rawFiles = state.metadata?.files;
137
+ const partMetaFiles = part.metadata?.files;
138
+ const filesList = Array.isArray(rawFiles)
139
+ ? rawFiles
140
+ : Array.isArray(partMetaFiles)
141
+ ? partMetaFiles
142
+ : [];
143
+ const summarizeFiles = (files) => {
144
+ const summarized = files
145
+ .map((f) => {
146
+ if (!f) {
147
+ return null;
148
+ }
149
+ if (typeof f === 'string') {
150
+ const fileName = f.split('/').pop() || '';
151
+ return fileName ? `*${escapeInlineMarkdown(fileName)}* (+0-0)` : `(+0-0)`;
152
+ }
153
+ if (typeof f !== 'object') {
154
+ return null;
155
+ }
156
+ const file = f;
157
+ const pathStr = String(file.relativePath || file.filePath || file.path || '');
158
+ const fileName = pathStr.split('/').pop() || '';
159
+ const added = typeof file.additions === 'number' ? file.additions : 0;
160
+ const removed = typeof file.deletions === 'number' ? file.deletions : 0;
161
+ return fileName
162
+ ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})`
163
+ : `(+${added}-${removed})`;
164
+ })
165
+ .filter(Boolean)
166
+ .join(', ');
167
+ return summarized;
168
+ };
169
+ if (filesList.length > 0) {
170
+ const summarized = summarizeFiles(filesList);
171
+ if (summarized) {
172
+ return summarized;
173
+ }
174
+ }
175
+ const outputText = typeof state.output === 'string' ? state.output : '';
176
+ const outputLines = outputText.split('\n');
177
+ const updatedIndex = outputLines.findIndex((line) => line.startsWith('Success. Updated the following files:'));
178
+ if (updatedIndex !== -1) {
179
+ const fileLines = outputLines.slice(updatedIndex + 1).filter(Boolean);
180
+ if (fileLines.length > 0) {
181
+ const summarized = summarizeFiles(fileLines.map((line) => line.replace(/^[AMD]\s+/, '').trim()));
182
+ if (summarized) {
183
+ return summarized;
184
+ }
185
+ }
186
+ }
187
+ return '';
188
+ }
128
189
  if (part.tool === 'write') {
129
190
  const filePath = part.state.input?.filePath || '';
130
191
  const content = part.state.input?.content || '';
@@ -175,7 +236,8 @@ export function getToolSummaryText(part) {
175
236
  if (value === null || value === undefined)
176
237
  return null;
177
238
  const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
178
- const truncatedValue = stringValue.length > 50 ? stringValue.slice(0, 50) + '…' : stringValue;
239
+ const normalized = normalizeWhitespace(stringValue);
240
+ const truncatedValue = normalized.length > 50 ? normalized.slice(0, 50) + '…' : normalized;
179
241
  return `${key}: ${truncatedValue}`;
180
242
  })
181
243
  .filter(Boolean);
@@ -201,7 +263,7 @@ export function formatTodoList(part) {
201
263
  return `${num} **${escapeInlineMarkdown(content)}**`;
202
264
  }
203
265
  export function formatPart(part, prefix) {
204
- const pfx = prefix ? `${prefix}: ` : '';
266
+ const pfx = prefix ? `${prefix} ` : '';
205
267
  if (part.type === 'text') {
206
268
  if (!part.text?.trim())
207
269
  return '';
@@ -278,12 +340,13 @@ export function formatPart(part, prefix) {
278
340
  if (part.state.status === 'error') {
279
341
  return '⨯';
280
342
  }
281
- if (part.tool === 'edit' || part.tool === 'write') {
343
+ if (part.tool === 'edit' || part.tool === 'write' || part.tool === 'apply_patch') {
282
344
  return '◼︎';
283
345
  }
284
346
  return '┣';
285
347
  })();
286
- return `${icon} ${pfx}${part.tool} ${toolTitle} ${summaryText}`.trim();
348
+ const toolParts = [part.tool, toolTitle, summaryText].filter(Boolean).join(' ');
349
+ return `${icon} ${pfx}${toolParts}`;
287
350
  }
288
351
  logger.warn('Unknown part type:', part);
289
352
  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() {
@@ -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
@@ -29,6 +29,7 @@ function buildPermissionDedupeKey({ permission, directory, }) {
29
29
  // Queue of messages waiting to be sent after current response finishes
30
30
  // Key is threadId, value is array of queued messages
31
31
  export const messageQueue = new Map();
32
+ const activeEventHandlers = new Map();
32
33
  export function addToQueue({ threadId, message, }) {
33
34
  const queue = messageQueue.get(threadId) || [];
34
35
  queue.push(message);
@@ -55,7 +56,7 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
55
56
  }
56
57
  sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
57
58
  // Abort with special reason so we don't show "completed" message
58
- controller.abort('model-change');
59
+ controller.abort(new Error('model-change'));
59
60
  // Also call the API abort endpoint
60
61
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
61
62
  if (getClient instanceof Error) {
@@ -176,6 +177,15 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
176
177
  if (existingController) {
177
178
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
178
179
  existingController.abort(new Error('New request started'));
180
+ const abortResult = await errore.tryAsync(() => {
181
+ return getClient().session.abort({
182
+ path: { id: session.id },
183
+ query: { directory: sdkDirectory },
184
+ });
185
+ });
186
+ if (abortResult instanceof Error) {
187
+ sessionLogger.log(`[ABORT] Server abort failed (may be already done):`, abortResult);
188
+ }
179
189
  }
180
190
  // Auto-reject ALL pending permissions for this thread
181
191
  const threadPermissions = pendingPermissions.get(thread.id);
@@ -210,10 +220,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
210
220
  await sendThreadMessage(thread, `⚠️ ${rejectedCount} pending permission request${plural} auto-rejected due to new message`);
211
221
  }
212
222
  }
213
- // Cancel any pending question tool if user sends a new message (silently, no thread message)
214
- const questionCancelled = await cancelPendingQuestion(thread.id);
215
- if (questionCancelled) {
216
- sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`);
223
+ // Answer any pending question tool with the user's message (silently, no thread message)
224
+ const questionAnswered = await cancelPendingQuestion(thread.id, prompt);
225
+ if (questionAnswered) {
226
+ sessionLogger.log(`[QUESTION] Answered pending question with user message`);
217
227
  }
218
228
  const abortController = new AbortController();
219
229
  abortControllers.set(session.id, abortController);
@@ -230,6 +240,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
230
240
  sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
231
241
  return;
232
242
  }
243
+ const previousHandler = activeEventHandlers.get(thread.id);
244
+ if (previousHandler) {
245
+ sessionLogger.log(`[EVENT] Waiting for previous handler to finish`);
246
+ await Promise.race([
247
+ previousHandler,
248
+ new Promise((resolve) => {
249
+ setTimeout(resolve, 1000);
250
+ }),
251
+ ]);
252
+ }
233
253
  // Use v2 client for event subscription (has proper types for question.asked events)
234
254
  const clientV2 = getOpencodeClientV2(directory);
235
255
  if (!clientV2) {
@@ -252,8 +272,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
252
272
  let usedAgent;
253
273
  let tokensUsedInSession = 0;
254
274
  let lastDisplayedContextPercentage = 0;
275
+ let lastRateLimitDisplayTime = 0;
255
276
  let modelContextLimit;
256
277
  let assistantMessageId;
278
+ let handlerPromise = null;
257
279
  let typingInterval = null;
258
280
  function startTyping() {
259
281
  if (abortController.signal.aborted) {
@@ -509,6 +531,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
509
531
  }
510
532
  };
511
533
  const handleSubtaskPart = async (part, subtaskInfo) => {
534
+ // In text-only mode, skip all subtask output (they're tool-related)
535
+ if (verbosity === 'text-only') {
536
+ return;
537
+ }
512
538
  if (part.type === 'step-start' || part.type === 'step-finish') {
513
539
  return;
514
540
  }
@@ -700,10 +726,44 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
700
726
  });
701
727
  });
702
728
  };
729
+ const handleSessionStatus = async (properties) => {
730
+ if (properties.sessionID !== session.id) {
731
+ return;
732
+ }
733
+ if (properties.status.type !== 'retry') {
734
+ return;
735
+ }
736
+ // Throttle to once per 10 seconds
737
+ const now = Date.now();
738
+ if (now - lastRateLimitDisplayTime < 10_000) {
739
+ return;
740
+ }
741
+ lastRateLimitDisplayTime = now;
742
+ const { attempt, message, next } = properties.status;
743
+ const remainingMs = Math.max(0, next - now);
744
+ const remainingSec = Math.ceil(remainingMs / 1000);
745
+ const duration = (() => {
746
+ if (remainingSec < 60) {
747
+ return `${remainingSec}s`;
748
+ }
749
+ const mins = Math.floor(remainingSec / 60);
750
+ const secs = remainingSec % 60;
751
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
752
+ })();
753
+ const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`;
754
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
755
+ };
703
756
  const handleSessionIdle = (idleSessionId) => {
704
757
  if (idleSessionId === session.id) {
758
+ // Ignore stale session.idle events - if we haven't received any content yet
759
+ // (no assistantMessageId set), this is likely a stale event from before
760
+ // the prompt was sent or from a previous request's subscription state.
761
+ if (!assistantMessageId) {
762
+ sessionLogger.log(`[SESSION IDLE] Ignoring stale idle event for ${session.id} (no content received yet)`);
763
+ return;
764
+ }
705
765
  sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
706
- abortController.abort('finished');
766
+ abortController.abort(new Error('finished'));
707
767
  return;
708
768
  }
709
769
  if (!subtaskSessions.has(idleSessionId)) {
@@ -738,6 +798,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
738
798
  case 'session.idle':
739
799
  handleSessionIdle(event.properties.sessionID);
740
800
  break;
801
+ case 'session.status':
802
+ await handleSessionStatus(event.properties);
803
+ break;
741
804
  default:
742
805
  break;
743
806
  }
@@ -766,7 +829,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
766
829
  stopTyping();
767
830
  stopTyping = null;
768
831
  }
769
- if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
832
+ const abortReason = abortController.signal.reason?.message;
833
+ if (!abortController.signal.aborted || abortReason === 'finished') {
770
834
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
771
835
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
772
836
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
@@ -835,12 +899,18 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
835
899
  }
836
900
  }
837
901
  else {
838
- sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
902
+ sessionLogger.log(`Session was aborted (reason: ${abortReason}), skipping duration message`);
839
903
  }
840
904
  }
841
905
  };
842
906
  const promptResult = await errore.tryAsync(async () => {
843
- const eventHandlerPromise = eventHandler();
907
+ const newHandlerPromise = eventHandler().finally(() => {
908
+ if (activeEventHandlers.get(thread.id) === newHandlerPromise) {
909
+ activeEventHandlers.delete(thread.id);
910
+ }
911
+ });
912
+ activeEventHandlers.set(thread.id, newHandlerPromise);
913
+ handlerPromise = newHandlerPromise;
844
914
  if (abortController.signal.aborted) {
845
915
  sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
846
916
  return;
@@ -927,7 +997,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
927
997
  })();
928
998
  throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
929
999
  }
930
- abortController.abort('finished');
1000
+ abortController.abort(new Error('finished'));
931
1001
  sessionLogger.log(`Successfully sent prompt, got response`);
932
1002
  if (originalMessage) {
933
1003
  const reactionResult = await errore.tryAsync(async () => {
@@ -940,6 +1010,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
940
1010
  }
941
1011
  return { sessionID: session.id, result: response.data, port };
942
1012
  });
1013
+ if (handlerPromise) {
1014
+ await Promise.race([
1015
+ handlerPromise,
1016
+ new Promise((resolve) => {
1017
+ setTimeout(resolve, 1000);
1018
+ }),
1019
+ ]);
1020
+ }
943
1021
  if (!errore.isError(promptResult)) {
944
1022
  return promptResult;
945
1023
  }
@@ -948,7 +1026,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
948
1026
  return;
949
1027
  }
950
1028
  sessionLogger.error(`ERROR: Failed to send prompt:`, promptError);
951
- abortController.abort('error');
1029
+ abortController.abort(new Error('error'));
952
1030
  if (originalMessage) {
953
1031
  const reactionResult = await errore.tryAsync(async () => {
954
1032
  await originalMessage.reactions.removeAll();
package/dist/tools.js CHANGED
@@ -6,9 +6,9 @@ import { z } from 'zod';
6
6
  import { spawn } from 'node:child_process';
7
7
  import net from 'node:net';
8
8
  import { createOpencodeClient, } from '@opencode-ai/sdk';
9
- import { createLogger } from './logger.js';
9
+ import { createLogger, LogPrefix } from './logger.js';
10
10
  import * as errore from 'errore';
11
- const toolsLogger = createLogger('TOOLS');
11
+ const toolsLogger = createLogger(LogPrefix.TOOLS);
12
12
  import { ShareMarkdown } from './markdown.js';
13
13
  import { formatDistanceToNow } from './utils.js';
14
14
  import pc from 'picocolors';
@@ -17,8 +17,8 @@ import { getDatabase } from './database.js';
17
17
  import { sendThreadMessage, escapeDiscordFormatting, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
18
18
  import { transcribeAudio } from './voice.js';
19
19
  import { FetchError } from './errors.js';
20
- import { createLogger } from './logger.js';
21
- const voiceLogger = createLogger('VOICE');
20
+ import { createLogger, LogPrefix } from './logger.js';
21
+ const voiceLogger = createLogger(LogPrefix.VOICE);
22
22
  export const voiceConnections = new Map();
23
23
  export function convertToMono16k(buffer) {
24
24
  const inputSampleRate = 48000;
package/dist/voice.js CHANGED
@@ -4,11 +4,11 @@
4
4
  // Uses errore for type-safe error handling.
5
5
  import { GoogleGenAI, Type } from '@google/genai';
6
6
  import * as errore from 'errore';
7
- import { createLogger } from './logger.js';
7
+ import { createLogger, LogPrefix } from './logger.js';
8
8
  import { glob } from 'glob';
9
9
  import { ripGrep } from 'ripgrep-js';
10
10
  import { ApiKeyMissingError, InvalidAudioFormatError, TranscriptionError, EmptyTranscriptionError, NoResponseContentError, NoToolResponseError, GrepSearchError, GlobSearchError, } from './errors.js';
11
- const voiceLogger = createLogger('VOICE');
11
+ const voiceLogger = createLogger(LogPrefix.VOICE);
12
12
  function runGrep({ pattern, directory }) {
13
13
  return errore.tryAsync({
14
14
  try: async () => {