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
@@ -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
 
@@ -86,6 +86,8 @@ export type QueuedMessage = {
86
86
  // Key is threadId, value is array of queued messages
87
87
  export const messageQueue = new Map<string, QueuedMessage[]>()
88
88
 
89
+ const activeEventHandlers = new Map<string, Promise<void>>()
90
+
89
91
  export function addToQueue({
90
92
  threadId,
91
93
  message,
@@ -132,7 +134,7 @@ export async function abortAndRetrySession({
132
134
  sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`)
133
135
 
134
136
  // Abort with special reason so we don't show "completed" message
135
- controller.abort('model-change')
137
+ controller.abort(new Error('model-change'))
136
138
 
137
139
  // Also call the API abort endpoint
138
140
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
@@ -301,6 +303,15 @@ export async function handleOpencodeSession({
301
303
  if (existingController) {
302
304
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`)
303
305
  existingController.abort(new Error('New request started'))
306
+ const abortResult = await errore.tryAsync(() => {
307
+ return getClient().session.abort({
308
+ path: { id: session.id },
309
+ query: { directory: sdkDirectory },
310
+ })
311
+ })
312
+ if (abortResult instanceof Error) {
313
+ sessionLogger.log(`[ABORT] Server abort failed (may be already done):`, abortResult)
314
+ }
304
315
  }
305
316
 
306
317
  // Auto-reject ALL pending permissions for this thread
@@ -339,10 +350,10 @@ export async function handleOpencodeSession({
339
350
  }
340
351
  }
341
352
 
342
- // Cancel any pending question tool if user sends a new message (silently, no thread message)
343
- const questionCancelled = await cancelPendingQuestion(thread.id)
344
- if (questionCancelled) {
345
- 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`)
346
357
  }
347
358
 
348
359
  const abortController = new AbortController()
@@ -363,6 +374,17 @@ export async function handleOpencodeSession({
363
374
  return
364
375
  }
365
376
 
377
+ const previousHandler = activeEventHandlers.get(thread.id)
378
+ if (previousHandler) {
379
+ sessionLogger.log(`[EVENT] Waiting for previous handler to finish`)
380
+ await Promise.race([
381
+ previousHandler,
382
+ new Promise((resolve) => {
383
+ setTimeout(resolve, 1000)
384
+ }),
385
+ ])
386
+ }
387
+
366
388
  // Use v2 client for event subscription (has proper types for question.asked events)
367
389
  const clientV2 = getOpencodeClientV2(directory)
368
390
  if (!clientV2) {
@@ -396,8 +418,10 @@ export async function handleOpencodeSession({
396
418
  let usedAgent: string | undefined
397
419
  let tokensUsedInSession = 0
398
420
  let lastDisplayedContextPercentage = 0
421
+ let lastRateLimitDisplayTime = 0
399
422
  let modelContextLimit: number | undefined
400
423
  let assistantMessageId: string | undefined
424
+ let handlerPromise: Promise<void> | null = null
401
425
 
402
426
  let typingInterval: NodeJS.Timeout | null = null
403
427
 
@@ -726,6 +750,10 @@ export async function handleOpencodeSession({
726
750
  part: Part,
727
751
  subtaskInfo: { label: string; assistantMessageId?: string },
728
752
  ) => {
753
+ // In text-only mode, skip all subtask output (they're tool-related)
754
+ if (verbosity === 'text-only') {
755
+ return
756
+ }
729
757
  if (part.type === 'step-start' || part.type === 'step-finish') {
730
758
  return
731
759
  }
@@ -984,10 +1012,53 @@ export async function handleOpencodeSession({
984
1012
  })
985
1013
  }
986
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
+
987
1049
  const handleSessionIdle = (idleSessionId: string) => {
988
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
+ }
989
1060
  sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
990
- abortController.abort('finished')
1061
+ abortController.abort(new Error('finished'))
991
1062
  return
992
1063
  }
993
1064
 
@@ -1024,6 +1095,9 @@ export async function handleOpencodeSession({
1024
1095
  case 'session.idle':
1025
1096
  handleSessionIdle(event.properties.sessionID)
1026
1097
  break
1098
+ case 'session.status':
1099
+ await handleSessionStatus(event.properties)
1100
+ break
1027
1101
  default:
1028
1102
  break
1029
1103
  }
@@ -1052,7 +1126,8 @@ export async function handleOpencodeSession({
1052
1126
  stopTyping = null
1053
1127
  }
1054
1128
 
1055
- if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
1129
+ const abortReason = (abortController.signal.reason as Error)?.message
1130
+ if (!abortController.signal.aborted || abortReason === 'finished') {
1056
1131
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime)
1057
1132
  const attachCommand = port ? ` ⋅ ${session.id}` : ''
1058
1133
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
@@ -1143,7 +1218,7 @@ export async function handleOpencodeSession({
1143
1218
  }
1144
1219
  } else {
1145
1220
  sessionLogger.log(
1146
- `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
1221
+ `Session was aborted (reason: ${abortReason}), skipping duration message`,
1147
1222
  )
1148
1223
  }
1149
1224
  }
@@ -1151,7 +1226,13 @@ export async function handleOpencodeSession({
1151
1226
 
1152
1227
  const promptResult: Error | { sessionID: string; result: any; port?: number } | undefined =
1153
1228
  await errore.tryAsync(async () => {
1154
- const eventHandlerPromise = eventHandler()
1229
+ const newHandlerPromise = eventHandler().finally(() => {
1230
+ if (activeEventHandlers.get(thread.id) === newHandlerPromise) {
1231
+ activeEventHandlers.delete(thread.id)
1232
+ }
1233
+ })
1234
+ activeEventHandlers.set(thread.id, newHandlerPromise)
1235
+ handlerPromise = newHandlerPromise
1155
1236
 
1156
1237
  if (abortController.signal.aborted) {
1157
1238
  sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
@@ -1256,7 +1337,7 @@ export async function handleOpencodeSession({
1256
1337
  throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
1257
1338
  }
1258
1339
 
1259
- abortController.abort('finished')
1340
+ abortController.abort(new Error('finished'))
1260
1341
 
1261
1342
  sessionLogger.log(`Successfully sent prompt, got response`)
1262
1343
 
@@ -1273,6 +1354,15 @@ export async function handleOpencodeSession({
1273
1354
  return { sessionID: session.id, result: response.data, port }
1274
1355
  })
1275
1356
 
1357
+ if (handlerPromise) {
1358
+ await Promise.race([
1359
+ handlerPromise,
1360
+ new Promise((resolve) => {
1361
+ setTimeout(resolve, 1000)
1362
+ }),
1363
+ ])
1364
+ }
1365
+
1276
1366
  if (!errore.isError(promptResult)) {
1277
1367
  return promptResult
1278
1368
  }
@@ -1283,7 +1373,7 @@ export async function handleOpencodeSession({
1283
1373
  }
1284
1374
 
1285
1375
  sessionLogger.error(`ERROR: Failed to send prompt:`, promptError)
1286
- abortController.abort('error')
1376
+ abortController.abort(new Error('error'))
1287
1377
 
1288
1378
  if (originalMessage) {
1289
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,
@@ -1,14 +1,15 @@
1
1
  // Worktree utility functions.
2
2
  // Wrapper for OpenCode worktree creation that also initializes git submodules.
3
+ // Also handles capturing and applying git diffs when creating worktrees from threads.
3
4
 
4
- import { exec } from 'node:child_process'
5
+ import { exec, spawn } from 'node:child_process'
5
6
  import { promisify } from 'node:util'
6
- import { createLogger } from './logger.js'
7
+ import { createLogger, LogPrefix } from './logger.js'
7
8
  import type { getOpencodeClientV2 } from './opencode.js'
8
9
 
9
10
  export const execAsync = promisify(exec)
10
11
 
11
- const logger = createLogger('WORKTREE-UTILS')
12
+ const logger = createLogger(LogPrefix.WORKTREE)
12
13
 
13
14
  type OpencodeClientV2 = NonNullable<ReturnType<typeof getOpencodeClientV2>>
14
15
 
@@ -20,16 +21,21 @@ type WorktreeResult = {
20
21
  /**
21
22
  * Create a worktree using OpenCode SDK and initialize git submodules.
22
23
  * This wrapper ensures submodules are properly set up in new worktrees.
24
+ *
25
+ * If diff is provided, it's applied BEFORE submodule update to ensure
26
+ * any submodule pointer changes in the diff are respected.
23
27
  */
24
28
  export async function createWorktreeWithSubmodules({
25
29
  clientV2,
26
30
  directory,
27
31
  name,
32
+ diff,
28
33
  }: {
29
34
  clientV2: OpencodeClientV2
30
35
  directory: string
31
36
  name: string
32
- }): Promise<WorktreeResult | Error> {
37
+ diff?: CapturedDiff | null
38
+ }): Promise<WorktreeResult & { diffApplied: boolean } | Error> {
33
39
  // 1. Create worktree via OpenCode SDK
34
40
  const response = await clientV2.worktree.create({
35
41
  directory,
@@ -45,8 +51,19 @@ export async function createWorktreeWithSubmodules({
45
51
  }
46
52
 
47
53
  const worktreeDir = response.data.directory
54
+ let diffApplied = false
48
55
 
49
- // 2. Init submodules in new worktree (don't block on failure)
56
+ // 2. Apply diff BEFORE submodule update (if provided)
57
+ // This ensures any submodule pointer changes in the diff are applied first,
58
+ // so submodule update checks out the correct commits.
59
+ if (diff) {
60
+ logger.log(`Applying diff to ${worktreeDir} before submodule init`)
61
+ diffApplied = await applyGitDiff(worktreeDir, diff)
62
+ }
63
+
64
+ // 3. Init submodules in new worktree (don't block on failure)
65
+ // Uses --init to initialize, --recursive for nested submodules.
66
+ // Submodules will be checked out at the commit specified by the (possibly updated) index.
50
67
  try {
51
68
  logger.log(`Initializing submodules in ${worktreeDir}`)
52
69
  await execAsync('git submodule update --init --recursive', {
@@ -60,7 +77,7 @@ export async function createWorktreeWithSubmodules({
60
77
  )
61
78
  }
62
79
 
63
- // 3. Install dependencies using ni (detects package manager from lockfile)
80
+ // 4. Install dependencies using ni (detects package manager from lockfile)
64
81
  try {
65
82
  logger.log(`Installing dependencies in ${worktreeDir}`)
66
83
  await execAsync('npx -y ni', {
@@ -74,5 +91,92 @@ export async function createWorktreeWithSubmodules({
74
91
  )
75
92
  }
76
93
 
77
- return response.data
94
+ return { ...response.data, diffApplied }
95
+ }
96
+
97
+ /**
98
+ * Captured git diff (both staged and unstaged changes).
99
+ */
100
+ export type CapturedDiff = {
101
+ unstaged: string
102
+ staged: string
103
+ }
104
+
105
+ /**
106
+ * Capture git diff from a directory (both staged and unstaged changes).
107
+ * Returns null if no changes or on error.
108
+ */
109
+ export async function captureGitDiff(directory: string): Promise<CapturedDiff | null> {
110
+ try {
111
+ // Capture unstaged changes
112
+ const unstagedResult = await execAsync('git diff', { cwd: directory })
113
+ const unstaged = unstagedResult.stdout.trim()
114
+
115
+ // Capture staged changes
116
+ const stagedResult = await execAsync('git diff --staged', { cwd: directory })
117
+ const staged = stagedResult.stdout.trim()
118
+
119
+ if (!unstaged && !staged) {
120
+ return null
121
+ }
122
+
123
+ return { unstaged, staged }
124
+ } catch (e) {
125
+ logger.warn(`Failed to capture git diff from ${directory}: ${e instanceof Error ? e.message : String(e)}`)
126
+ return null
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Run a git command with stdin input.
132
+ * Uses spawn to pipe the diff content to git apply.
133
+ */
134
+ function runGitWithStdin(args: string[], cwd: string, input: string): Promise<void> {
135
+ return new Promise((resolve, reject) => {
136
+ const child = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] })
137
+
138
+ let stderr = ''
139
+ child.stderr?.on('data', (data) => {
140
+ stderr += data.toString()
141
+ })
142
+
143
+ child.on('close', (code) => {
144
+ if (code === 0) {
145
+ resolve()
146
+ } else {
147
+ reject(new Error(stderr || `git ${args.join(' ')} failed with code ${code}`))
148
+ }
149
+ })
150
+
151
+ child.on('error', reject)
152
+
153
+ child.stdin?.write(input)
154
+ child.stdin?.end()
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Apply a captured git diff to a directory.
160
+ * Applies staged changes first, then unstaged.
161
+ */
162
+ export async function applyGitDiff(directory: string, diff: CapturedDiff): Promise<boolean> {
163
+ try {
164
+ // Apply staged changes first (and stage them)
165
+ if (diff.staged) {
166
+ logger.log(`Applying staged diff to ${directory}`)
167
+ await runGitWithStdin(['apply', '--index'], directory, diff.staged)
168
+ }
169
+
170
+ // Apply unstaged changes (don't stage them)
171
+ if (diff.unstaged) {
172
+ logger.log(`Applying unstaged diff to ${directory}`)
173
+ await runGitWithStdin(['apply'], directory, diff.unstaged)
174
+ }
175
+
176
+ logger.log(`Successfully applied diff to ${directory}`)
177
+ return true
178
+ } catch (e) {
179
+ logger.warn(`Failed to apply git diff to ${directory}: ${e instanceof Error ? e.message : String(e)}`)
180
+ return false
181
+ }
78
182
  }
package/src/xml.ts CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  import { DomHandler, Parser, ElementType } from 'htmlparser2'
6
6
  import type { ChildNode, Element, Text } from 'domhandler'
7
- import { createLogger } from './logger.js'
7
+ import { createLogger, LogPrefix } from './logger.js'
8
8
 
9
- const xmlLogger = createLogger('XML')
9
+ const xmlLogger = createLogger(LogPrefix.XML)
10
10
 
11
11
  export function extractTagsArrays<T extends string>({
12
12
  xml,