kimaki 0.4.46 → 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 +3 -3
  8. package/dist/commands/fork.js +3 -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 +2 -2
  15. package/dist/commands/session.js +2 -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 +2 -2
  19. package/dist/commands/verbosity.js +3 -3
  20. package/dist/commands/worktree-settings.js +2 -2
  21. package/dist/commands/worktree.js +18 -8
  22. package/dist/database.js +2 -2
  23. package/dist/discord-bot.js +3 -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 +53 -14
  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 +3 -3
  48. package/src/commands/fork.ts +3 -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 +2 -2
  55. package/src/commands/session.ts +2 -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 +2 -2
  59. package/src/commands/verbosity.ts +3 -3
  60. package/src/commands/worktree-settings.ts +2 -2
  61. package/src/commands/worktree.ts +20 -7
  62. package/src/database.ts +2 -2
  63. package/src/discord-bot.ts +3 -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 +62 -14
  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/src/database.ts CHANGED
@@ -6,10 +6,10 @@ import Database from 'better-sqlite3'
6
6
  import fs from 'node:fs'
7
7
  import path from 'node:path'
8
8
  import * as errore from 'errore'
9
- import { createLogger } from './logger.js'
9
+ import { createLogger, LogPrefix } from './logger.js'
10
10
  import { getDataDir } from './config.js'
11
11
 
12
- const dbLogger = createLogger('DB')
12
+ const dbLogger = createLogger(LogPrefix.DB)
13
13
 
14
14
  let db: Database.Database | null = null
15
15
 
@@ -66,7 +66,7 @@ import {
66
66
  } from 'discord.js'
67
67
  import fs from 'node:fs'
68
68
  import * as errore from 'errore'
69
- import { createLogger } from './logger.js'
69
+ import { createLogger, LogPrefix } from './logger.js'
70
70
  import { setGlobalDispatcher, Agent } from 'undici'
71
71
 
72
72
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
@@ -74,8 +74,8 @@ import { setGlobalDispatcher, Agent } from 'undici'
74
74
  // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
75
75
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }))
76
76
 
77
- const discordLogger = createLogger('DISCORD')
78
- const voiceLogger = createLogger('VOICE')
77
+ const discordLogger = createLogger(LogPrefix.DISCORD)
78
+ const voiceLogger = createLogger(LogPrefix.VOICE)
79
79
 
