kimaki 0.4.37 → 0.4.39
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.
- package/dist/channel-management.js +6 -2
- package/dist/cli.js +41 -15
- package/dist/commands/abort.js +15 -6
- package/dist/commands/add-project.js +9 -0
- package/dist/commands/agent.js +114 -20
- package/dist/commands/fork.js +13 -2
- package/dist/commands/model.js +12 -0
- package/dist/commands/remove-project.js +26 -16
- package/dist/commands/resume.js +9 -0
- package/dist/commands/session.js +13 -0
- package/dist/commands/share.js +10 -1
- package/dist/commands/undo-redo.js +13 -4
- package/dist/database.js +24 -5
- package/dist/discord-bot.js +38 -31
- package/dist/errors.js +110 -0
- package/dist/genai-worker.js +18 -16
- package/dist/interaction-handler.js +6 -1
- package/dist/markdown.js +96 -85
- package/dist/markdown.test.js +10 -3
- package/dist/message-formatting.js +50 -37
- package/dist/opencode.js +43 -46
- package/dist/session-handler.js +136 -8
- package/dist/system-message.js +2 -0
- package/dist/tools.js +18 -8
- package/dist/voice-handler.js +48 -25
- package/dist/voice.js +159 -131
- package/package.json +2 -1
- package/src/channel-management.ts +6 -2
- package/src/cli.ts +67 -19
- package/src/commands/abort.ts +17 -7
- package/src/commands/add-project.ts +9 -0
- package/src/commands/agent.ts +160 -25
- package/src/commands/fork.ts +18 -7
- package/src/commands/model.ts +12 -0
- package/src/commands/remove-project.ts +28 -16
- package/src/commands/resume.ts +9 -0
- package/src/commands/session.ts +13 -0
- package/src/commands/share.ts +11 -1
- package/src/commands/undo-redo.ts +15 -6
- package/src/database.ts +26 -4
- package/src/discord-bot.ts +42 -34
- package/src/errors.ts +208 -0
- package/src/genai-worker.ts +20 -17
- package/src/interaction-handler.ts +7 -1
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +111 -95
- package/src/message-formatting.ts +55 -38
- package/src/opencode.ts +52 -49
- package/src/session-handler.ts +164 -11
- package/src/system-message.ts +2 -0
- package/src/tools.ts +18 -8
- package/src/voice-handler.ts +48 -23
- package/src/voice.ts +195 -148
package/src/markdown.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
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'
|
|
6
8
|
import * as yaml from 'js-yaml'
|
|
7
9
|
import { formatDateTime } from './utils.js'
|
|
8
10
|
import { extractNonXmlContent } from './xml.js'
|
|
9
11
|
import { createLogger } from './logger.js'
|
|
12
|
+
import { SessionNotFoundError, MessagesNotFoundError } from './errors.js'
|
|
13
|
+
|
|
14
|
+
// Generic error for unexpected exceptions in async operations
|
|
15
|
+
class UnexpectedError extends errore.TaggedError('UnexpectedError')<{
|
|
16
|
+
message: string
|
|
17
|
+
cause?: unknown
|
|
18
|
+
}>() {}
|
|
10
19
|
|
|
11
20
|
const markdownLogger = createLogger('MARKDOWN')
|
|
12
21
|
|
|
@@ -16,13 +25,13 @@ export class ShareMarkdown {
|
|
|
16
25
|
/**
|
|
17
26
|
* Generate a markdown representation of a session
|
|
18
27
|
* @param options Configuration options
|
|
19
|
-
* @returns
|
|
28
|
+
* @returns Error or markdown string
|
|
20
29
|
*/
|
|
21
30
|
async generate(options: {
|
|
22
31
|
sessionID: string
|
|
23
32
|
includeSystemInfo?: boolean
|
|
24
33
|
lastAssistantOnly?: boolean
|
|
25
|
-
}): Promise<string> {
|
|
34
|
+
}): Promise<SessionNotFoundError | MessagesNotFoundError | string> {
|
|
26
35
|
const { sessionID, includeSystemInfo, lastAssistantOnly } = options
|
|
27
36
|
|
|
28
37
|
// Get session info
|
|
@@ -30,7 +39,7 @@ export class ShareMarkdown {
|
|
|
30
39
|
path: { id: sessionID },
|
|
31
40
|
})
|
|
32
41
|
if (!sessionResponse.data) {
|
|
33
|
-
|
|
42
|
+
return new SessionNotFoundError({ sessionId: sessionID })
|
|
34
43
|
}
|
|
35
44
|
const session = sessionResponse.data
|
|
36
45
|
|
|
@@ -39,7 +48,7 @@ export class ShareMarkdown {
|
|
|
39
48
|
path: { id: sessionID },
|
|
40
49
|
})
|
|
41
50
|
if (!messagesResponse.data) {
|
|
42
|
-
|
|
51
|
+
return new MessagesNotFoundError({ sessionId: sessionID })
|
|
43
52
|
}
|
|
44
53
|
const messages = messagesResponse.data
|
|
45
54
|
|
|
@@ -234,7 +243,7 @@ export class ShareMarkdown {
|
|
|
234
243
|
* Includes system prompt (optional), user messages, assistant text,
|
|
235
244
|
* and tool calls in compact form (name + params only, no output).
|
|
236
245
|
*/
|
|
237
|
-
export
|
|
246
|
+
export function getCompactSessionContext({
|
|
238
247
|
client,
|
|
239
248
|
sessionId,
|
|
240
249
|
includeSystemPrompt = false,
|
|
@@ -244,114 +253,121 @@ export async function getCompactSessionContext({
|
|
|
244
253
|
sessionId: string
|
|
245
254
|
includeSystemPrompt?: boolean
|
|
246
255
|
maxMessages?: number
|
|
247
|
-
}): Promise<string> {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
256
|
+
}): Promise<UnexpectedError | string> {
|
|
257
|
+
return errore.tryAsync({
|
|
258
|
+
try: async () => {
|
|
259
|
+
const messagesResponse = await client.session.messages({
|
|
260
|
+
path: { id: sessionId },
|
|
261
|
+
})
|
|
262
|
+
const messages = messagesResponse.data || []
|
|
263
|
+
|
|
264
|
+
const lines: string[] = []
|
|
265
|
+
|
|
266
|
+
// Get system prompt if requested
|
|
267
|
+
// Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
|
|
268
|
+
// 1. session.system field (if available in future SDK versions)
|
|
269
|
+
// 2. synthetic text part in first assistant message (current approach)
|
|
270
|
+
if (includeSystemPrompt && messages.length > 0) {
|
|
271
|
+
const firstAssistant = messages.find((m) => m.info.role === 'assistant')
|
|
272
|
+
if (firstAssistant) {
|
|
273
|
+
// look for text part marked as synthetic (system prompt)
|
|
274
|
+
const systemPart = (firstAssistant.parts || []).find(
|
|
275
|
+
(p) => p.type === 'text' && (p as any).synthetic === true,
|
|
276
|
+
)
|
|
277
|
+
if (systemPart && 'text' in systemPart && systemPart.text) {
|
|
278
|
+
lines.push('[System Prompt]')
|
|
279
|
+
const truncated = systemPart.text.slice(0, 3000)
|
|
280
|
+
lines.push(truncated)
|
|
281
|
+
if (systemPart.text.length > 3000) {
|
|
282
|
+
lines.push('...(truncated)')
|
|
283
|
+
}
|
|
284
|
+
lines.push('')
|
|
273
285
|
}
|
|
274
|
-
lines.push('')
|
|
275
286
|
}
|
|
276
287
|
}
|
|
277
|
-
}
|
|
278
288
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
289
|
+
// Process recent messages
|
|
290
|
+
const recentMessages = messages.slice(-maxMessages)
|
|
291
|
+
|
|
292
|
+
for (const msg of recentMessages) {
|
|
293
|
+
if (msg.info.role === 'user') {
|
|
294
|
+
const textParts = (msg.parts || [])
|
|
295
|
+
.filter((p) => p.type === 'text' && 'text' in p)
|
|
296
|
+
.map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
|
|
297
|
+
.filter(Boolean)
|
|
298
|
+
if (textParts.length > 0) {
|
|
299
|
+
lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`)
|
|
300
|
+
lines.push('')
|
|
301
|
+
}
|
|
302
|
+
} else if (msg.info.role === 'assistant') {
|
|
303
|
+
// Get assistant text parts (non-synthetic, non-empty)
|
|
304
|
+
const textParts = (msg.parts || [])
|
|
305
|
+
.filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
|
|
306
|
+
.map((p) => ('text' in p ? p.text : ''))
|
|
307
|
+
.filter(Boolean)
|
|
308
|
+
if (textParts.length > 0) {
|
|
309
|
+
lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`)
|
|
310
|
+
lines.push('')
|
|
311
|
+
}
|
|
302
312
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
+
// Get tool calls in compact form (name + params only)
|
|
314
|
+
const toolParts = (msg.parts || []).filter(
|
|
315
|
+
(p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed',
|
|
316
|
+
)
|
|
317
|
+
for (const part of toolParts) {
|
|
318
|
+
if (part.type === 'tool' && 'tool' in part && 'state' in part) {
|
|
319
|
+
const toolName = part.tool
|
|
320
|
+
// skip noisy tools
|
|
321
|
+
if (toolName === 'todoread' || toolName === 'todowrite') {
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
const input = part.state?.input || {}
|
|
325
|
+
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
|
|
326
|
+
// compact params: just key=value on one line
|
|
327
|
+
const params = Object.entries(input)
|
|
328
|
+
.map(([k, v]) => {
|
|
329
|
+
const val =
|
|
330
|
+
typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100)
|
|
331
|
+
return `${k}=${normalize(val)}`
|
|
332
|
+
})
|
|
333
|
+
.join(', ')
|
|
334
|
+
lines.push(`[Tool ${toolName}]: ${params}`)
|
|
313
335
|
}
|
|
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
336
|
}
|
|
325
337
|
}
|
|
326
338
|
}
|
|
327
|
-
}
|
|
328
339
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
340
|
+
return lines.join('\n').slice(0, 8000)
|
|
341
|
+
},
|
|
342
|
+
catch: (e) => {
|
|
343
|
+
markdownLogger.error('Failed to get compact session context:', e)
|
|
344
|
+
return new UnexpectedError({ message: 'Failed to get compact session context', cause: e })
|
|
345
|
+
},
|
|
346
|
+
})
|
|
334
347
|
}
|
|
335
348
|
|
|
336
349
|
/**
|
|
337
350
|
* Get the last session for a directory (excluding the current one).
|
|
338
351
|
*/
|
|
339
|
-
export
|
|
352
|
+
export function getLastSessionId({
|
|
340
353
|
client,
|
|
341
354
|
excludeSessionId,
|
|
342
355
|
}: {
|
|
343
356
|
client: OpencodeClient
|
|
344
357
|
excludeSessionId?: string
|
|
345
|
-
}): Promise<string | null> {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
358
|
+
}): Promise<UnexpectedError | (string | null)> {
|
|
359
|
+
return errore.tryAsync({
|
|
360
|
+
try: async () => {
|
|
361
|
+
const sessionsResponse = await client.session.list()
|
|
362
|
+
const sessions = sessionsResponse.data || []
|
|
363
|
+
|
|
364
|
+
// Sessions are sorted by time, get the most recent one that isn't the current
|
|
365
|
+
const lastSession = sessions.find((s) => s.id !== excludeSessionId)
|
|
366
|
+
return lastSession?.id || null
|
|
367
|
+
},
|
|
368
|
+
catch: (e) => {
|
|
369
|
+
markdownLogger.error('Failed to get last session:', e)
|
|
370
|
+
return new UnexpectedError({ message: 'Failed to get last session', cause: e })
|
|
371
|
+
},
|
|
372
|
+
})
|
|
357
373
|
}
|
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 (errore.isError(response)) {
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 (errore.isError(response)) {
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
144
|
+
logger.log(`Downloaded attachment to ${localPath}`)
|
|
136
145
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
56
|
+
async function waitForServer(port: number, maxAttempts = 30): Promise<ServerStartError | true> {
|
|
48
57
|
for (let i = 0; i < maxAttempts; i++) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
try
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 (errore.isError(response)) {
|
|
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
|
-
|
|
75
|
-
//
|
|
76
|
-
if (
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 (errore.isError(accessCheck)) {
|
|
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).
|
|
163
|
-
|
|
166
|
+
initializeOpencodeForDirectory(directory).then((result) => {
|
|
167
|
+
if (errore.isError(result)) {
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
opencodeLogger.log(`Server ready on port ${port}`)
|
|
176
|
-
} catch (e) {
|
|
179
|
+
const waitResult = await waitForServer(port)
|
|
180
|
+
if (errore.isError(waitResult)) {
|
|
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
|
-
|
|
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
|
|
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
|
}
|