kimaki 0.4.47 → 0.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,6 +77,10 @@ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections:
77
77
  const discordLogger = createLogger(LogPrefix.DISCORD)
78
78
  const voiceLogger = createLogger(LogPrefix.VOICE)
79
79
 
80
+ function prefixWithDiscordUser({ username, prompt }: { username: string; prompt: string }): string {
81
+ return `<discord-user name="${username}" />\n${prompt}`
82
+ }
83
+
80
84
  type StartOptions = {
81
85
  token: string
82
86
  appId?: string
@@ -165,6 +169,17 @@ export async function startDiscordBot({
165
169
  if (message.author?.bot) {
166
170
  return
167
171
  }
172
+
173
+ // Ignore messages that start with a mention of another user (not the bot).
174
+ // These are likely users talking to each other, not the bot.
175
+ const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
176
+ if (leadingMentionMatch) {
177
+ const mentionedUserId = leadingMentionMatch[1]
178
+ if (mentionedUserId !== discordClient.user?.id) {
179
+ return
180
+ }
181
+ }
182
+
168
183
  if (message.partial) {
169
184
  discordLogger.log(`Fetching partial message ${message.id}`)
170
185
  const fetched = await errore.tryAsync({
@@ -275,13 +290,19 @@ export async function startDiscordBot({
275
290
 
276
291
  // Include starter message as context for the session
277
292
  let prompt = message.content
278
- const starterMessage = await thread.fetchStarterMessage().catch(() => null)
293
+ const starterMessage = await thread.fetchStarterMessage().catch((error) => {
294
+ discordLogger.warn(
295
+ `[SESSION] Failed to fetch starter message for thread ${thread.id}:`,
296
+ error instanceof Error ? error.message : String(error),
297
+ )
298
+ return null
299
+ })
279
300
  if (starterMessage?.content && starterMessage.content !== message.content) {
280
301
  prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`
281
302
  }
282
303
 
283
304
  await handleOpencodeSession({
284
- prompt,
305
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt }),
285
306
  thread,
286
307
  projectDirectory,
287
308
  channelId: parent?.id || '',
@@ -358,7 +379,7 @@ export async function startDiscordBot({
358
379
  ? `${messageContent}\n\n${textAttachmentsContent}`
359
380
  : messageContent
360
381
  await handleOpencodeSession({
361
- prompt: promptWithAttachments,
382
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt: promptWithAttachments }),
362
383
  thread,
363
384
  projectDirectory,
364
385
  originalMessage: message,
@@ -502,7 +523,7 @@ export async function startDiscordBot({
502
523
  ? `${messageContent}\n\n${textAttachmentsContent}`
503
524
  : messageContent
504
525
  await handleOpencodeSession({
505
- prompt: promptWithAttachments,
526
+ prompt: prefixWithDiscordUser({ username: message.member?.displayName || message.author.displayName, prompt: promptWithAttachments }),
506
527
  thread,
507
528
  projectDirectory: sessionDirectory,
508
529
  originalMessage: message,
@@ -517,8 +538,11 @@ export async function startDiscordBot({
517
538
  try {
518
539
  const errMsg = error instanceof Error ? error.message : String(error)
519
540
  await message.reply({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
520
- } catch {
521
- voiceLogger.error('Discord handler error (fallback):', error)
541
+ } catch (sendError) {
542
+ voiceLogger.error(
543
+ 'Discord handler error (fallback):',
544
+ sendError instanceof Error ? sendError.message : String(sendError),
545
+ )
522
546
  }
523
547
  }
524
548
  })
@@ -539,7 +563,13 @@ export async function startDiscordBot({
539
563
  }
540
564
 
541
565
  // Get the starter message to check for auto-start marker
542
- const starterMessage = await thread.fetchStarterMessage().catch(() => null)
566
+ const starterMessage = await thread.fetchStarterMessage().catch((error) => {
567
+ discordLogger.warn(
568
+ `[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`,
569
+ error instanceof Error ? error.message : String(error),
570
+ )
571
+ return null
572
+ })
543
573
  if (!starterMessage) {
544
574
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
545
575
  return
@@ -601,8 +631,11 @@ export async function startDiscordBot({
601
631
  try {
602
632
  const errMsg = error instanceof Error ? error.message : String(error)
603
633
  await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
604
- } catch {
605
- // Ignore send errors
634
+ } catch (sendError) {
635
+ voiceLogger.error(
636
+ '[BOT_SESSION] Failed to send error message:',
637
+ sendError instanceof Error ? sendError.message : String(sendError),
638
+ )
606
639
  }
607
640
  }
608
641
  })
@@ -29,6 +29,74 @@ function escapeInlineMarkdown(text: string): string {
29
29
  return text.replace(/([*_~|`\\])/g, '\\$1')
30
30
  }
31
31
 
32
+ /**
33
+ * Parses a patchText string (apply_patch format) and counts additions/deletions per file.
34
+ * Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
35
+ * with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
36
+ */
37
+ function parsePatchCounts(
38
+ patchText: string,
39
+ ): Map<string, { additions: number; deletions: number }> {
40
+ const counts = new Map<string, { additions: number; deletions: number }>()
41
+ const lines = patchText.split('\n')
42
+ let currentFile = ''
43
+ let currentType = ''
44
+ let inHunk = false
45
+
46
+ for (const line of lines) {
47
+ const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/)
48
+ const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/)
49
+ const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/)
50
+
51
+ if (addMatch || updateMatch || deleteMatch) {
52
+ const match = addMatch || updateMatch || deleteMatch
53
+ currentFile = (match?.[1] ?? '').trim()
54
+ currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete'
55
+ counts.set(currentFile, { additions: 0, deletions: 0 })
56
+ inHunk = false
57
+ continue
58
+ }
59
+
60
+ if (line.startsWith('@@')) {
61
+ inHunk = true
62
+ continue
63
+ }
64
+
65
+ if (line.startsWith('*** ')) {
66
+ inHunk = false
67
+ continue
68
+ }
69
+
70
+ if (!currentFile) {
71
+ continue
72
+ }
73
+
74
+ const entry = counts.get(currentFile)
75
+ if (!entry) {
76
+ continue
77
+ }
78
+
79
+ if (currentType === 'add') {
80
+ // all content lines in Add File are additions
81
+ if (line.length > 0 && !line.startsWith('*** ')) {
82
+ entry.additions++
83
+ }
84
+ } else if (currentType === 'delete') {
85
+ // all content lines in Delete File are deletions
86
+ if (line.length > 0 && !line.startsWith('*** ')) {
87
+ entry.deletions++
88
+ }
89
+ } else if (inHunk) {
90
+ if (line.startsWith('+')) {
91
+ entry.additions++
92
+ } else if (line.startsWith('-')) {
93
+ entry.deletions++
94
+ }
95
+ }
96
+ }
97
+ return counts
98
+ }
99
+
32
100
  /**
33
101
  * Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
34
102
  */
@@ -178,70 +246,20 @@ export function getToolSummaryText(part: Part): string {
178
246
  }
179
247
 
180
248
  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
- }
249
+ // Only inputs are available when parts are sent during streaming (output/metadata not yet populated)
250
+ const patchText = (part.state.input?.patchText as string) || ''
251
+ if (!patchText) {
252
+ return ''
242
253
  }
243
-
244
- return ''
254
+ const patchCounts = parsePatchCounts(patchText)
255
+ return [...patchCounts.entries()]
256
+ .map(([filePath, { additions, deletions }]) => {
257
+ const fileName = filePath.split('/').pop() || ''
258
+ return fileName
259
+ ? `*${escapeInlineMarkdown(fileName)}* (+${additions}-${deletions})`
260
+ : `(+${additions}-${deletions})`
261
+ })
262
+ .join(', ')
245
263
  }
246
264
 
247
265
  if (part.tool === 'write') {
package/src/opencode.ts CHANGED
@@ -54,31 +54,24 @@ async function getOpenPort(): Promise<number> {
54
54
  }
55
55
 
56
56
  async function waitForServer(port: number, maxAttempts = 30): Promise<ServerStartError | true> {
57
+ const endpoint = `http://127.0.0.1:${port}/api/health`
57
58
  for (let i = 0; i < maxAttempts; i++) {
58
- const endpoints = [
59
- `http://127.0.0.1:${port}/api/health`,
60
- `http://127.0.0.1:${port}/`,
61
- `http://127.0.0.1:${port}/api`,
62
- ]
63
-
64
- for (const endpoint of endpoints) {
65
- const response = await errore.tryAsync({
66
- try: () => fetch(endpoint),
67
- catch: (e) => new FetchError({ url: endpoint, cause: e }),
68
- })
69
- if (response instanceof Error) {
70
- // Connection refused or other transient errors - continue polling
71
- opencodeLogger.debug(`Server polling attempt failed: ${response.message}`)
72
- continue
73
- }
74
- if (response.status < 500) {
75
- return true
76
- }
77
- const body = await response.text()
78
- // Fatal errors that won't resolve with retrying
79
- if (body.includes('BunInstallFailedError')) {
80
- return new ServerStartError({ port, reason: body.slice(0, 200) })
81
- }
59
+ const response = await errore.tryAsync({
60
+ try: () => fetch(endpoint),
61
+ catch: (e) => new FetchError({ url: endpoint, cause: e }),
62
+ })
63
+ if (response instanceof Error) {
64
+ // Connection refused or other transient errors - continue polling
65
+ await new Promise((resolve) => setTimeout(resolve, 1000))
66
+ continue
67
+ }
68
+ if (response.status < 500) {
69
+ return true
70
+ }
71
+ const body = await response.text()
72
+ // Fatal errors that won't resolve with retrying
73
+ if (body.includes('BunInstallFailedError')) {
74
+ return new ServerStartError({ port, reason: body.slice(0, 200) })
82
75
  }
83
76
  await new Promise((resolve) => setTimeout(resolve, 1000))
84
77
  }
@@ -134,6 +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
+ sessionLogger.log(`[ABORT] reason=model-change sessionId=${sessionId} - user changed model mid-request, will retry with new model`)
137
138
  controller.abort(new Error('model-change'))
138
139
 
139
140
  // Also call the API abort endpoint
@@ -142,11 +143,12 @@ export async function abortAndRetrySession({
142
143
  sessionLogger.error(`[ABORT+RETRY] Failed to initialize OpenCode client:`, getClient.message)
143
144
  return false
144
145
  }
146
+ sessionLogger.log(`[ABORT-API] reason=model-change sessionId=${sessionId} - sending API abort for model change retry`)
145
147
  const abortResult = await errore.tryAsync(() => {
146
148
  return getClient().session.abort({ path: { id: sessionId } })
147
149
  })
148
150
  if (abortResult instanceof Error) {
149
- sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, abortResult)
151
+ sessionLogger.log(`[ABORT-API] API abort call failed (may already be done):`, abortResult)
150
152
  }
151
153
 
152
154
  // Small delay to let the abort propagate
@@ -302,7 +304,9 @@ export async function handleOpencodeSession({
302
304
  const existingController = abortControllers.get(session.id)
303
305
  if (existingController) {
304
306
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`)
307
+ sessionLogger.log(`[ABORT] reason=new-request sessionId=${session.id} threadId=${thread.id} - new user message arrived while previous request was still running`)
305
308
  existingController.abort(new Error('New request started'))
309
+ sessionLogger.log(`[ABORT-API] reason=new-request sessionId=${session.id} - sending API abort because new message arrived`)
306
310
  const abortResult = await errore.tryAsync(() => {
307
311
  return getClient().session.abort({
308
312
  path: { id: session.id },
@@ -310,7 +314,7 @@ export async function handleOpencodeSession({
310
314
  })
311
315
  })
312
316
  if (abortResult instanceof Error) {
313
- sessionLogger.log(`[ABORT] Server abort failed (may be already done):`, abortResult)
317
+ sessionLogger.log(`[ABORT-API] Server abort failed (may be already done):`, abortResult)
314
318
  }
315
319
  }
316
320
 
@@ -424,6 +428,9 @@ export async function handleOpencodeSession({
424
428
  let handlerPromise: Promise<void> | null = null
425
429
 
426
430
  let typingInterval: NodeJS.Timeout | null = null
431
+ let hasSentParts = false
432
+ let promptResolved = false
433
+ let hasReceivedEvent = false
427
434
 
428
435
  function startTyping(): () => void {
429
436
  if (abortController.signal.aborted) {
@@ -470,13 +477,15 @@ export async function handleOpencodeSession({
470
477
  }
471
478
  }
472
479
 
473
- // Get verbosity setting for this channel (use parent channel for threads)
480
+ // Read verbosity dynamically so mid-session /verbosity changes take effect immediately
474
481
  const verbosityChannelId = channelId || thread.parentId || thread.id
475
- const verbosity = getChannelVerbosity(verbosityChannelId)
482
+ const getVerbosity = () => {
483
+ return getChannelVerbosity(verbosityChannelId)
484
+ }
476
485
 
477
486
  const sendPartMessage = async (part: Part) => {
478
487
  // In text-only mode, only send text parts (the ⬥ diamond messages)
479
- if (verbosity === 'text-only' && part.type !== 'text') {
488
+ if (getVerbosity() === 'text-only' && part.type !== 'text') {
480
489
  return
481
490
  }
482
491
 
@@ -497,6 +506,7 @@ export async function handleOpencodeSession({
497
506
  discordLogger.error(`ERROR: Failed to send part ${part.id}:`, sendResult)
498
507
  return
499
508
  }
509
+ hasSentParts = true
500
510
  sentPartIds.add(part.id)
501
511
 
502
512
  getDatabase()
@@ -588,6 +598,7 @@ export async function handleOpencodeSession({
588
598
  if (msg.sessionID !== session.id) {
589
599
  return
590
600
  }
601
+ hasReceivedEvent = true
591
602
 
592
603
  if (msg.role !== 'assistant') {
593
604
  return
@@ -687,7 +698,7 @@ export async function handleOpencodeSession({
687
698
  const label = `${agent}-${agentSpawnCounts[agent]}`
688
699
  subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
689
700
  // Skip task messages in text-only mode
690
- if (verbosity !== 'text-only') {
701
+ if (getVerbosity() !== 'text-only') {
691
702
  const taskDisplay = `┣ task **${label}** _${description}_`
692
703
  await sendThreadMessage(thread, taskDisplay + '\n\n')
693
704
  }
@@ -697,7 +708,7 @@ export async function handleOpencodeSession({
697
708
  return
698
709
  }
699
710
 
700
- if (part.type === 'tool' && part.state.status === 'completed') {
711
+ if (part.type === 'tool' && part.state.status === 'completed' && getVerbosity() !== 'text-only') {
701
712
  const output = part.state.output || ''
702
713
  const outputTokens = Math.ceil(output.length / 4)
703
714
  const largeOutputThreshold = 3000
@@ -751,7 +762,7 @@ export async function handleOpencodeSession({
751
762
  subtaskInfo: { label: string; assistantMessageId?: string },
752
763
  ) => {
753
764
  // In text-only mode, skip all subtask output (they're tool-related)
754
- if (verbosity === 'text-only') {
765
+ if (getVerbosity() === 'text-only') {
755
766
  return
756
767
  }
757
768
  if (part.type === 'step-start' || part.type === 'step-finish') {
@@ -837,13 +848,20 @@ export async function handleOpencodeSession({
837
848
  }
838
849
 
839
850
  const handlePermissionAsked = async (permission: PermissionRequest) => {
840
- if (permission.sessionID !== session.id) {
851
+ const isMainSession = permission.sessionID === session.id
852
+ const isSubtaskSession = subtaskSessions.has(permission.sessionID)
853
+
854
+ if (!isMainSession && !isSubtaskSession) {
841
855
  voiceLogger.log(
842
- `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
856
+ `[PERMISSION IGNORED] Permission for unknown session (expected: ${session.id} or subtask, got: ${permission.sessionID})`,
843
857
  )
844
858
  return
845
859
  }
846
860
 
861
+ const subtaskLabel = isSubtaskSession
862
+ ? subtaskSessions.get(permission.sessionID)?.label
863
+ : undefined
864
+
847
865
  const dedupeKey = buildPermissionDedupeKey({ permission, directory })
848
866
  const threadPermissions = pendingPermissions.get(thread.id)
849
867
  const existingPending = threadPermissions
@@ -883,7 +901,7 @@ export async function handleOpencodeSession({
883
901
  }
884
902
 
885
903
  sessionLogger.log(
886
- `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
904
+ `Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}${subtaskLabel ? `, subtask=${subtaskLabel}` : ''}`,
887
905
  )
888
906
 
889
907
  if (stopTyping) {
@@ -895,6 +913,7 @@ export async function handleOpencodeSession({
895
913
  thread,
896
914
  permission,
897
915
  directory,
916
+ subtaskLabel,
898
917
  })
899
918
 
900
919
  if (!pendingPermissions.has(thread.id)) {
@@ -918,7 +937,10 @@ export async function handleOpencodeSession({
918
937
  reply: string
919
938
  sessionID: string
920
939
  }) => {
921
- if (sessionID !== session.id) {
940
+ const isMainSession = sessionID === session.id
941
+ const isSubtaskSession = subtaskSessions.has(sessionID)
942
+
943
+ if (!isMainSession && !isSubtaskSession) {
922
944
  return
923
945
  }
924
946
 
@@ -989,10 +1011,11 @@ export async function handleOpencodeSession({
989
1011
  )
990
1012
 
991
1013
  setImmediate(() => {
1014
+ const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`
992
1015
  void errore
993
1016
  .tryAsync(async () => {
994
1017
  return handleOpencodeSession({
995
- prompt: nextMessage.prompt,
1018
+ prompt: prefixedPrompt,
996
1019
  thread,
997
1020
  projectDirectory: directory,
998
1021
  images: nextMessage.images,
@@ -1048,16 +1071,14 @@ export async function handleOpencodeSession({
1048
1071
 
1049
1072
  const handleSessionIdle = (idleSessionId: string) => {
1050
1073
  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) {
1074
+ if (!promptResolved || !hasReceivedEvent) {
1055
1075
  sessionLogger.log(
1056
- `[SESSION IDLE] Ignoring stale idle event for ${session.id} (no content received yet)`,
1076
+ `[SESSION IDLE] Ignoring idle event for ${session.id} (prompt not resolved or no events yet)`,
1057
1077
  )
1058
1078
  return
1059
1079
  }
1060
- sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
1080
+ sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, ending stream`)
1081
+ sessionLogger.log(`[ABORT] reason=finished sessionId=${session.id} threadId=${thread.id} - session completed normally, received idle event after prompt resolved`)
1061
1082
  abortController.abort(new Error('finished'))
1062
1083
  return
1063
1084
  }
@@ -1203,8 +1224,9 @@ export async function handleOpencodeSession({
1203
1224
  // Send the queued message as a new prompt (recursive call)
1204
1225
  // Use setImmediate to avoid blocking and allow this finally to complete
1205
1226
  setImmediate(() => {
1227
+ const prefixedPrompt = `<discord-user name="${nextMessage.username}" />\n${nextMessage.prompt}`
1206
1228
  handleOpencodeSession({
1207
- prompt: nextMessage.prompt,
1229
+ prompt: prefixedPrompt,
1208
1230
  thread,
1209
1231
  projectDirectory,
1210
1232
  images: nextMessage.images,
@@ -1298,6 +1320,8 @@ export async function handleOpencodeSession({
1298
1320
  }
1299
1321
  : undefined
1300
1322
 
1323
+ hasSentParts = false
1324
+
1301
1325
  const response = command
1302
1326
  ? await getClient().session.command({
1303
1327
  path: { id: session.id },
@@ -1337,7 +1361,7 @@ export async function handleOpencodeSession({
1337
1361
  throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
1338
1362
  }
1339
1363
 
1340
- abortController.abort(new Error('finished'))
1364
+ promptResolved = true
1341
1365
 
1342
1366
  sessionLogger.log(`Successfully sent prompt, got response`)
1343
1367
 
@@ -1373,6 +1397,7 @@ export async function handleOpencodeSession({
1373
1397
  }
1374
1398
 
1375
1399
  sessionLogger.error(`ERROR: Failed to send prompt:`, promptError)
1400
+ sessionLogger.log(`[ABORT] reason=error sessionId=${session.id} threadId=${thread.id} - prompt failed with error: ${(promptError as Error).message}`)
1376
1401
  abortController.abort(new Error('error'))
1377
1402
 
1378
1403
  if (originalMessage) {
@@ -133,19 +133,21 @@ headings are discouraged anyway. instead try to use bold text for titles which r
133
133
 
134
134
  you can create diagrams wrapping them in code blocks.
135
135
 
136
- ## ending conversations with options
136
+ ## proactivity
137
137
 
138
- IMPORTANT: At the end of each response, especially after completing a task or presenting a plan, use the question tool to offer the user clear options for what to do next.
138
+ Be proactive. When the user asks you to do something, do it. Do NOT stop to ask for confirmation.
139
139
 
140
- IMPORTANT: The question tool must be called last, after all text parts. If it is called before your final text response, the user will not see the text.
140
+ Only ask questions when the request is genuinely ambiguous with multiple valid approaches, or the action is destructive and irreversible.
141
141
 
142
- Examples:
143
- - After showing a plan: offer "Start implementing?" with Yes/No options
144
- - After completing edits: offer "Commit changes?" with Yes/No options
145
- - After debugging: offer "How to proceed?" with options like "Apply fix", "Investigate further", "Try different approach"
142
+ ## ending conversations with options
146
143
 
147
- The user can always select "Other" to type a custom response if the provided options don't fit their needs, or if the plan needs updating.
144
+ After **completing** a task, use the question tool to offer follow-up options. The question tool must be called last, after all text parts.
148
145
 
149
- This makes the interaction more guided and reduces friction for the user.
146
+ IMPORTANT: Do NOT use the question tool to ask permission before doing work. Do the work first, then offer follow-ups.
147
+
148
+ Examples:
149
+ - After completing edits: offer "Commit changes?" or "Run tests?"
150
+ - After debugging: offer "Apply fix", "Investigate further", "Try different approach"
151
+ - After a genuinely ambiguous request where you cannot infer intent: offer the different approaches
150
152
  `
151
153
  }
package/src/tools.ts CHANGED
@@ -343,6 +343,7 @@ export async function getTools({
343
343
  }),
344
344
  execute: async ({ sessionId }) => {
345
345
  try {
346
+ toolsLogger.log(`[ABORT] reason=voice-tool sessionId=${sessionId} - user requested abort via voice assistant tool`)
346
347
  const result = await getClient().session.abort({
347
348
  path: { id: sessionId },
348
349
  })
package/src/utils.ts CHANGED
@@ -73,6 +73,7 @@ export function isAbortError(error: unknown, signal?: AbortSignal): error is Err
73
73
  error.name === 'Aborterror' ||
74
74
  error.name === 'aborterror' ||
75
75
  error.name.toLowerCase() === 'aborterror' ||
76
+ error.name === 'MessageAbortedError' ||
76
77
  error.message?.includes('aborted') ||
77
78
  (signal?.aborted ?? false))) ||
78
79
  (error instanceof DOMException && error.name === 'AbortError')