@skelm/opencode 0.4.2 → 0.4.4

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/README.md CHANGED
@@ -40,7 +40,7 @@ export default defineConfig({
40
40
  A workflow that applies a fix to a codebase:
41
41
 
42
42
  ```ts
43
- // workflows/fix-bug.workflow.ts
43
+ // workflows/fix-bug.workflow.mts
44
44
  import { agent, pipeline } from 'skelm'
45
45
  import { z } from 'zod'
46
46
 
@@ -74,7 +74,7 @@ export default pipeline({
74
74
  ## What's exported
75
75
 
76
76
  ```ts
77
- export { createOpencodeBackend, createOpencodeAcpBackend } from './backend.js'
77
+ export { createOpencodeBackend } from './backend.js'
78
78
  export { createOpencodeBackendFromConfig } from './factory.js'
79
79
  export { OpencodeProvider, createOpencodeProvider } from './provider.js'
80
80
  export { OpencodeClientWrapper } from './client.js'
package/dist/backend.d.ts CHANGED
@@ -15,25 +15,17 @@ export declare function createOpencodeBackend(options: OpencodeBackendOptions):
15
15
  * Custom error types for opencode backend
16
16
  */
17
17
  export declare class BackendAuthenticationError extends Error {
18
- constructor(message: string);
18
+ constructor(message: string, options?: {
19
+ cause?: unknown;
20
+ });
19
21
  }
20
22
  export declare class BackendRateLimitError extends Error {
21
- constructor(message: string);
23
+ constructor(message: string, options?: {
24
+ cause?: unknown;
25
+ });
22
26
  }
23
27
  export declare class BackendTimeoutError extends Error {
24
- constructor(message: string);
28
+ constructor(message: string, options?: {
29
+ cause?: unknown;
30
+ });
25
31
  }
26
- /**
27
- * Create an opencode backend with ACP compatibility mode
28
- *
29
- * This runs opencode as a subprocess via ACP instead of using the SDK directly.
30
- * Useful for testing or when API access is restricted.
31
- */
32
- export declare function createOpencodeAcpBackend(options: {
33
- command?: string;
34
- args?: readonly string[];
35
- cwd?: string;
36
- env?: NodeJS.ProcessEnv;
37
- id?: string;
38
- label?: string;
39
- }): SkelmBackend;
package/dist/backend.js CHANGED
@@ -1,6 +1,6 @@
1
- import { loadSkillBodies } from '@skelm/core';
1
+ import { PermissionDeniedError, loadSkillBodies } from '@skelm/core';
2
2
  import { OpencodeClientWrapper } from './client.js';
3
- import { buildPermissionAuditEntry, mapSkelmPermissionsToOpencode, validatePermissions, } from './permission-mapper.js';
3
+ import { mapSkelmPermissionsToOpencode, validatePermissions } from './permission-mapper.js';
4
4
  /**
5
5
  * SkelmBackend implementation for opencode.ai with full permission enforcement
6
6
  *
@@ -20,6 +20,13 @@ export function createOpencodeBackend(options) {
20
20
  skills: true,
21
21
  modelSelection: options.model !== undefined,
22
22
  toolPermissions: 'native',
23
+ // Image content is threaded into opencode as a `FilePartInput` alongside
24
+ // the text part (see buildOpencodePromptParts in client.ts); whether the
25
+ // *upstream* model actually processes images depends on the configured
26
+ // opencode model (Sonnet, GPT-4o, etc. — see opencode docs). Set
27
+ // `vision: false` via the second capability block above if a deployment
28
+ // pins a known text-only model.
29
+ vision: options.vision ?? true,
23
30
  };
24
31
  // Single client instance per backend — server started on first call and
25
32
  // kept alive for subsequent calls (one session per prompt call).
@@ -40,14 +47,9 @@ export function createOpencodeBackend(options) {
40
47
  }),
41
48
  });
42
49
  if (!permissionResult.allowed) {
43
- // Log audit entry for denied permissions
44
- const auditEntry = buildPermissionAuditEntry('unknown', // runId not available in BackendContext
45
- 'unknown', // stepId not available in AgentRequest
46
- policy, permissionResult);
47
- // In production, this would be written to the audit log
48
- // For now, we log to console
49
- console.warn('Permission denied:', JSON.stringify(auditEntry, null, 2));
50
- throw new Error(`Permission denied: ${permissionResult.denied.join(', ')}`);
50
+ // Throw the typed error so the runner's audit writer (the single
51
+ // durable record) captures the denial; no parallel console log.
52
+ throw new PermissionDeniedError(`opencode: permission denied: ${permissionResult.denied.join(', ')}`);
51
53
  }
52
54
  // Load skills and inject into system prompt before forwarding
53
55
  const enrichedRequest = await injectSkills(request, context);
@@ -59,14 +61,20 @@ export function createOpencodeBackend(options) {
59
61
  }
60
62
  catch (error) {
61
63
  if (error instanceof Error) {
64
+ // The opencode SDK surfaces these conditions as plain Errors
65
+ // with descriptive messages; substring matching is the only
66
+ // signal available. The original error is attached as `cause`
67
+ // so callers retain stack and any provider-specific fields.
62
68
  if (error.message.includes('Authentication')) {
63
- throw new BackendAuthenticationError(`Opencode authentication failed: ${error.message}`);
69
+ throw new BackendAuthenticationError(`Opencode authentication failed: ${error.message}`, { cause: error });
64
70
  }
65
71
  if (error.message.includes('Rate limit')) {
66
- throw new BackendRateLimitError(`Opencode rate limit exceeded: ${error.message}`);
72
+ throw new BackendRateLimitError(`Opencode rate limit exceeded: ${error.message}`, {
73
+ cause: error,
74
+ });
67
75
  }
68
76
  if (error.message.includes('timed out')) {
69
- throw new BackendTimeoutError(error.message);
77
+ throw new BackendTimeoutError(error.message, { cause: error });
70
78
  }
71
79
  }
72
80
  throw error;
@@ -123,51 +131,20 @@ function createEmptyPolicy() {
123
131
  * Custom error types for opencode backend
124
132
  */
125
133
  export class BackendAuthenticationError extends Error {
126
- constructor(message) {
127
- super(message);
134
+ constructor(message, options) {
135
+ super(message, options);
128
136
  this.name = 'BackendAuthenticationError';
129
137
  }
130
138
  }
131
139
  export class BackendRateLimitError extends Error {
132
- constructor(message) {
133
- super(message);
140
+ constructor(message, options) {
141
+ super(message, options);
134
142
  this.name = 'BackendRateLimitError';
135
143
  }
136
144
  }
137
145
  export class BackendTimeoutError extends Error {
138
- constructor(message) {
139
- super(message);
146
+ constructor(message, options) {
147
+ super(message, options);
140
148
  this.name = 'BackendTimeoutError';
141
149
  }
142
150
  }
143
- /**
144
- * Create an opencode backend with ACP compatibility mode
145
- *
146
- * This runs opencode as a subprocess via ACP instead of using the SDK directly.
147
- * Useful for testing or when API access is restricted.
148
- */
149
- export function createOpencodeAcpBackend(options) {
150
- const command = options.command ?? 'opencode';
151
- const args = options.args ?? ['acp'];
152
- const capabilities = {
153
- prompt: true,
154
- streaming: true,
155
- sessionLifecycle: true,
156
- mcp: true,
157
- skills: false, // ACP mode has limited skill control
158
- modelSelection: false,
159
- toolPermissions: 'unsupported', // ACP forwards permissions as metadata only
160
- };
161
- const backend = {
162
- id: options.id ?? 'opencode-acp',
163
- capabilities,
164
- ...(options.label !== undefined && { label: options.label }),
165
- async run(request, context) {
166
- // ACP mode: permissions are advisory only
167
- // Forward the request to opencode via stdio
168
- // This is a placeholder - full ACP implementation would use the AcpClient
169
- throw new Error('ACP mode not yet implemented. Use SDK mode with createOpencodeBackend() instead.');
170
- },
171
- };
172
- return backend;
173
- }
package/dist/client.js CHANGED
@@ -8,7 +8,35 @@
8
8
  // #3 — Non-blocking promptAsync + SSE stream instead of blocking session.prompt()
9
9
  import { spawn } from 'node:child_process'; // @subprocess-ok: spawns opencode serve for HTTP backend
10
10
  import { createOpencodeClient } from '@opencode-ai/sdk';
11
- import { TrustEnforcer } from '@skelm/core';
11
+ import { BackendSessionError, RunCancelledError, TrustEnforcer, extractPromptText, } from '@skelm/core';
12
+ /**
13
+ * Map a skelm `AgentRequest.prompt` (string or `ContentPart[]`) onto
14
+ * opencode's prompt-parts shape. Text parts become `{type:'text'}`; image
15
+ * parts are forwarded as `{type:'file'}` with a base64 data URL, matching
16
+ * the opencode SDK's documented `FilePartInput` schema. opencode's own
17
+ * vision-capable models (Sonnet, GPT-4o, etc.) consume these attachments
18
+ * directly; non-vision models surface their own provider error which
19
+ * propagates as a thrown step failure.
20
+ */
21
+ function buildOpencodePromptParts(prompt) {
22
+ if (typeof prompt === 'string') {
23
+ return [{ type: 'text', text: prompt }];
24
+ }
25
+ const parts = [];
26
+ for (const part of prompt) {
27
+ if (part.type === 'text') {
28
+ parts.push({ type: 'text', text: part.text });
29
+ }
30
+ else if (part.type === 'image') {
31
+ parts.push({
32
+ type: 'file',
33
+ mime: part.mimeType,
34
+ url: `data:${part.mimeType};base64,${part.data}`,
35
+ });
36
+ }
37
+ }
38
+ return parts.length > 0 ? parts : [{ type: 'text', text: extractPromptText(prompt) }];
39
+ }
12
40
  // Module-singleton process-exit hook. Without this, every OpencodeClientWrapper
13
41
  // that called process.once('SIGTERM', …) would add a fresh listener, tripping
14
42
  // Node's MaxListeners=10 warning under any non-trivial backend churn. Wrappers
@@ -104,20 +132,32 @@ export class OpencodeClientWrapper {
104
132
  liveChildren.add(proc);
105
133
  ensureSignalHook();
106
134
  let resolved = false;
107
- let buf = '';
108
- const tryParse = (chunk) => {
135
+ let stdoutBuf = '';
136
+ const MAX_BUF_BYTES = 64 * 1024;
137
+ // opencode prints its listen URL on stdout. Parse stdout only and
138
+ // anchor strictly to a loopback URL so noisy stderr (or LLM output
139
+ // proxied through stderr) cannot redirect the gateway to an
140
+ // attacker-controlled URL. The buffer is capped so a flood of
141
+ // non-matching output can't be retained indefinitely.
142
+ const LISTEN_RE = /(?:^|\n)opencode(?: \w+)? listening on (https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+)(?=\s|$)/;
143
+ const tryParseStdout = (chunk) => {
109
144
  if (resolved)
110
145
  return;
111
- buf += chunk.toString();
112
- const m = buf.match(/listening on (https?:\/\/[^\s]+)/);
146
+ stdoutBuf += chunk.toString('utf8');
147
+ if (stdoutBuf.length > MAX_BUF_BYTES) {
148
+ stdoutBuf = stdoutBuf.slice(stdoutBuf.length - MAX_BUF_BYTES);
149
+ }
150
+ const m = stdoutBuf.match(LISTEN_RE);
113
151
  if (m?.[1]) {
114
152
  resolved = true;
115
153
  this.client = createOpencodeClient({ baseUrl: m[1].trim() });
116
154
  resolve();
117
155
  }
118
156
  };
119
- proc.stdout?.on('data', tryParse);
120
- proc.stderr?.on('data', tryParse);
157
+ proc.stdout?.on('data', tryParseStdout);
158
+ // stderr is consumed only to drain the pipe and avoid backpressure;
159
+ // its content is no longer parsed for the listen URL.
160
+ proc.stderr?.on('data', () => { });
121
161
  proc.once('error', (err) => {
122
162
  if (!resolved)
123
163
  reject(err);
@@ -147,7 +187,7 @@ export class OpencodeClientWrapper {
147
187
  async prompt(request, signal, timeoutMs = 300_000, resolvedPolicy, onPartial) {
148
188
  await this.ensureStarted();
149
189
  if (!this.client)
150
- throw new Error('opencode serve not ready');
190
+ throw new BackendSessionError('opencode serve not ready', 'opencode');
151
191
  const cwd = request.cwd ?? process.cwd();
152
192
  // Forward each per-step McpServerConfig to opencode so its model sees the
153
193
  // namespaced tools. The opencode subprocess persists across calls, so the
@@ -158,7 +198,7 @@ export class OpencodeClientWrapper {
158
198
  }
159
199
  const sessResult = await this.client.session.create({ query: { directory: cwd } });
160
200
  if (!sessResult.data) {
161
- throw new Error(`session.create failed: ${JSON.stringify(sessResult.error)}`);
201
+ throw new BackendSessionError(`session.create failed: ${JSON.stringify(sessResult.error)}`, 'opencode', { cause: sessResult.error });
162
202
  }
163
203
  const sessionId = sessResult.data.id;
164
204
  // (#3) Subscribe to the global SSE stream BEFORE calling promptAsync so
@@ -174,7 +214,7 @@ export class OpencodeClientWrapper {
174
214
  const promptResult = await this.client.session.promptAsync({
175
215
  path: { id: sessionId },
176
216
  body: {
177
- parts: [{ type: 'text', text: request.prompt }],
217
+ parts: buildOpencodePromptParts(request.prompt),
178
218
  ...(request.system !== undefined && { system: request.system }),
179
219
  ...(resolvedPolicy !== undefined && {
180
220
  tools: buildOpencodeToolsFromPolicy(resolvedPolicy),
@@ -182,7 +222,7 @@ export class OpencodeClientWrapper {
182
222
  },
183
223
  });
184
224
  if (!promptResult.data) {
185
- throw new Error(`session.promptAsync failed: ${JSON.stringify(promptResult.error)}`);
225
+ throw new BackendSessionError(`session.promptAsync failed: ${JSON.stringify(promptResult.error)}`, 'opencode', { cause: promptResult.error });
186
226
  }
187
227
  return await this._collectFromStream(stream, sessionId, signal, onPartial, resolvedPolicy);
188
228
  }
@@ -209,7 +249,7 @@ export class OpencodeClientWrapper {
209
249
  if (event.type === 'session.error') {
210
250
  const props = event.properties;
211
251
  if (!props.sessionID || props.sessionID === sessionId) {
212
- throw new Error(`opencode session error: ${JSON.stringify(props.error)}`);
252
+ throw new BackendSessionError(`opencode session error: ${JSON.stringify(props.error)}`, 'opencode', { cause: props.error });
213
253
  }
214
254
  }
215
255
  // Opencode pauses the session and emits permission.asked when a tool
@@ -260,7 +300,7 @@ export class OpencodeClientWrapper {
260
300
  }
261
301
  }
262
302
  if (signal.aborted)
263
- throw new Error('opencode agent aborted');
303
+ throw new RunCancelledError();
264
304
  const text = [...textParts.values()].join('');
265
305
  return { text: text.trim(), stopReason: 'end_turn' };
266
306
  }
@@ -276,7 +316,7 @@ export class OpencodeClientWrapper {
276
316
  if (this.attachedMcp.has(server.id))
277
317
  continue;
278
318
  if (server.transport !== 'stdio') {
279
- throw new Error(`opencode backend currently only forwards stdio MCP servers; "${server.id}" uses ${server.transport}`);
319
+ throw new BackendSessionError(`opencode backend currently only forwards stdio MCP servers; "${server.id}" uses ${server.transport}`, 'opencode');
280
320
  }
281
321
  const command = [server.command, ...(server.args ?? [])];
282
322
  const result = await this.client.mcp.add({
@@ -291,7 +331,7 @@ export class OpencodeClientWrapper {
291
331
  },
292
332
  });
293
333
  if (result.error !== undefined) {
294
- throw new Error(`opencode mcp.add(${server.id}) failed: ${JSON.stringify(result.error)}`);
334
+ throw new BackendSessionError(`opencode mcp.add(${server.id}) failed: ${JSON.stringify(result.error)}`, 'opencode', { cause: result.error });
295
335
  }
296
336
  this.attachedMcp.add(server.id);
297
337
  }
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Full integration with opencode.ai coding agent via the official SDK,
5
5
  * with granular permission enforcement and multi-agent support.
6
6
  */
7
- export { createOpencodeBackend, createOpencodeAcpBackend } from './backend.js';
7
+ export { createOpencodeBackend } from './backend.js';
8
8
  export { createOpencodeBackendFromConfig } from './factory.js';
9
9
  export type { OpencodeBackendConfig } from './factory.js';
10
10
  export { OpencodeProvider, createOpencodeProvider } from './provider.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Full integration with opencode.ai coding agent via the official SDK,
5
5
  * with granular permission enforcement and multi-agent support.
6
6
  */
7
- export { createOpencodeBackend, createOpencodeAcpBackend } from './backend.js';
7
+ export { createOpencodeBackend } from './backend.js';
8
8
  export { createOpencodeBackendFromConfig } from './factory.js';
9
9
  export { OpencodeProvider, createOpencodeProvider } from './provider.js';
10
10
  export { OpencodeClientWrapper } from './client.js';
package/dist/provider.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Implements ProviderPluginBase for the opencode.ai coding agent.
5
5
  */
6
- import { ProviderPluginBase } from '@skelm/core';
6
+ import { BackendConfigError, ProviderPluginBase } from '@skelm/core';
7
7
  import { createOpencodeBackend } from './backend.js';
8
8
  /**
9
9
  * Opencode provider implementation
@@ -83,7 +83,7 @@ export class OpencodeProvider extends ProviderPluginBase {
83
83
  // Validate API key
84
84
  const apiKey = config.apiKey ?? process.env.OPENCODE_API_KEY;
85
85
  if (!apiKey) {
86
- throw new Error('Opencode API key is required. Set apiKey config or OPENCODE_API_KEY env var.');
86
+ throw new BackendConfigError('Opencode API key is required. Set apiKey config or OPENCODE_API_KEY env var.', 'opencode');
87
87
  }
88
88
  }
89
89
  /**
@@ -91,7 +91,7 @@ export class OpencodeProvider extends ProviderPluginBase {
91
91
  */
92
92
  async createBackend(options) {
93
93
  if (!this.initialized) {
94
- throw new Error('Provider must be initialized before creating backends');
94
+ throw new BackendConfigError('Provider must be initialized before creating backends', 'opencode');
95
95
  }
96
96
  const config = {};
97
97
  const apiKey = this.config.apiKey ?? process.env.OPENCODE_API_KEY;
@@ -199,7 +199,7 @@ export class OpencodeProvider extends ProviderPluginBase {
199
199
  validateConfig(config) {
200
200
  const apiKey = config.apiKey ?? process.env.OPENCODE_API_KEY;
201
201
  if (!apiKey) {
202
- throw new Error('Opencode API key is required');
202
+ throw new BackendConfigError('Opencode API key is required', 'opencode');
203
203
  }
204
204
  return config;
205
205
  }
package/dist/types.d.ts CHANGED
@@ -28,6 +28,14 @@ export interface OpencodeBackendOptions {
28
28
  maxRetries?: number;
29
29
  /** Log level */
30
30
  logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'off';
31
+ /**
32
+ * Advertise `capabilities.vision`. Defaults to `true`: image content is
33
+ * forwarded to opencode as a `FilePartInput` and the upstream model
34
+ * decides whether it can process it. Set `false` when targeting an
35
+ * opencode model known to be text-only — the framework gate then rejects
36
+ * image prompts at step start with no opencode session ever spawned.
37
+ */
38
+ vision?: boolean;
31
39
  /** Egress proxy URL for outbound HTTP requests (e.g., 'http://127.0.0.1:14739'). */
32
40
  egressProxyUrl?: string;
33
41
  /** Egress token for proxy authentication (injected into subprocesses). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skelm/opencode",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Opencode.ai backend for skelm with full permission enforcement",
5
5
  "license": "MIT",
6
6
  "author": "Scott Glover <scottgl@gmail.com>",
@@ -48,10 +48,10 @@
48
48
  "@opencode-ai/sdk": "^1.14.33"
49
49
  },
50
50
  "peerDependencies": {
51
- "@skelm/core": "^0.4.2"
51
+ "@skelm/core": "^0.4.4"
52
52
  },
53
53
  "devDependencies": {
54
- "@skelm/core": "^0.4.2",
54
+ "@skelm/core": "^0.4.4",
55
55
  "@types/node": "^20.10.0",
56
56
  "typescript": "^5.3.0",
57
57
  "vitest": "^1.0.0"