kimaki 0.4.38 → 0.4.40

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 (55) hide show
  1. package/dist/cli.js +27 -23
  2. package/dist/commands/abort.js +15 -6
  3. package/dist/commands/add-project.js +9 -0
  4. package/dist/commands/agent.js +13 -1
  5. package/dist/commands/fork.js +13 -2
  6. package/dist/commands/model.js +12 -0
  7. package/dist/commands/remove-project.js +26 -16
  8. package/dist/commands/resume.js +9 -0
  9. package/dist/commands/session.js +14 -1
  10. package/dist/commands/share.js +10 -1
  11. package/dist/commands/undo-redo.js +13 -4
  12. package/dist/commands/worktree.js +180 -0
  13. package/dist/database.js +57 -5
  14. package/dist/discord-bot.js +48 -10
  15. package/dist/discord-utils.js +36 -0
  16. package/dist/errors.js +109 -0
  17. package/dist/genai-worker.js +18 -16
  18. package/dist/interaction-handler.js +6 -2
  19. package/dist/markdown.js +100 -85
  20. package/dist/markdown.test.js +10 -3
  21. package/dist/message-formatting.js +50 -37
  22. package/dist/opencode.js +43 -46
  23. package/dist/session-handler.js +100 -2
  24. package/dist/system-message.js +2 -0
  25. package/dist/tools.js +18 -8
  26. package/dist/voice-handler.js +48 -25
  27. package/dist/voice.js +159 -131
  28. package/package.json +4 -2
  29. package/src/cli.ts +31 -32
  30. package/src/commands/abort.ts +17 -7
  31. package/src/commands/add-project.ts +9 -0
  32. package/src/commands/agent.ts +13 -1
  33. package/src/commands/fork.ts +18 -7
  34. package/src/commands/model.ts +12 -0
  35. package/src/commands/remove-project.ts +28 -16
  36. package/src/commands/resume.ts +9 -0
  37. package/src/commands/session.ts +14 -1
  38. package/src/commands/share.ts +11 -1
  39. package/src/commands/undo-redo.ts +15 -6
  40. package/src/commands/worktree.ts +243 -0
  41. package/src/database.ts +104 -4
  42. package/src/discord-bot.ts +49 -9
  43. package/src/discord-utils.ts +50 -0
  44. package/src/errors.ts +138 -0
  45. package/src/genai-worker.ts +20 -17
  46. package/src/interaction-handler.ts +7 -2
  47. package/src/markdown.test.ts +13 -3
  48. package/src/markdown.ts +112 -95
  49. package/src/message-formatting.ts +55 -38
  50. package/src/opencode.ts +52 -49
  51. package/src/session-handler.ts +118 -3
  52. package/src/system-message.ts +2 -0
  53. package/src/tools.ts +18 -8
  54. package/src/voice-handler.ts +48 -23
  55. package/src/voice.ts +195 -148
package/src/markdown.ts CHANGED
@@ -1,12 +1,22 @@
1
1
  // Session-to-markdown renderer for sharing.
2
2
  // Generates shareable markdown from OpenCode sessions, formatting
3
3
  // user messages, assistant responses, tool calls, and reasoning blocks.
4
+ // Uses errore for type-safe error handling.
4
5
 
5
6
  import type { OpencodeClient } from '@opencode-ai/sdk'
7
+ import * as errore from 'errore'
8
+ import { createTaggedError } from 'errore'
6
9
  import * as yaml from 'js-yaml'
7
10
  import { formatDateTime } from './utils.js'
8
11
  import { extractNonXmlContent } from './xml.js'
9
12
  import { createLogger } from './logger.js'
13
+ import { SessionNotFoundError, MessagesNotFoundError } from './errors.js'
14
+
15
+ // Generic error for unexpected exceptions in async operations
16
+ class UnexpectedError extends createTaggedError({
17
+ name: 'UnexpectedError',
18
+ message: '$message',
19
+ }) {}
10
20
 
11
21
  const markdownLogger = createLogger('MARKDOWN')
12
22
 
@@ -16,13 +26,13 @@ export class ShareMarkdown {
16
26
  /**
17
27
  * Generate a markdown representation of a session
18
28
  * @param options Configuration options
19
- * @returns Markdown string representation of the session
29
+ * @returns Error or markdown string
20
30
  */
21
31
  async generate(options: {
22
32
  sessionID: string
23
33
  includeSystemInfo?: boolean
24
34
  lastAssistantOnly?: boolean
25
- }): Promise<string> {
35
+ }): Promise<SessionNotFoundError | MessagesNotFoundError | string> {
26
36
  const { sessionID, includeSystemInfo, lastAssistantOnly } = options
27
37
 
28
38
  // Get session info
@@ -30,7 +40,7 @@ export class ShareMarkdown {
30
40
  path: { id: sessionID },
31
41
  })
