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/dist/markdown.js CHANGED
@@ -1,10 +1,20 @@
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.
5
+ import * as errore from 'errore';
6
+ import { createTaggedError } from 'errore';
4
7
  import * as yaml from 'js-yaml';
5
8
  import { formatDateTime } from './utils.js';
6
9
  import { extractNonXmlContent } from './xml.js';
7
10
  import { createLogger } from './logger.js';
11
+ import { SessionNotFoundError, MessagesNotFoundError } from './errors.js';
12
+ // Generic error for unexpected exceptions in async operations
13
+ class UnexpectedError extends createTaggedError({
14
+ name: 'UnexpectedError',
15
+ message: '$message',
16
+ }) {
17
+ }
8
18
  const markdownLogger = createLogger('MARKDOWN');
9
19
  export class ShareMarkdown {
10
20
  client;
@@ -14,7 +24,7 @@ export class ShareMarkdown {
14
24
  /**
15
25
  * Generate a markdown representation of a session
16
26
  * @param options Configuration options
17
- * @returns Markdown string representation of the session
27
+ * @returns Error or markdown string
18
28
  */
19
29
  async generate(options) {
20
30
  const { sessionID, includeSystemInfo, lastAssistantOnly } = options;
@@ -23,7 +33,7 @@ export class ShareMarkdown {
23
33
  path: { id: sessionID },
24
34
  });
25
35
  if (!sessionResponse.data) {
26
- throw new Error(`Session ${sessionID} not found`);
36
+ return new SessionNotFoundError({ sessionId: sessionID });
27
37
  }
28
38
  const session = sessionResponse.data;
29
39
  // Get all messages
@@ -31,7 +41,7 @@ export class ShareMarkdown {
31
41
  path: { id: sessionID },
32
42
  });
33
43
  if (!messagesResponse.data) {
34
- throw new Error(`No messages found for session ${sessionID}`);
44
+ return new MessagesNotFoundError({ sessionId: sessionID });
35
45
  }
36
46
  const messages = messagesResponse.data;
37
47
  // If lastAssistantOnly, filter to only the last assistant message
@@ -211,98 +221,103 @@ export class ShareMarkdown {
211
221
  * Includes system prompt (optional), user messages, assistant text,
212
222
  * and tool calls in compact form (name + params only, no output).
213
223
  */
214
- export async function getCompactSessionContext({ client, sessionId, includeSystemPrompt = false, maxMessages = 20, }) {
215
- try {
216
- const messagesResponse = await client.session.messages({
217
- path: { id: sessionId },
218
- });
219
- const messages = messagesResponse.data || [];
220
- const lines = [];
221
- // Get system prompt if requested
222
- // Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
223
- // 1. session.system field (if available in future SDK versions)
224
- // 2. synthetic text part in first assistant message (current approach)
225
- if (includeSystemPrompt && messages.length > 0) {
226
- const firstAssistant = messages.find((m) => m.info.role === 'assistant');
227
- if (firstAssistant) {
228
- // look for text part marked as synthetic (system prompt)
229
- const systemPart = (firstAssistant.parts || []).find((p) => p.type === 'text' && p.synthetic === true);
230
- if (systemPart && 'text' in systemPart && systemPart.text) {
231
- lines.push('[System Prompt]');
232
- const truncated = systemPart.text.slice(0, 3000);
233
- lines.push(truncated);
234
- if (systemPart.text.length > 3000) {
235
- lines.push('...(truncated)');
224
+ export function getCompactSessionContext({ client, sessionId, includeSystemPrompt = false, maxMessages = 20, }) {
225
+ return errore.tryAsync({
226
+ try: async () => {
227
+ const messagesResponse = await client.session.messages({
228
+ path: { id: sessionId },
229
+ });
230
+ const messages = messagesResponse.data || [];
231
+ const lines = [];
232
+ // Get system prompt if requested
233
+ // Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
234
+ // 1. session.system field (if available in future SDK versions)
235
+ // 2. synthetic text part in first assistant message (current approach)
236
+ if (includeSystemPrompt && messages.length > 0) {
237
+ const firstAssistant = messages.find((m) => m.info.role === 'assistant');
238
+ if (firstAssistant) {
239
+ // look for text part marked as synthetic (system prompt)
240
+ const systemPart = (firstAssistant.parts || []).find((p) => p.type === 'text' && p.synthetic === true);
241
+ if (systemPart && 'text' in systemPart && systemPart.text) {
242
+ lines.push('[System Prompt]');
243
+ const truncated = systemPart.text.slice(0, 3000);
244
+ lines.push(truncated);
245
+ if (systemPart.text.length > 3000) {
246
+ lines.push('...(truncated)');
247
+ }
248
+ lines.push('');
236
249
  }
237
- lines.push('');
238
- }
239
- }
240
- }
241
- // Process recent messages
242
- const recentMessages = messages.slice(-maxMessages);
243
- for (const msg of recentMessages) {
244
- if (msg.info.role === 'user') {
245
- const textParts = (msg.parts || [])
246
- .filter((p) => p.type === 'text' && 'text' in p)
247
- .map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
248
- .filter(Boolean);
249
- if (textParts.length > 0) {
250
- lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`);
251
- lines.push('');
252
250
  }
253
251
  }
254
- else if (msg.info.role === 'assistant') {
255
- // Get assistant text parts (non-synthetic, non-empty)
256
- const textParts = (msg.parts || [])
257
- .filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
258
- .map((p) => ('text' in p ? p.text : ''))
259
- .filter(Boolean);
260
- if (textParts.length > 0) {
261
- lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`);
262
- lines.push('');
252
+ // Process recent messages
253
+ const recentMessages = messages.slice(-maxMessages);
254
+ for (const msg of recentMessages) {
255
+ if (msg.info.role === 'user') {
256
+ const textParts = (msg.parts || [])
257
+ .filter((p) => p.type === 'text' && 'text' in p)
258
+ .map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
259
+ .filter(Boolean);
260
+ if (textParts.length > 0) {
261
+ lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`);
262
+ lines.push('');
263
+ }
263
264
  }
264
- // Get tool calls in compact form (name + params only)
265
- const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed');
266
- for (const part of toolParts) {
267
- if (part.type === 'tool' && 'tool' in part && 'state' in part) {
268
- const toolName = part.tool;
269
- // skip noisy tools
270
- if (toolName === 'todoread' || toolName === 'todowrite') {
271
- continue;
265
+ else if (msg.info.role === 'assistant') {
266
+ // Get assistant text parts (non-synthetic, non-empty)
267
+ const textParts = (msg.parts || [])
268
+ .filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
269
+ .map((p) => ('text' in p ? p.text : ''))
270
+ .filter(Boolean);
271
+ if (textParts.length > 0) {
272
+ lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`);
273
+ lines.push('');
274
+ }
275
+ // Get tool calls in compact form (name + params only)
276
+ const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed');
277
+ for (const part of toolParts) {
278
+ if (part.type === 'tool' && 'tool' in part && 'state' in part) {
279
+ const toolName = part.tool;
280
+ // skip noisy tools
281
+ if (toolName === 'todoread' || toolName === 'todowrite') {
282
+ continue;
283
+ }
284
+ const input = part.state?.input || {};
285
+ const normalize = (value) => value.replace(/\s+/g, ' ').trim();
286
+ // compact params: just key=value on one line
287
+ const params = Object.entries(input)
288
+ .map(([k, v]) => {
289
+ const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100);
290
+ return `${k}=${normalize(val)}`;
291
+ })
292
+ .join(', ');
293
+ lines.push(`[Tool ${toolName}]: ${params}`);
272
294
  }
273
- const input = part.state?.input || {};
274
- // compact params: just key=value on one line
275
- const params = Object.entries(input)
276
- .map(([k, v]) => {
277
- const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100);
278
- return `${k}=${val}`;
279
- })
280
- .join(', ');
281
- lines.push(`[Tool ${toolName}]: ${params}`);
282
295
  }
283
296
  }
284
297
  }
285
- }
286
- return lines.join('\n').slice(0, 8000);
287
- }
288
- catch (e) {
289
- markdownLogger.error('Failed to get compact session context:', e);
290
- return '';
291
- }
298
+ return lines.join('\n').slice(0, 8000);
299
+ },
300
+ catch: (e) => {
301
+ markdownLogger.error('Failed to get compact session context:', e);
302
+ return new UnexpectedError({ message: 'Failed to get compact session context', cause: e });
303
+ },
304
+ });
292
305
  }
293
306
  /**
294
307
  * Get the last session for a directory (excluding the current one).
295
308
  */
296
- export async function getLastSessionId({ client, excludeSessionId, }) {
297
- try {
298
- const sessionsResponse = await client.session.list();
299
- const sessions = sessionsResponse.data || [];
300
- // Sessions are sorted by time, get the most recent one that isn't the current
301
- const lastSession = sessions.find((s) => s.id !== excludeSessionId);
302
- return lastSession?.id || null;
303
- }
304
- catch (e) {
305
- markdownLogger.error('Failed to get last session:', e);
306
- return null;
307
- }
309
+ export function getLastSessionId({ client, excludeSessionId, }) {
310
+ return errore.tryAsync({
311
+ try: async () => {
312
+ const sessionsResponse = await client.session.list();
313
+ const sessions = sessionsResponse.data || [];
314
+ // Sessions are sorted by time, get the most recent one that isn't the current
315
+ const lastSession = sessions.find((s) => s.id !== excludeSessionId);
316
+ return lastSession?.id || null;
317
+ },
318
+ catch: (e) => {
319
+ markdownLogger.error('Failed to get last session:', e);
320
+ return new UnexpectedError({ message: 'Failed to get last session', cause: e });
321
+ },
322
+ });
308
323
  }
@@ -1,6 +1,7 @@
1
1
  import { test, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { spawn } from 'child_process';
3
3
  import { OpencodeClient } from '@opencode-ai/sdk';
4
+ import * as errore from 'errore';
4
5
  import { ShareMarkdown, getCompactSessionContext } from './markdown.js';
5
6
  let serverProcess;
6
7
  let client;
@@ -100,10 +101,12 @@ test('generate markdown from first available session', async () => {
100
101
  // Create markdown exporter
101
102
  const exporter = new ShareMarkdown(client);
102
103
  // Generate markdown with system info
103
- const markdown = await exporter.generate({
104
+ const markdownResult = await exporter.generate({
104
105
  sessionID,
105
106
  includeSystemInfo: true,
106
107
  });
108
+ expect(errore.isOk(markdownResult)).toBe(true);
109
+ const markdown = errore.unwrap(markdownResult);
107
110
  console.log(`Generated markdown length: ${markdown.length} characters`);
108
111
  // Basic assertions
109
112
  expect(markdown).toBeTruthy();
@@ -233,12 +236,14 @@ test('generate markdown from multiple sessions', async () => {
233
236
  // test for getCompactSessionContext - disabled in CI since it requires a specific session
234
237
  test.skipIf(process.env.CI)('getCompactSessionContext generates compact format', async () => {
235
238
  const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM';
236
- const context = await getCompactSessionContext({
239
+ const contextResult = await getCompactSessionContext({
237
240
  client,
238
241
  sessionId,
239
242
  includeSystemPrompt: true,
240
243
  maxMessages: 15,
241
244
  });
245
+ expect(errore.isOk(contextResult)).toBe(true);
246
+ const context = errore.unwrap(contextResult);
242
247
  console.log(`Generated compact context length: ${context.length} characters`);
243
248
  expect(context).toBeTruthy();
244
249
  expect(context.length).toBeGreaterThan(0);
@@ -248,12 +253,14 @@ test.skipIf(process.env.CI)('getCompactSessionContext generates compact format',
248
253
  });
249
254
  test.skipIf(process.env.CI)('getCompactSessionContext without system prompt', async () => {
250
255
  const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM';
251
- const context = await getCompactSessionContext({
256
+ const contextResult = await getCompactSessionContext({
252
257
  client,
253
258
  sessionId,
254
259
  includeSystemPrompt: false,
255
260
  maxMessages: 10,
256
261
  });
262
+ expect(errore.isOk(contextResult)).toBe(true);
263
+ const context = errore.unwrap(contextResult);
257
264
  console.log(`Generated compact context (no system) length: ${context.length} characters`);
258
265
  expect(context).toBeTruthy();
259
266
  // should NOT have system prompt
@@ -3,7 +3,9 @@
3
3
  // handles file attachments, and provides tool summary generation.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import * as errore from 'errore';
6
7
  import { createLogger } from './logger.js';
8
+ import { FetchError } from './errors.js';
7
9
  const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments');
8
10
  const logger = createLogger('FORMATTING');
9
11
  /**
@@ -56,18 +58,18 @@ export async function getTextAttachments(message) {
56
58
  return '';
57
59
  }
58
60
  const textContents = await Promise.all(textAttachments.map(async (attachment) => {
59
- try {
60
- const response = await fetch(attachment.url);
61
- if (!response.ok) {
62
- return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
63
- }
64
- const text = await response.text();
65
- return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
61
+ const response = await errore.tryAsync({
62
+ try: () => fetch(attachment.url),
63
+ catch: (e) => new FetchError({ url: attachment.url, cause: e }),
64
+ });
65
+ if (response instanceof Error) {
66
+ return `<attachment filename="${attachment.name}" error="${response.message}" />`;
66
67
  }
67
- catch (error) {
68
- const errMsg = error instanceof Error ? error.message : String(error);
69
- return `<attachment filename="${attachment.name}" error="${errMsg}" />`;
68
+ if (!response.ok) {
69
+ return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
70
70
  }
71
+ const text = await response.text();
72
+ return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
71
73
  }));
72
74
  return textContents.join('\n\n');
73
75
  }
@@ -84,27 +86,28 @@ export async function getFileAttachments(message) {
84
86
  fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
85
87
  }
86
88
  const results = await Promise.all(fileAttachments.map(async (attachment) => {
87
- try {
88
- const response = await fetch(attachment.url);
89
- if (!response.ok) {
90
- logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`);
91
- return null;
92
- }
93
- const buffer = Buffer.from(await response.arrayBuffer());
94
- const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`);
95
- fs.writeFileSync(localPath, buffer);
96
- logger.log(`Downloaded attachment to ${localPath}`);
97
- return {
98
- type: 'file',
99
- mime: attachment.contentType || 'application/octet-stream',
100
- filename: attachment.name,
101
- url: localPath,
102
- };
89
+ const response = await errore.tryAsync({
90
+ try: () => fetch(attachment.url),
91
+ catch: (e) => new FetchError({ url: attachment.url, cause: e }),
92
+ });
93
+ if (response instanceof Error) {
94
+ logger.error(`Error downloading attachment ${attachment.name}:`, response.message);
95
+ return null;
103
96
  }
104
- catch (error) {
105
- logger.error(`Error downloading attachment ${attachment.name}:`, error);
97
+ if (!response.ok) {
98
+ logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`);
106
99
  return null;
107
100
  }
101
+ const buffer = Buffer.from(await response.arrayBuffer());
102
+ const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`);
103
+ fs.writeFileSync(localPath, buffer);
104
+ logger.log(`Downloaded attachment to ${localPath}`);
105
+ return {
106
+ type: 'file',
107
+ mime: attachment.contentType || 'application/octet-stream',
108
+ filename: attachment.name,
109
+ url: localPath,
110
+ };
108
111
  }));
109
112
  return results.filter((r) => r !== null);
110
113
  }
@@ -157,9 +160,9 @@ export function getToolSummaryText(part) {
157
160
  if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
158
161
  return '';
159
162
  }
163
+ // Task tool display is handled via subtask part in session-handler (shows label like explore-1)
160
164
  if (part.tool === 'task') {
161
- const description = part.state.input?.description || '';
162
- return description ? `_${escapeInlineMarkdown(description)}_` : '';
165
+ return '';
163
166
  }
164
167
  if (part.tool === 'skill') {
165
168
  const name = part.state.input?.name || '';
@@ -197,10 +200,15 @@ export function formatTodoList(part) {
197
200
  const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1);
198
201
  return `${num} **${escapeInlineMarkdown(content)}**`;
199
202
  }
200
- export function formatPart(part) {
203
+ export function formatPart(part, prefix) {
204
+ const pfx = prefix ? `${prefix}: ` : '';
201
205
  if (part.type === 'text') {
202
206
  if (!part.text?.trim())
203
207
  return '';
208
+ // For subtask text, always use bullet with prefix
209
+ if (prefix) {
210
+ return `⬥ ${pfx}${part.text.trim()}`;
211
+ }
204
212
  const trimmed = part.text.trimStart();
205
213
  const firstChar = trimmed[0] || '';
206
214
  const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|'];
@@ -213,28 +221,33 @@ export function formatPart(part) {
213
221
  if (part.type === 'reasoning') {
214
222
  if (!part.text?.trim())
215
223
  return '';
216
- return `┣ thinking`;
224
+ return `┣ ${pfx}thinking`;
217
225
  }
218
226
  if (part.type === 'file') {
219
- return `📄 ${part.filename || 'File'}`;
227
+ return prefix ? `📄 ${pfx}${part.filename || 'File'}` : `📄 ${part.filename || 'File'}`;
220
228
  }
221
229
  if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
222
230
  return '';
223
231
  }
224
232
  if (part.type === 'agent') {
225
- return `┣ agent ${part.id}`;
233
+ return `┣ ${pfx}agent ${part.id}`;
226
234
  }
227
235
  if (part.type === 'snapshot') {
228
- return `┣ snapshot ${part.snapshot}`;
236
+ return `┣ ${pfx}snapshot ${part.snapshot}`;
229
237
  }
230
238
  if (part.type === 'tool') {
231
239
  if (part.tool === 'todowrite') {
232
- return formatTodoList(part);
240
+ const formatted = formatTodoList(part);
241
+ return prefix && formatted ? `┣ ${pfx}${formatted}` : formatted;
233
242
  }
234
243
  // Question tool is handled via Discord dropdowns, not text
235
244
  if (part.tool === 'question') {
236
245
  return '';
237
246
  }
247
+ // Task tool display is handled in session-handler with proper label
248
+ if (part.tool === 'task') {
249
+ return '';
250
+ }
238
251
  if (part.state.status === 'pending') {
239
252
  return '';
240
253
  }
@@ -270,7 +283,7 @@ export function formatPart(part) {
270
283
  }
271
284
  return '┣';
272
285
  })();
273
- return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
286
+ return `${icon} ${pfx}${part.tool} ${toolTitle} ${summaryText}`.trim();
274
287
  }
275
288
  logger.warn('Unknown part type:', part);
276
289
  return '';
package/dist/opencode.js CHANGED
@@ -1,12 +1,15 @@
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
  import { spawn } from 'node:child_process';
5
6
  import fs from 'node:fs';
6
7
  import net from 'node:net';
7
8
  import { createOpencodeClient } from '@opencode-ai/sdk';
8
9
  import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
10
+ import * as errore from 'errore';
9
11
  import { createLogger } from './logger.js';
12
+ import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
10
13
  const opencodeLogger = createLogger('OPENCODE');
11
14
  const opencodeServers = new Map();
12
15
  const serverRetryCount = new Map();
@@ -30,42 +33,33 @@ async function getOpenPort() {
30
33
  }
31
34
  async function waitForServer(port, maxAttempts = 30) {
32
35
  for (let i = 0; i < maxAttempts; i++) {
33
- try {
34
- const endpoints = [
35
- `http://127.0.0.1:${port}/api/health`,
36
- `http://127.0.0.1:${port}/`,
37
- `http://127.0.0.1:${port}/api`,
38
- ];
39
- for (const endpoint of endpoints) {
40
- try {
41
- const response = await fetch(endpoint);
42
- if (response.status < 500) {
43
- return true;
44
- }
45
- const body = await response.text();
46
- // Fatal errors that won't resolve with retrying
47
- if (body.includes('BunInstallFailedError')) {
48
- throw new Error(`Server failed to start: ${body.slice(0, 200)}`);
49
- }
50
- }
51
- catch (e) {
52
- // Re-throw fatal errors
53
- if (e.message?.includes('Server failed to start')) {
54
- throw e;
55
- }
56
- }
36
+ const endpoints = [
37
+ `http://127.0.0.1:${port}/api/health`,
38
+ `http://127.0.0.1:${port}/`,
39
+ `http://127.0.0.1:${port}/api`,
40
+ ];
41
+ for (const endpoint of endpoints) {
42
+ const response = await errore.tryAsync({
43
+ try: () => fetch(endpoint),
44
+ catch: (e) => new FetchError({ url: endpoint, cause: e }),
45
+ });
46
+ if (response instanceof Error) {
47
+ // Connection refused or other transient errors - continue polling
48
+ opencodeLogger.debug(`Server polling attempt failed: ${response.message}`);
49
+ continue;
57
50
  }
58
- }
59
- catch (e) {
60
- // Re-throw fatal errors that won't resolve with retrying
61
- if (e.message?.includes('Server failed to start')) {
62
- throw e;
51
+ if (response.status < 500) {
52
+ return true;
53
+ }
54
+ const body = await response.text();
55
+ // Fatal errors that won't resolve with retrying
56
+ if (body.includes('BunInstallFailedError')) {
57
+ return new ServerStartError({ port, reason: body.slice(0, 200) });
63
58
  }
64
- opencodeLogger.debug(`Server polling attempt failed: ${e.message}`);
65
59
  }
66
60
  await new Promise((resolve) => setTimeout(resolve, 1000));
67
61
  }
68
- throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`);
62
+ return new ServerStartError({ port, reason: `Server did not start after ${maxAttempts} seconds` });
69
63
  }
70
64
  export async function initializeOpencodeForDirectory(directory) {
71
65
  const existing = opencodeServers.get(directory);
@@ -74,17 +68,20 @@ export async function initializeOpencodeForDirectory(directory) {
74
68
  return () => {
75
69
  const entry = opencodeServers.get(directory);
76
70
  if (!entry?.client) {
77
- throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
71
+ throw new ServerNotReadyError({ directory });
78
72
  }
79
73
  return entry.client;
80
74
  };
81
75
  }
82
76
  // Verify directory exists and is accessible before spawning
83
- try {
84
- fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
85
- }
86
- catch {
87
- throw new Error(`Directory does not exist or is not accessible: ${directory}`);
77
+ const accessCheck = errore.tryFn({
78
+ try: () => {
79
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
80
+ },
81
+ catch: () => new DirectoryNotAccessibleError({ directory }),
82
+ });
83
+ if (accessCheck instanceof Error) {
84
+ return accessCheck;
88
85
  }
89
86
  const port = await getOpenPort();
90
87
  const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
@@ -127,8 +124,10 @@ export async function initializeOpencodeForDirectory(directory) {
127
124
  if (retryCount < 5) {
128
125
  serverRetryCount.set(directory, retryCount + 1);
129
126
  opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
130
- initializeOpencodeForDirectory(directory).catch((e) => {
131
- opencodeLogger.error(`Failed to restart opencode server:`, e);
127
+ initializeOpencodeForDirectory(directory).then((result) => {
128
+ if (result instanceof Error) {
129
+ opencodeLogger.error(`Failed to restart opencode server:`, result);
130
+ }
132
131
  });
133
132
  }
134
133
  else {
@@ -139,18 +138,16 @@ export async function initializeOpencodeForDirectory(directory) {
139
138
  serverRetryCount.delete(directory);
140
139
  }
141
140
  });
142
- try {
143
- await waitForServer(port);
144
- opencodeLogger.log(`Server ready on port ${port}`);
145
- }
146
- catch (e) {
141
+ const waitResult = await waitForServer(port);
142
+ if (waitResult instanceof Error) {
147
143
  // Dump buffered logs on failure
148
144
  opencodeLogger.error(`Server failed to start for ${directory}:`);
149
145
  for (const line of logBuffer) {
150
146
  opencodeLogger.error(` ${line}`);
151
147
  }
152
- throw e;
148
+ return waitResult;
153
149
  }
150
+ opencodeLogger.log(`Server ready on port ${port}`);
154
151
  const baseUrl = `http://127.0.0.1:${port}`;
155
152
  const fetchWithTimeout = (request) => fetch(request, {
156
153
  // @ts-ignore
@@ -173,7 +170,7 @@ export async function initializeOpencodeForDirectory(directory) {
173
170
  return () => {
174
171
  const entry = opencodeServers.get(directory);
175
172
  if (!entry?.client) {
176
- throw new Error(`OpenCode server for directory "${directory}" is in an error state (no client available)`);
173
+ throw new ServerNotReadyError({ directory });
177
174
  }
178
175
  return entry.client;
179
176
  };