80
80
  type StartOptions = {
81
81
  token: string
@@ -8,12 +8,12 @@ import { formatMarkdownTables } from './format-tables.js'
8
8
  import { getChannelDirectory } from './database.js'
9
9
  import { limitHeadingDepth } from './limit-heading-depth.js'
10
10
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
11
- import { createLogger } from './logger.js'
11
+ import { createLogger, LogPrefix } from './logger.js'
12
12
  import mime from 'mime'
13
13
  import fs from 'node:fs'
14
14
  import path from 'node:path'
15
15
 
16
- const discordLogger = createLogger('DISCORD')
16
+ const discordLogger = createLogger(LogPrefix.DISCORD)
17
17
 
18
18
  export const SILENT_MESSAGE_FLAGS = 4 | 4096
19
19
  // Same as SILENT but without SuppressNotifications - triggers badge/notification
@@ -5,10 +5,10 @@
5
5
  import { Worker } from 'node:worker_threads'
6
6
  import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
7
7
  import type { Tool as AITool } from 'ai'
8
- import { createLogger } from './logger.js'
8
+ import { createLogger, LogPrefix } from './logger.js'
9
9
 
10
- const genaiWorkerLogger = createLogger('GENAI WORKER')
11
- const genaiWrapperLogger = createLogger('GENAI WORKER WRAPPER')
10
+ const genaiWorkerLogger = createLogger(LogPrefix.GENAI_WORKER)
11
+ const genaiWrapperLogger = createLogger(LogPrefix.GENAI_WORKER)
12
12
 
13
13
  export interface GenAIWorkerOptions {
14
14
  directory: string
@@ -13,13 +13,13 @@ import type { Session } from '@google/genai'
13
13
  import { getTools } from './tools.js'
14
14
  import { mkdir } from 'node:fs/promises'
15
15
  import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
16
- import { createLogger } from './logger.js'
16
+ import { createLogger, LogPrefix } from './logger.js'
17
17
 
18
18
  if (!parentPort) {
19
19
  throw new Error('This module must be run as a worker thread')
20
20
  }
21
21
 
22
- const workerLogger = createLogger(`WORKER ${threadId}`)
22
+ const workerLogger = createLogger(`${LogPrefix.WORKER}_${threadId}`)
23
23
  workerLogger.log('GenAI worker started')
24
24
 
25
25
  // Define sendError early so it can be used by global handlers
package/src/genai.ts CHANGED
@@ -7,10 +7,10 @@ import type { CallableTool } from '@google/genai'
7
7
  import { writeFile } from 'fs'
8
8
  import type { Tool as AITool } from 'ai'
9
9
 
10
- import { createLogger } from './logger.js'
10
+ import { createLogger, LogPrefix } from './logger.js'
11
11
  import { aiToolToCallableTool } from './ai-tool-to-genai.js'
12
12
 
13
- const genaiLogger = createLogger('GENAI')
13
+ const genaiLogger = createLogger(LogPrefix.GENAI)
14
14
 
15
15
  const audioParts: Buffer[] = []
16
16
 
@@ -19,6 +19,7 @@ import {
19
19
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js'
20
20
  import { handlePermissionSelectMenu } from './commands/permissions.js'
21
21
  import { handleAbortCommand } from './commands/abort.js'
22
+ import { handleCompactCommand } from './commands/compact.js'
22
23
  import { handleShareCommand } from './commands/share.js'
23
24
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js'
24
25
  import {
@@ -32,9 +33,9 @@ import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js
32
33
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
33
34
  import { handleUserCommand } from './commands/user-command.js'
34
35
  import { handleVerbosityCommand } from './commands/verbosity.js'
35
- import { createLogger } from './logger.js'
36
+ import { createLogger, LogPrefix } from './logger.js'
36
37
 
37
- const interactionLogger = createLogger('INTERACTION')
38
+ const interactionLogger = createLogger(LogPrefix.INTERACTION)
38
39
 
39
40
  export function registerInteractionHandler({
40
41
  discordClient,
@@ -126,6 +127,10 @@ export function registerInteractionHandler({
126
127
  await handleAbortCommand({ command: interaction, appId })
127
128
  return
128
129
 
130
+ case 'compact':
131
+ await handleCompactCommand({ command: interaction, appId })
132
+ return
133
+
129
134
  case 'share':
130
135
  await handleShareCommand({ command: interaction, appId })
131
136
  return
package/src/logger.ts CHANGED
@@ -1,12 +1,54 @@
1
- // Prefixed logging utility using @clack/prompts.
2
- // Creates loggers with consistent prefixes for different subsystems
3
- // (DISCORD, VOICE, SESSION, etc.) for easier debugging.
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
4
 
5
- import { log } from '@clack/prompts'
5
+ import { log as clackLog } from '@clack/prompts'
6
6
  import fs from 'node:fs'
7
7
  import path, { dirname } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import util from 'node:util'
10
+ import pc from 'picocolors'
11
+
12
+ // All known log prefixes - add new ones here to keep alignment consistent
13
+ export const LogPrefix = {
14
+ ABORT: 'ABORT',
15
+ ADD_PROJECT: 'ADD_PROJ',
16
+ AGENT: 'AGENT',
17
+ ASK_QUESTION: 'QUESTION',
18
+ CLI: 'CLI',
19
+ COMPACT: 'COMPACT',
20
+ CREATE_PROJECT: 'NEW_PROJ',
21
+ DB: 'DB',
22
+ DISCORD: 'DISCORD',
23
+ FORK: 'FORK',
24
+ FORMATTING: 'FORMAT',
25
+ GENAI: 'GENAI',
26
+ GENAI_WORKER: 'GENAI_W',
27
+ INTERACTION: 'INTERACT',
28
+ MARKDOWN: 'MARKDOWN',
29
+ MODEL: 'MODEL',
30
+ OPENAI: 'OPENAI',
31
+ OPENCODE: 'OPENCODE',
32
+ PERMISSIONS: 'PERMS',
33
+ QUEUE: 'QUEUE',
34
+ REMOVE_PROJECT: 'RM_PROJ',
35
+ RESUME: 'RESUME',
36
+ SESSION: 'SESSION',
37
+ SHARE: 'SHARE',
38
+ TOOLS: 'TOOLS',
39
+ UNDO_REDO: 'UNDO',
40
+ USER_CMD: 'USER_CMD',
41
+ VERBOSITY: 'VERBOSE',
42
+ VOICE: 'VOICE',
43
+ WORKER: 'WORKER',
44
+ WORKTREE: 'WORKTREE',
45
+ XML: 'XML',
46
+ } as const
47
+
48
+ export type LogPrefixType = (typeof LogPrefix)[keyof typeof LogPrefix]
49
+
50
+ // compute max length from all known prefixes for alignment
51
+ const MAX_PREFIX_LENGTH = Math.max(...Object.values(LogPrefix).map((p) => p.length))
10
52
 
11
53
  const __filename = fileURLToPath(import.meta.url)
12
54
  const __dirname = dirname(__filename)
@@ -39,27 +81,39 @@ function writeToFile(level: string, prefix: string, args: unknown[]) {
39
81
  fs.appendFileSync(logFilePath, message)
40
82
  }
41
83
 
42
- export function createLogger(prefix: string) {
84
+ function getTimestamp(): string {
85
+ const now = new Date()
86
+ return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
87
+ }
88
+
89
+ function padPrefix(prefix: string): string {
90
+ return prefix.padEnd(MAX_PREFIX_LENGTH)
91
+ }
92
+
93
+ export function createLogger(prefix: LogPrefixType | string) {
94
+ const paddedPrefix = padPrefix(prefix)
43
95
  return {
44
96
  log: (...args: unknown[]) => {
45
97
  writeToFile('INFO', prefix, args)
46
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
98
+ console.log(pc.dim(getTimestamp()), pc.cyan(paddedPrefix), ...args.map(formatArg))
47
99
  },
48
100
  error: (...args: unknown[]) => {
49
101
  writeToFile('ERROR', prefix, args)
50
- log.error([`[${prefix}]`, ...args.map(formatArg)].join(' '))
102
+ // use clack for errors - visually distinct
103
+ clackLog.error([paddedPrefix, ...args.map(formatArg)].join(' '))
51
104
  },
52
105
  warn: (...args: unknown[]) => {
53
106
  writeToFile('WARN', prefix, args)
54
- log.warn([`[${prefix}]`, ...args.map(formatArg)].join(' '))
107
+ // use clack for warnings - visually distinct
108
+ clackLog.warn([paddedPrefix, ...args.map(formatArg)].join(' '))
55
109
  },
56
110
  info: (...args: unknown[]) => {
57
111
  writeToFile('INFO', prefix, args)
58
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
112
+ console.log(pc.dim(getTimestamp()), pc.blue(paddedPrefix), ...args.map(formatArg))
59
113
  },
60
114
  debug: (...args: unknown[]) => {
61
115
  writeToFile('DEBUG', prefix, args)
62
- log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
116
+ console.log(pc.dim(getTimestamp()), pc.dim(paddedPrefix), ...args.map(formatArg))
63
117
  },
64
118
  }
65
119
  }
package/src/markdown.ts CHANGED
@@ -9,7 +9,7 @@ import { createTaggedError } from 'errore'
9
9
  import * as yaml from 'js-yaml'
10
10
  import { formatDateTime } from './utils.js'
11
11
  import { extractNonXmlContent } from './xml.js'
12
- import { createLogger } from './logger.js'
12
+ import { createLogger, LogPrefix } from './logger.js'
13
13
  import { SessionNotFoundError, MessagesNotFoundError } from './errors.js'
14
14
 
15
15
  // Generic error for unexpected exceptions in async operations
@@ -18,7 +18,7 @@ class UnexpectedError extends createTaggedError({
18
18
  message: '$message',
19
19
  }) {}
20
20
 
21
- const markdownLogger = createLogger('MARKDOWN')
21
+ const markdownLogger = createLogger(LogPrefix.MARKDOWN)
22
22
 
23
23
  export class ShareMarkdown {
24
24
  constructor(private client: OpencodeClient) {}
@@ -8,7 +8,7 @@ import type { Message } from 'discord.js'
8
8
  import fs from 'node:fs'
9
9
  import path from 'node:path'
10
10
  import * as errore from 'errore'
11
- import { createLogger } from './logger.js'
11
+ import { createLogger, LogPrefix } from './logger.js'
12
12
  import { FetchError } from './errors.js'
13
13
 
14
14
  // Generic message type compatible with both v1 and v2 SDK
@@ -19,7 +19,7 @@ type GenericSessionMessage = {
19
19
 
20
20
  const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments')
21
21
 
22
- const logger = createLogger('FORMATTING')
22
+ const logger = createLogger(LogPrefix.FORMATTING)
23
23
 
24
24
  /**
25
25
  * Escapes Discord inline markdown characters so dynamic content
@@ -29,6 +29,13 @@ function escapeInlineMarkdown(text: string): string {
29
29
  return text.replace(/([*_~|`\\])/g, '\\$1')
30
30
  }
31
31
 
32
+ /**
33
+ * Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
34
+ */
35
+ function normalizeWhitespace(text: string): string {
36
+ return text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ')
37
+ }
38
+
32
39
  /**
33
40
  * Collects and formats the last N assistant parts from session messages.
34
41
  * Used by both /resume and /fork to show recent assistant context.
@@ -170,6 +177,73 @@ export function getToolSummaryText(part: Part): string {
170
177
  : `(+${added}-${removed})`
171
178
  }
172
179
 
180
+ if (part.tool === 'apply_patch') {
181
+ const state = part.state as {
182
+ metadata?: { files?: unknown; diff?: unknown }
183
+ output?: unknown
184
+ }
185
+ const rawFiles = state.metadata?.files
186
+ const partMetaFiles = (part as { metadata?: { files?: unknown } }).metadata?.files
187
+ const filesList = Array.isArray(rawFiles)
188
+ ? rawFiles
189
+ : Array.isArray(partMetaFiles)
190
+ ? partMetaFiles
191
+ : []
192
+
193
+ const summarizeFiles = (files: unknown[]): string => {
194
+ const summarized = files
195
+ .map((f) => {
196
+ if (!f) {
197
+ return null
198
+ }
199
+ if (typeof f === 'string') {
200
+ const fileName = f.split('/').pop() || ''
201
+ return fileName ? `*${escapeInlineMarkdown(fileName)}* (+0-0)` : `(+0-0)`
202
+ }
203
+ if (typeof f !== 'object') {
204
+ return null
205
+ }
206
+ const file = f as Record<string, unknown>
207
+ const pathStr = String(file.relativePath || file.filePath || file.path || '')
208
+ const fileName = pathStr.split('/').pop() || ''
209
+ const added = typeof file.additions === 'number' ? file.additions : 0
210
+ const removed = typeof file.deletions === 'number' ? file.deletions : 0
211
+ return fileName
212
+ ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})`
213
+ : `(+${added}-${removed})`
214
+ })
215
+ .filter(Boolean)
216
+ .join(', ')
217
+ return summarized
218
+ }
219
+
220
+ if (filesList.length > 0) {
221
+ const summarized = summarizeFiles(filesList)
222
+ if (summarized) {
223
+ return summarized
224
+ }
225
+ }
226
+
227
+ const outputText = typeof state.output === 'string' ? state.output : ''
228
+ const outputLines = outputText.split('\n')
229
+ const updatedIndex = outputLines.findIndex((line) =>
230
+ line.startsWith('Success. Updated the following files:'),
231
+ )
232
+ if (updatedIndex !== -1) {
233
+ const fileLines = outputLines.slice(updatedIndex + 1).filter(Boolean)
234
+ if (fileLines.length > 0) {
235
+ const summarized = summarizeFiles(
236
+ fileLines.map((line) => line.replace(/^[AMD]\s+/, '').trim()),
237
+ )
238
+ if (summarized) {
239
+ return summarized
240
+ }
241
+ }
242
+ }
243
+
244
+ return ''
245
+ }
246
+
173
247
  if (part.tool === 'write') {
174
248
  const filePath = (part.state.input?.filePath as string) || ''
175
249
  const content = (part.state.input?.content as string) || ''
@@ -228,7 +302,8 @@ export function getToolSummaryText(part: Part): string {
228
302
  .map(([key, value]) => {
229
303
  if (value === null || value === undefined) return null
230
304
  const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
231
- const truncatedValue = stringValue.length > 50 ? stringValue.slice(0, 50) + '…' : stringValue
305
+ const normalized = normalizeWhitespace(stringValue)
306
+ const truncatedValue = normalized.length > 50 ? normalized.slice(0, 50) + '…' : normalized
232
307
  return `${key}: ${truncatedValue}`
233
308
  })
234
309
  .filter(Boolean)
@@ -259,7 +334,7 @@ export function formatTodoList(part: Part): string {
259
334
  }
260
335
 
261
336
  export function formatPart(part: Part, prefix?: string): string {
262
- const pfx = prefix ? `${prefix}: ` : ''
337
+ const pfx = prefix ? `${prefix} ` : ''
263
338
 
264
339
  if (part.type === 'text') {
265
340
  if (!part.text?.trim()) return ''
@@ -343,12 +418,13 @@ export function formatPart(part: Part, prefix?: string): string {
343
418
  if (part.state.status === 'error') {
344
419
  return '⨯'
345
420
  }
346
- if (part.tool === 'edit' || part.tool === 'write') {
421
+ if (part.tool === 'edit' || part.tool === 'write' || part.tool === 'apply_patch') {
347
422
  return '◼︎'
348
423
  }
349
424
  return '┣'
350
425
  })()
351
- return `${icon} ${pfx}${part.tool} ${toolTitle} ${summaryText}`.trim()
426
+ const toolParts = [part.tool, toolTitle, summaryText].filter(Boolean).join(' ')
427
+ return `${icon} ${pfx}${toolParts}`
352
428
  }
353
429
 
354
430
  logger.warn('Unknown part type:', part)
@@ -5,9 +5,9 @@
5
5
  import { RealtimeClient } from '@openai/realtime-api-beta'
6
6
  import { writeFile } from 'fs'
7
7
  import type { Tool } from 'ai'
8
- import { createLogger } from './logger.js'
8
+ import { createLogger, LogPrefix } from './logger.js'
9
9
 
10
- const openaiLogger = createLogger('OPENAI')
10
+ const openaiLogger = createLogger(LogPrefix.OPENAI)
11
11
 
12
12
  // Export the session type for reuse
13
13
  export interface OpenAIRealtimeSession {
package/src/opencode.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  type OpencodeClient as OpencodeClientV2,
13
13
  } from '@opencode-ai/sdk/v2'
14
14
  import * as errore from 'errore'
15
- import { createLogger } from './logger.js'
15
+ import { createLogger, LogPrefix } from './logger.js'
16
16
  import {
17
17
  DirectoryNotAccessibleError,
18
18
  ServerStartError,
@@ -21,7 +21,7 @@ import {
21
21
  type OpenCodeErrors,
22
22
  } from './errors.js'
23
23
 
24
- const opencodeLogger = createLogger('OPENCODE')
24
+ const opencodeLogger = createLogger(LogPrefix.OPENCODE)
25
25
 
26
26
  const opencodeServers = new Map<
27
27
  string,
@@ -24,7 +24,7 @@ import {
24
24
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
25
25
  import { formatPart } from './message-formatting.js'
26
26
  import { getOpencodeSystemMessage, type WorktreeInfo } from './system-message.js'
27
- import { createLogger } from './logger.js'
27
+ import { createLogger, LogPrefix } from './logger.js'
28
28
  import { isAbortError } from './utils.js'
29
29
  import {
30
30
  showAskUserQuestionDropdowns,
@@ -38,9 +38,9 @@ import {
38
38
  } from './commands/permissions.js'
39
39
  import * as errore from 'errore'
40
40
 
41
- const sessionLogger = createLogger('SESSION')
42
- const voiceLogger = createLogger('VOICE')
43
- const discordLogger = createLogger('DISCORD')
41
+ const sessionLogger = createLogger(LogPrefix.SESSION)
42
+ const voiceLogger = createLogger(LogPrefix.VOICE)
43
+ const discordLogger = createLogger(LogPrefix.DISCORD)
44
44
 
45
45
  export const abortControllers = new Map<string, AbortController>()
46
46
 
@@ -134,7 +134,7 @@ export async function abortAndRetrySession({
134
134
  sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`)
135
135
 
136
136
  // Abort with special reason so we don't show "completed" message
137
- controller.abort('model-change')
137
+ controller.abort(new Error('model-change'))
138
138
 
139
139
  // Also call the API abort endpoint
140
140
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
@@ -350,10 +350,10 @@ export async function handleOpencodeSession({
350
350
  }
351
351
  }
352
352
 
353
- // Cancel any pending question tool if user sends a new message (silently, no thread message)
354
- const questionCancelled = await cancelPendingQuestion(thread.id)
355
- if (questionCancelled) {
356
- sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`)
353
+ // Answer any pending question tool with the user's message (silently, no thread message)
354
+ const questionAnswered = await cancelPendingQuestion(thread.id, prompt)
355
+ if (questionAnswered) {
356
+ sessionLogger.log(`[QUESTION] Answered pending question with user message`)
357
357
  }
358
358
 
359
359
  const abortController = new AbortController()
@@ -418,6 +418,7 @@ export async function handleOpencodeSession({
418
418
  let usedAgent: string | undefined
419
419
  let tokensUsedInSession = 0
420
420
  let lastDisplayedContextPercentage = 0
421
+ let lastRateLimitDisplayTime = 0
421
422
  let modelContextLimit: number | undefined
422
423
  let assistantMessageId: string | undefined
423
424
  let handlerPromise: Promise<void> | null = null
@@ -1011,10 +1012,53 @@ export async function handleOpencodeSession({
1011
1012
  })
1012
1013
  }
1013
1014
 
1015
+ const handleSessionStatus = async (properties: {
1016
+ sessionID: string
1017
+ status: { type: 'idle' } | { type: 'retry'; attempt: number; message: string; next: number } | { type: 'busy' }
1018
+ }) => {
1019
+ if (properties.sessionID !== session.id) {
1020
+ return
1021
+ }
1022
+ if (properties.status.type !== 'retry') {
1023
+ return
1024
+ }
1025
+ // Throttle to once per 10 seconds
1026
+ const now = Date.now()
1027
+ if (now - lastRateLimitDisplayTime < 10_000) {
1028
+ return
1029
+ }
1030
+ lastRateLimitDisplayTime = now
1031
+
1032
+ const { attempt, message, next } = properties.status
1033
+ const remainingMs = Math.max(0, next - now)
1034
+ const remainingSec = Math.ceil(remainingMs / 1000)
1035
+
1036
+ const duration = (() => {
1037
+ if (remainingSec < 60) {
1038
+ return `${remainingSec}s`
1039
+ }
1040
+ const mins = Math.floor(remainingSec / 60)
1041
+ const secs = remainingSec % 60
1042
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
1043
+ })()
1044
+
1045
+ const chunk = `⬦ ${message} - retrying in ${duration} (attempt #${attempt})`
1046
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
1047
+ }
1048
+
1014
1049
  const handleSessionIdle = (idleSessionId: string) => {
1015
1050
  if (idleSessionId === session.id) {
1051
+ // Ignore stale session.idle events - if we haven't received any content yet
1052
+ // (no assistantMessageId set), this is likely a stale event from before
1053
+ // the prompt was sent or from a previous request's subscription state.
1054
+ if (!assistantMessageId) {
1055
+ sessionLogger.log(
1056
+ `[SESSION IDLE] Ignoring stale idle event for ${session.id} (no content received yet)`,
1057
+ )
1058
+ return
1059
+ }
1016
1060
  sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
1017
- abortController.abort('finished')
1061
+ abortController.abort(new Error('finished'))
1018
1062
  return
1019
1063
  }
1020
1064
 
@@ -1051,6 +1095,9 @@ export async function handleOpencodeSession({
1051
1095
  case 'session.idle':
1052
1096
  handleSessionIdle(event.properties.sessionID)
1053
1097
  break
1098
+ case 'session.status':
1099
+ await handleSessionStatus(event.properties)
1100
+ break
1054
1101
  default:
1055
1102
  break
1056
1103
  }
@@ -1079,7 +1126,8 @@ export async function handleOpencodeSession({
1079
1126
  stopTyping = null
1080
1127
  }
1081
1128
 
1082
- if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
1129
+ const abortReason = (abortController.signal.reason as Error)?.message
1130
+ if (!abortController.signal.aborted || abortReason === 'finished') {
1083
1131
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime)
1084
1132
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
1085
1133
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
@@ -1170,7 +1218,7 @@ export async function handleOpencodeSession({
1170
1218
  }
1171
1219
  } else {
1172
1220
  sessionLogger.log(
1173
- `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
1221
+ `Session was aborted (reason: ${abortReason}), skipping duration message`,
1174
1222
  )
1175
1223
  }
1176
1224
  }
@@ -1289,7 +1337,7 @@ export async function handleOpencodeSession({
1289
1337
  throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
1290
1338
  }
1291
1339
 
1292
- abortController.abort('finished')
1340
+ abortController.abort(new Error('finished'))
1293
1341
 
1294
1342
  sessionLogger.log(`Successfully sent prompt, got response`)
1295
1343
 
@@ -1325,7 +1373,7 @@ export async function handleOpencodeSession({
1325
1373
  }
1326
1374
 
1327
1375
  sessionLogger.error(`ERROR: Failed to send prompt:`, promptError)
1328
- abortController.abort('error')
1376
+ abortController.abort(new Error('error'))
1329
1377
 
1330
1378
  if (originalMessage) {
1331
1379
  const reactionResult = await errore.tryAsync(async () => {
package/src/tools.ts CHANGED
@@ -12,10 +12,10 @@ import {
12
12
  type AssistantMessage,
13
13
  type Provider,
14
14
  } from '@opencode-ai/sdk'
15
- import { createLogger } from './logger.js'
15
+ import { createLogger, LogPrefix } from './logger.js'
16
16
  import * as errore from 'errore'
17
17
 
18
- const toolsLogger = createLogger('TOOLS')
18
+ const toolsLogger = createLogger(LogPrefix.TOOLS)
19
19
 
20
20
  import { ShareMarkdown } from './markdown.js'
21
21
  import { formatDistanceToNow } from './utils.js'
@@ -37,9 +37,9 @@ import {
37
37
  import { transcribeAudio } from './voice.js'
38
38
  import { FetchError } from './errors.js'
39
39
 
40
- import { createLogger } from './logger.js'
40
+ import { createLogger, LogPrefix } from './logger.js'
41
41
 
42
- const voiceLogger = createLogger('VOICE')
42
+ const voiceLogger = createLogger(LogPrefix.VOICE)
43
43
 
44
44
  export type VoiceConnectionData = {
45
45
  connection: VoiceConnection
package/src/voice.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { GoogleGenAI, Type, type Content, type Part, type Tool } from '@google/genai'
7
7
  import * as errore from 'errore'
8
- import { createLogger } from './logger.js'
8
+ import { createLogger, LogPrefix } from './logger.js'
9
9
  import { glob } from 'glob'
10
10
  import { ripGrep } from 'ripgrep-js'
11
11
  import {
@@ -19,7 +19,7 @@ import {
19
19
  GlobSearchError,
20
20
  } from './errors.js'
21
21
 
22
- const voiceLogger = createLogger('VOICE')
22
+ const voiceLogger = createLogger(LogPrefix.VOICE)
23
23
 
24
24
  export type TranscriptionToolRunner = ({
25
25
  name,