32
42
  if (!sessionResponse.data) {
33
- throw new Error(`Session ${sessionID} not found`)
43
+ return new SessionNotFoundError({ sessionId: sessionID })
34
44
  }
35
45
  const session = sessionResponse.data
36
46
 
@@ -39,7 +49,7 @@ export class ShareMarkdown {
39
49
  path: { id: sessionID },
40
50
  })
41
51
  if (!messagesResponse.data) {
42
- throw new Error(`No messages found for session ${sessionID}`)
52
+ return new MessagesNotFoundError({ sessionId: sessionID })
43
53
  }
44
54
  const messages = messagesResponse.data
45
55
 
@@ -234,7 +244,7 @@ export class ShareMarkdown {
234
244
  * Includes system prompt (optional), user messages, assistant text,
235
245
  * and tool calls in compact form (name + params only, no output).
236
246
  */
237
- export async function getCompactSessionContext({
247
+ export function getCompactSessionContext({
238
248
  client,
239
249
  sessionId,
240
250
  includeSystemPrompt = false,
@@ -244,114 +254,121 @@ export async function getCompactSessionContext({
244
254
  sessionId: string
245
255
  includeSystemPrompt?: boolean
246
256
  maxMessages?: number
247
- }): Promise<string> {
248
- try {
249
- const messagesResponse = await client.session.messages({
250
- path: { id: sessionId },
251
- })
252
- const messages = messagesResponse.data || []
253
-
254
- const lines: string[] = []
255
-
256
- // Get system prompt if requested
257
- // Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
258
- // 1. session.system field (if available in future SDK versions)
259
- // 2. synthetic text part in first assistant message (current approach)
260
- if (includeSystemPrompt && messages.length > 0) {
261
- const firstAssistant = messages.find((m) => m.info.role === 'assistant')
262
- if (firstAssistant) {
263
- // look for text part marked as synthetic (system prompt)
264
- const systemPart = (firstAssistant.parts || []).find(
265
- (p) => p.type === 'text' && (p as any).synthetic === true,
266
- )
267
- if (systemPart && 'text' in systemPart && systemPart.text) {
268
- lines.push('[System Prompt]')
269
- const truncated = systemPart.text.slice(0, 3000)
270
- lines.push(truncated)
271
- if (systemPart.text.length > 3000) {
272
- lines.push('...(truncated)')
257
+ }): Promise<UnexpectedError | string> {
258
+ return errore.tryAsync({
259
+ try: async () => {
260
+ const messagesResponse = await client.session.messages({
261
+ path: { id: sessionId },
262
+ })
263
+ const messages = messagesResponse.data || []
264
+
265
+ const lines: string[] = []
266
+
267
+ // Get system prompt if requested
268
+ // Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
269
+ // 1. session.system field (if available in future SDK versions)
270
+ // 2. synthetic text part in first assistant message (current approach)
271
+ if (includeSystemPrompt && messages.length > 0) {
272
+ const firstAssistant = messages.find((m) => m.info.role === 'assistant')
273
+ if (firstAssistant) {
274
+ // look for text part marked as synthetic (system prompt)
275
+ const systemPart = (firstAssistant.parts || []).find(
276
+ (p) => p.type === 'text' && (p as any).synthetic === true,
277
+ )
278
+ if (systemPart && 'text' in systemPart && systemPart.text) {
279
+ lines.push('[System Prompt]')
280
+ const truncated = systemPart.text.slice(0, 3000)
281
+ lines.push(truncated)
282
+ if (systemPart.text.length > 3000) {
283
+ lines.push('...(truncated)')
284
+ }
285
+ lines.push('')
273
286
  }
274
- lines.push('')
275
287
  }
276
288
  }
277
- }
278
289
 
279
- // Process recent messages
280
- const recentMessages = messages.slice(-maxMessages)
281
-
282
- for (const msg of recentMessages) {
283
- if (msg.info.role === 'user') {
284
- const textParts = (msg.parts || [])
285
- .filter((p) => p.type === 'text' && 'text' in p)
286
- .map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
287
- .filter(Boolean)
288
- if (textParts.length > 0) {
289
- lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`)
290
- lines.push('')
291
- }
292
- } else if (msg.info.role === 'assistant') {
293
- // Get assistant text parts (non-synthetic, non-empty)
294
- const textParts = (msg.parts || [])
295
- .filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
296
- .map((p) => ('text' in p ? p.text : ''))
297
- .filter(Boolean)
298
- if (textParts.length > 0) {
299
- lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`)
300
- lines.push('')
301
- }
290
+ // Process recent messages
291
+ const recentMessages = messages.slice(-maxMessages)
292
+
293
+ for (const msg of recentMessages) {
294
+ if (msg.info.role === 'user') {
295
+ const textParts = (msg.parts || [])
296
+ .filter((p) => p.type === 'text' && 'text' in p)
297
+ .map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
298
+ .filter(Boolean)
299
+ if (textParts.length > 0) {
300
+ lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`)
301
+ lines.push('')
302
+ }
303
+ } else if (msg.info.role === 'assistant') {
304
+ // Get assistant text parts (non-synthetic, non-empty)
305
+ const textParts = (msg.parts || [])
306
+ .filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
307
+ .map((p) => ('text' in p ? p.text : ''))
308
+ .filter(Boolean)
309
+ if (textParts.length > 0) {
310
+ lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`)
311
+ lines.push('')
312
+ }
302
313
 
303
- // Get tool calls in compact form (name + params only)
304
- const toolParts = (msg.parts || []).filter(
305
- (p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed',
306
- )
307
- for (const part of toolParts) {
308
- if (part.type === 'tool' && 'tool' in part && 'state' in part) {
309
- const toolName = part.tool
310
- // skip noisy tools
311
- if (toolName === 'todoread' || toolName === 'todowrite') {
312
- continue
314
+ // Get tool calls in compact form (name + params only)
315
+ const toolParts = (msg.parts || []).filter(
316
+ (p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed',
317
+ )
318
+ for (const part of toolParts) {
319
+ if (part.type === 'tool' && 'tool' in part && 'state' in part) {
320
+ const toolName = part.tool
321
+ // skip noisy tools
322
+ if (toolName === 'todoread' || toolName === 'todowrite') {
323
+ continue
324
+ }
325
+ const input = part.state?.input || {}
326
+ const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
327
+ // compact params: just key=value on one line
328
+ const params = Object.entries(input)
329
+ .map(([k, v]) => {
330
+ const val =
331
+ typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100)
332
+ return `${k}=${normalize(val)}`
333
+ })
334
+ .join(', ')
335
+ lines.push(`[Tool ${toolName}]: ${params}`)
313
336
  }
314
- const input = part.state?.input || {}
315
- // compact params: just key=value on one line
316
- const params = Object.entries(input)
317
- .map(([k, v]) => {
318
- const val =
319
- typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100)
320
- return `${k}=${val}`
321
- })
322
- .join(', ')
323
- lines.push(`[Tool ${toolName}]: ${params}`)
324
337
  }
325
338
  }
326
339
  }
327
- }
328
340
 
329
- return lines.join('\n').slice(0, 8000)
330
- } catch (e) {
331
- markdownLogger.error('Failed to get compact session context:', e)
332
- return ''
333
- }
341
+ return lines.join('\n').slice(0, 8000)
342
+ },
343
+ catch: (e) => {
344
+ markdownLogger.error('Failed to get compact session context:', e)
345
+ return new UnexpectedError({ message: 'Failed to get compact session context', cause: e })
346
+ },
347
+ })
334
348
  }
335
349
 
336
350
  /**
337
351
  * Get the last session for a directory (excluding the current one).
338
352
  */
339
- export async function getLastSessionId({
353
+ export function getLastSessionId({
340
354
  client,
341
355
  excludeSessionId,
342
356
  }: {
343
357
  client: OpencodeClient
344
358
  excludeSessionId?: string
345
- }): Promise<string | null> {
346
- try {
347
- const sessionsResponse = await client.session.list()
348
- const sessions = sessionsResponse.data || []
349
-
350
- // Sessions are sorted by time, get the most recent one that isn't the current
351
- const lastSession = sessions.find((s) => s.id !== excludeSessionId)
352
- return lastSession?.id || null
353
- } catch (e) {
354
- markdownLogger.error('Failed to get last session:', e)
355
- return null
356
- }
359
+ }): Promise<UnexpectedError | (string | null)> {
360
+ return errore.tryAsync({
361
+ try: async () => {
362
+ const sessionsResponse = await client.session.list()
363
+ const sessions = sessionsResponse.data || []
364
+
365
+ // Sessions are sorted by time, get the most recent one that isn't the current
366
+ const lastSession = sessions.find((s) => s.id !== excludeSessionId)
367
+ return lastSession?.id || null
368
+ },
369
+ catch: (e) => {
370
+ markdownLogger.error('Failed to get last session:', e)
371
+ return new UnexpectedError({ message: 'Failed to get last session', cause: e })
372
+ },
373
+ })
357
374
  }
@@ -7,7 +7,9 @@ import type { FilePartInput } from '@opencode-ai/sdk'
7
7
  import type { Message } from 'discord.js'
8
8
  import fs from 'node:fs'
9
9
  import path from 'node:path'
10
+ import * as errore from 'errore'
10
11
  import { createLogger } from './logger.js'
12
+ import { FetchError } from './errors.js'
11
13
 
12
14
  // Generic message type compatible with both v1 and v2 SDK
13
15
  type GenericSessionMessage = {
@@ -87,17 +89,18 @@ export async function getTextAttachments(message: Message): Promise<string> {
87
89
 
88
90
  const textContents = await Promise.all(
89
91
  textAttachments.map(async (attachment) => {
90
- try {
91
- const response = await fetch(attachment.url)
92
- if (!response.ok) {
93
- return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
94
- }
95
- const text = await response.text()
96
- return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
97
- } catch (error) {
98
- const errMsg = error instanceof Error ? error.message : String(error)
99
- return `<attachment filename="${attachment.name}" error="${errMsg}" />`
92
+ const response = await errore.tryAsync({
93
+ try: () => fetch(attachment.url),
94
+ catch: (e) => new FetchError({ url: attachment.url, cause: e }),
95
+ })
96
+ if (response instanceof Error) {
97
+ return `<attachment filename="${attachment.name}" error="${response.message}" />`
98
+ }
99
+ if (!response.ok) {
100
+ return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
100
101
  }
102
+ const text = await response.text()
103
+ return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
101
104
  }),
102
105
  )
103
106
 
@@ -121,28 +124,30 @@ export async function getFileAttachments(message: Message): Promise<FilePartInpu
121
124
 
122
125
  const results = await Promise.all(
123
126
  fileAttachments.map(async (attachment) => {
124
- try {
125
- const response = await fetch(attachment.url)
126
- if (!response.ok) {
127
- logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`)
128
- return null
129
- }
127
+ const response = await errore.tryAsync({
128
+ try: () => fetch(attachment.url),
129
+ catch: (e) => new FetchError({ url: attachment.url, cause: e }),
130
+ })
131
+ if (response instanceof Error) {
132
+ logger.error(`Error downloading attachment ${attachment.name}:`, response.message)
133
+ return null
134
+ }
135
+ if (!response.ok) {
136
+ logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`)
137
+ return null
138
+ }
130
139
 
131
- const buffer = Buffer.from(await response.arrayBuffer())
132
- const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`)
133
- fs.writeFileSync(localPath, buffer)
140
+ const buffer = Buffer.from(await response.arrayBuffer())
141
+ const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`)
142
+ fs.writeFileSync(localPath, buffer)
134
143
 
135
- logger.log(`Downloaded attachment to ${localPath}`)
144
+ logger.log(`Downloaded attachment to ${localPath}`)
136
145
 
137
- return {
138
- type: 'file' as const,
139
- mime: attachment.contentType || 'application/octet-stream',
140
- filename: attachment.name,
141
- url: localPath,
142
- }
143
- } catch (error) {
144
- logger.error(`Error downloading attachment ${attachment.name}:`, error)
145
- return null
146
+ return {
147
+ type: 'file' as const,
148
+ mime: attachment.contentType || 'application/octet-stream',
149
+ filename: attachment.name,
150
+ url: localPath,
146
151
  }
147
152
  }),
148
153
  )
@@ -207,9 +212,9 @@ export function getToolSummaryText(part: Part): string {
207
212
  return ''
208
213
  }
209
214
 
215
+ // Task tool display is handled via subtask part in session-handler (shows label like explore-1)
210
216
  if (part.tool === 'task') {
211
- const description = (part.state.input?.description as string) || ''
212
- return description ? `_${escapeInlineMarkdown(description)}_` : ''
217
+ return ''
213
218
  }
214
219
 
215
220
  if (part.tool === 'skill') {
@@ -253,9 +258,15 @@ export function formatTodoList(part: Part): string {
253
258
  return `${num} **${escapeInlineMarkdown(content)}**`
254
259
  }
255
260
 
256
- export function formatPart(part: Part): string {
261
+ export function formatPart(part: Part, prefix?: string): string {
262
+ const pfx = prefix ? `${prefix}: ` : ''
263
+
257
264
  if (part.type === 'text') {
258
265
  if (!part.text?.trim()) return ''
266
+ // For subtask text, always use bullet with prefix
267
+ if (prefix) {
268
+ return `⬥ ${pfx}${part.text.trim()}`
269
+ }
259
270
  const trimmed = part.text.trimStart()
260
271
  const firstChar = trimmed[0] || ''
261
272
  const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
@@ -268,11 +279,11 @@ export function formatPart(part: Part): string {
268
279
 
269
280
  if (part.type === 'reasoning') {
270
281
  if (!part.text?.trim()) return ''
271
- return `┣ thinking`
282
+ return `┣ ${pfx}thinking`
272
283
  }
273
284
 
274
285
  if (part.type === 'file') {
275
- return `📄 ${part.filename || 'File'}`
286
+ return prefix ? `📄 ${pfx}${part.filename || 'File'}` : `📄 ${part.filename || 'File'}`
276
287
  }
277
288
 
278
289
  if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
@@ -280,16 +291,17 @@ export function formatPart(part: Part): string {
280
291
  }
281
292
 
282
293
  if (part.type === 'agent') {
283
- return `┣ agent ${part.id}`
294
+ return `┣ ${pfx}agent ${part.id}`
284
295
  }
285
296
 
286
297
  if (part.type === 'snapshot') {
287
- return `┣ snapshot ${part.snapshot}`
298
+ return `┣ ${pfx}snapshot ${part.snapshot}`
288
299
  }
289
300
 
290
301
  if (part.type === 'tool') {
291
302
  if (part.tool === 'todowrite') {
292
- return formatTodoList(part)
303
+ const formatted = formatTodoList(part)
304
+ return prefix && formatted ? `┣ ${pfx}${formatted}` : formatted
293
305
  }
294
306
 
295
307
  // Question tool is handled via Discord dropdowns, not text
@@ -297,6 +309,11 @@ export function formatPart(part: Part): string {
297
309
  return ''
298
310
  }
299
311
 
312
+ // Task tool display is handled in session-handler with proper label
313
+ if (part.tool === 'task') {
314
+ return ''
315
+ }
316
+
300
317
  if (part.state.status === 'pending') {
301
318
  return ''
302
319
  }
@@ -331,7 +348,7 @@ export function formatPart(part: Part): string {
331
348
  }
332
349
  return '┣'
333
350
  })()
334
- return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
351
+ return `${icon} ${pfx}${part.tool} ${toolTitle} ${summaryText}`.trim()
335
352
  }
336
353
 
337
354
  logger.warn('Unknown part type:', part)
package/src/opencode.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // OpenCode server process manager.
2
2
  // Spawns and maintains OpenCode API servers per project directory,
3
3
  // handles automatic restarts on failure, and provides typed SDK clients.
4
+ // Uses errore for type-safe error handling.
4
5
 
5
6
  import { spawn, type ChildProcess } from 'node:child_process'
6
7
  import fs from 'node:fs'
@@ -10,7 +11,15 @@ import {
10
11
  createOpencodeClient as createOpencodeClientV2,
11
12
  type OpencodeClient as OpencodeClientV2,
12
13
  } from '@opencode-ai/sdk/v2'
14
+ import * as errore from 'errore'
13
15
  import { createLogger } from './logger.js'
16
+ import {
17
+ DirectoryNotAccessibleError,
18
+ ServerStartError,
19
+ ServerNotReadyError,
20
+ FetchError,
21
+ type OpenCodeErrors,
22
+ } from './errors.js'
14
23
 
15
24
  const opencodeLogger = createLogger('OPENCODE')
16
25
 
@@ -44,46 +53,39 @@ async function getOpenPort(): Promise<number> {
44
53
  })
45
54
  }
46
55
 
47
- async function waitForServer(port: number, maxAttempts = 30): Promise<boolean> {
56
+ async function waitForServer(port: number, maxAttempts = 30): Promise<ServerStartError | true> {
48
57
  for (let i = 0; i < maxAttempts; i++) {
49
- try {
50
- const endpoints = [
51
- `http://127.0.0.1:${port}/api/health`,
52
- `http://127.0.0.1:${port}/`,
53
- `http://127.0.0.1:${port}/api`,
54
- ]
55
-
56
- for (const endpoint of endpoints) {
57
- try {
58
- const response = await fetch(endpoint)
59
- if (response.status < 500) {
60
- return true
61
- }
62
- const body = await response.text()
63
- // Fatal errors that won't resolve with retrying
64
- if (body.includes('BunInstallFailedError')) {
65
- throw new Error(`Server failed to start: ${body.slice(0, 200)}`)
66
- }
67
- } catch (e) {
68
- // Re-throw fatal errors
69
- if ((e as Error).message?.includes('Server failed to start')) {
70
- throw e
71
- }
72
- }
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
73
76
  }
74
- } catch (e) {
75
- // Re-throw fatal errors that won't resolve with retrying
76
- if ((e as Error).message?.includes('Server failed to start')) {
77
- throw e
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) })
78
81
  }
79
- opencodeLogger.debug(`Server polling attempt failed: ${(e as Error).message}`)
80
82
  }
81
83
  await new Promise((resolve) => setTimeout(resolve, 1000))
82
84
  }
83
- throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`)
85
+ return new ServerStartError({ port, reason: `Server did not start after ${maxAttempts} seconds` })
84
86
  }
85
87
 
86
- export async function initializeOpencodeForDirectory(directory: string) {
88
+ export async function initializeOpencodeForDirectory(directory: string): Promise<OpenCodeErrors | (() => OpencodeClient)> {
87
89
  const existing = opencodeServers.get(directory)
88
90
  if (existing && !existing.process.killed) {
89
91
  opencodeLogger.log(
@@ -92,19 +94,21 @@ export async function initializeOpencodeForDirectory(directory: string) {
92
94
  return () => {
93
95
  const entry = opencodeServers.get(directory)
94
96
  if (!entry?.client) {
95
- throw new Error(
96
- `OpenCode server for directory "${directory}" is in an error state (no client available)`,
97
- )
97
+ throw new ServerNotReadyError({ directory })
98
98
  }
99
99
  return entry.client
100
100
  }
101
101
  }
102
102
 
103
103
  // Verify directory exists and is accessible before spawning
104
- try {
105
- fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK)
106
- } catch {
107
- throw new Error(`Directory does not exist or is not accessible: ${directory}`)
104
+ const accessCheck = errore.tryFn({
105
+ try: () => {
106
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK)
107
+ },
108
+ catch: () => new DirectoryNotAccessibleError({ directory }),
109
+ })
110
+ if (accessCheck instanceof Error) {
111
+ return accessCheck
108
112
  }
109
113
 
110
114
  const port = await getOpenPort()
@@ -159,8 +163,10 @@ export async function initializeOpencodeForDirectory(directory: string) {
159
163
  opencodeLogger.log(
160
164
  `Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`,
161
165
  )
162
- initializeOpencodeForDirectory(directory).catch((e) => {
163
- opencodeLogger.error(`Failed to restart opencode server:`, e)
166
+ initializeOpencodeForDirectory(directory).then((result) => {
167
+ if (result instanceof Error) {
168
+ opencodeLogger.error(`Failed to restart opencode server:`, result)
169
+ }
164
170
  })
165
171
  } else {
166
172
  opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`)
@@ -170,17 +176,16 @@ export async function initializeOpencodeForDirectory(directory: string) {
170
176
  }
171
177
  })
172
178
 
173
- try {
174
- await waitForServer(port)
175
- opencodeLogger.log(`Server ready on port ${port}`)
176
- } catch (e) {
179
+ const waitResult = await waitForServer(port)
180
+ if (waitResult instanceof Error) {
177
181
  // Dump buffered logs on failure
178
182
  opencodeLogger.error(`Server failed to start for ${directory}:`)
179
183
  for (const line of logBuffer) {
180
184
  opencodeLogger.error(` ${line}`)
181
185
  }
182
- throw e
186
+ return waitResult
183
187
  }
188
+ opencodeLogger.log(`Server ready on port ${port}`)
184
189
 
185
190
  const baseUrl = `http://127.0.0.1:${port}`
186
191
  const fetchWithTimeout = (request: Request) =>
@@ -209,9 +214,7 @@ export async function initializeOpencodeForDirectory(directory: string) {
209
214
  return () => {
210
215
  const entry = opencodeServers.get(directory)
211
216
  if (!entry?.client) {
212
- throw new Error(
213
- `OpenCode server for directory "${directory}" is in an error state (no client available)`,
214
- )
217
+ throw new ServerNotReadyError({ directory })
215
218
  }
216
219
  return entry.client
217
220
  }