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
@@ -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
@@ -56,7 +56,7 @@ 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
+ controller.abort(new Error('model-change'));
60
60
  // Also call the API abort endpoint
61
61
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
62
62
  if (getClient instanceof Error) {
@@ -220,10 +220,10 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
220
220
  await sendThreadMessage(thread, `⚠️ ${rejectedCount} pending permission request${plural} auto-rejected due to new message`);
221
221
  }
222
222
  }
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`);
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`);
227
227
  }
228
228
  const abortController = new AbortController();
229
229
  abortControllers.set(session.id, abortController);
@@ -272,6 +272,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
272
272
  let usedAgent;
273
273
  let tokensUsedInSession = 0;
274
274
  let lastDisplayedContextPercentage = 0;
275
+ let lastRateLimitDisplayTime = 0;
275
276
  let modelContextLimit;
276
277
  let assistantMessageId;
277
278
  let handlerPromise = null;
@@ -725,10 +726,44 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
725
726
  });
726
727
  });
727
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
+ };
728
756
  const handleSessionIdle = (idleSessionId) => {
729
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
+ }
730
765
  sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
731
- abortController.abort('finished');
766
+ abortController.abort(new Error('finished'));
732
767
  return;
733
768
  }
734
769
  if (!subtaskSessions.has(idleSessionId)) {
@@ -763,6 +798,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
763
798
  case 'session.idle':
764
799
  handleSessionIdle(event.properties.sessionID);
765
800
  break;
801
+ case 'session.status':
802
+ await handleSessionStatus(event.properties);
803
+ break;
766
804
  default:
767
805
  break;
768
806
  }
@@ -791,7 +829,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
791
829
  stopTyping();
792
830
  stopTyping = null;
793
831
  }
794
- if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
832
+ const abortReason = abortController.signal.reason?.message;
833
+ if (!abortController.signal.aborted || abortReason === 'finished') {
795
834
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
796
835
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
797
836
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
@@ -860,7 +899,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
860
899
  }
861
900
  }
862
901
  else {
863
- sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
902
+ sessionLogger.log(`Session was aborted (reason: ${abortReason}), skipping duration message`);
864
903
  }
865
904
  }
866
905
  };
@@ -958,7 +997,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
958
997
  })();
959
998
  throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
960
999
  }
961
- abortController.abort('finished');
1000
+ abortController.abort(new Error('finished'));
962
1001
  sessionLogger.log(`Successfully sent prompt, got response`);
963
1002
  if (originalMessage) {
964
1003
  const reactionResult = await errore.tryAsync(async () => {
@@ -987,7 +1026,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
987
1026
  return;
988
1027
  }
989
1028
  sessionLogger.error(`ERROR: Failed to send prompt:`, promptError);
990
- abortController.abort('error');
1029
+ abortController.abort(new Error('error'));
991
1030
  if (originalMessage) {
992
1031
  const reactionResult = await errore.tryAsync(async () => {
993
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 () => {
@@ -1,15 +1,19 @@
1
1
  // Worktree utility functions.
2
2
  // Wrapper for OpenCode worktree creation that also initializes git submodules.
3
- import { exec } from 'node:child_process';
3
+ // Also handles capturing and applying git diffs when creating worktrees from threads.
4
+ import { exec, spawn } from 'node:child_process';
4
5
  import { promisify } from 'node:util';
5
- import { createLogger } from './logger.js';
6
+ import { createLogger, LogPrefix } from './logger.js';
6
7
  export const execAsync = promisify(exec);
7
- const logger = createLogger('WORKTREE-UTILS');
8
+ const logger = createLogger(LogPrefix.WORKTREE);
8
9
  /**
9
10
  * Create a worktree using OpenCode SDK and initialize git submodules.
10
11
  * This wrapper ensures submodules are properly set up in new worktrees.
12
+ *
13
+ * If diff is provided, it's applied BEFORE submodule update to ensure
14
+ * any submodule pointer changes in the diff are respected.
11
15
  */
12
- export async function createWorktreeWithSubmodules({ clientV2, directory, name, }) {
16
+ export async function createWorktreeWithSubmodules({ clientV2, directory, name, diff, }) {
13
17
  // 1. Create worktree via OpenCode SDK
14
18
  const response = await clientV2.worktree.create({
15
19
  directory,
@@ -22,7 +26,17 @@ export async function createWorktreeWithSubmodules({ clientV2, directory, name,
22
26
  return new Error('No worktree data returned from SDK');
23
27
  }
24
28
  const worktreeDir = response.data.directory;
25
- // 2. Init submodules in new worktree (don't block on failure)
29
+ let diffApplied = false;
30
+ // 2. Apply diff BEFORE submodule update (if provided)
31
+ // This ensures any submodule pointer changes in the diff are applied first,
32
+ // so submodule update checks out the correct commits.
33
+ if (diff) {
34
+ logger.log(`Applying diff to ${worktreeDir} before submodule init`);
35
+ diffApplied = await applyGitDiff(worktreeDir, diff);
36
+ }
37
+ // 3. Init submodules in new worktree (don't block on failure)
38
+ // Uses --init to initialize, --recursive for nested submodules.
39
+ // Submodules will be checked out at the commit specified by the (possibly updated) index.
26
40
  try {
27
41
  logger.log(`Initializing submodules in ${worktreeDir}`);
28
42
  await execAsync('git submodule update --init --recursive', {
@@ -34,7 +48,7 @@ export async function createWorktreeWithSubmodules({ clientV2, directory, name,
34
48
  // Log but don't fail - submodules might not exist
35
49
  logger.warn(`Failed to init submodules in ${worktreeDir}: ${e instanceof Error ? e.message : String(e)}`);
36
50
  }
37
- // 3. Install dependencies using ni (detects package manager from lockfile)
51
+ // 4. Install dependencies using ni (detects package manager from lockfile)
38
52
  try {
39
53
  logger.log(`Installing dependencies in ${worktreeDir}`);
40
54
  await execAsync('npx -y ni', {
@@ -46,5 +60,75 @@ export async function createWorktreeWithSubmodules({ clientV2, directory, name,
46
60
  // Log but don't fail - might not be a JS project or might fail for various reasons
47
61
  logger.warn(`Failed to install dependencies in ${worktreeDir}: ${e instanceof Error ? e.message : String(e)}`);
48
62
  }
49
- return response.data;
63
+ return { ...response.data, diffApplied };
64
+ }
65
+ /**
66
+ * Capture git diff from a directory (both staged and unstaged changes).
67
+ * Returns null if no changes or on error.
68
+ */
69
+ export async function captureGitDiff(directory) {
70
+ try {
71
+ // Capture unstaged changes
72
+ const unstagedResult = await execAsync('git diff', { cwd: directory });
73
+ const unstaged = unstagedResult.stdout.trim();
74
+ // Capture staged changes
75
+ const stagedResult = await execAsync('git diff --staged', { cwd: directory });
76
+ const staged = stagedResult.stdout.trim();
77
+ if (!unstaged && !staged) {
78
+ return null;
79
+ }
80
+ return { unstaged, staged };
81
+ }
82
+ catch (e) {
83
+ logger.warn(`Failed to capture git diff from ${directory}: ${e instanceof Error ? e.message : String(e)}`);
84
+ return null;
85
+ }
86
+ }
87
+ /**
88
+ * Run a git command with stdin input.
89
+ * Uses spawn to pipe the diff content to git apply.
90
+ */
91
+ function runGitWithStdin(args, cwd, input) {
92
+ return new Promise((resolve, reject) => {
93
+ const child = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
94
+ let stderr = '';
95
+ child.stderr?.on('data', (data) => {
96
+ stderr += data.toString();
97
+ });
98
+ child.on('close', (code) => {
99
+ if (code === 0) {
100
+ resolve();
101
+ }
102
+ else {
103
+ reject(new Error(stderr || `git ${args.join(' ')} failed with code ${code}`));
104
+ }
105
+ });
106
+ child.on('error', reject);
107
+ child.stdin?.write(input);
108
+ child.stdin?.end();
109
+ });
110
+ }
111
+ /**
112
+ * Apply a captured git diff to a directory.
113
+ * Applies staged changes first, then unstaged.
114
+ */
115
+ export async function applyGitDiff(directory, diff) {
116
+ try {
117
+ // Apply staged changes first (and stage them)
118
+ if (diff.staged) {
119
+ logger.log(`Applying staged diff to ${directory}`);
120
+ await runGitWithStdin(['apply', '--index'], directory, diff.staged);
121
+ }
122
+ // Apply unstaged changes (don't stage them)
123
+ if (diff.unstaged) {
124
+ logger.log(`Applying unstaged diff to ${directory}`);
125
+ await runGitWithStdin(['apply'], directory, diff.unstaged);
126
+ }
127
+ logger.log(`Successfully applied diff to ${directory}`);
128
+ return true;
129
+ }
130
+ catch (e) {
131
+ logger.warn(`Failed to apply git diff to ${directory}: ${e instanceof Error ? e.message : String(e)}`);
132
+ return false;
133
+ }
50
134
  }
package/dist/xml.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // Parses XML-like tags from strings (e.g., channel topics) to extract
3
3
  // Kimaki configuration like directory paths and app IDs.
4
4
  import { DomHandler, Parser, ElementType } from 'htmlparser2';
5
- import { createLogger } from './logger.js';
6
- const xmlLogger = createLogger('XML');
5
+ import { createLogger, LogPrefix } from './logger.js';
6
+ const xmlLogger = createLogger(LogPrefix.XML);
7
7
  export function extractTagsArrays({ xml, tags, }) {
8
8
  const result = {
9
9
  others: [],