@gakim-digital/dexter-bridge 0.5.21 → 0.11.0

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.
@@ -0,0 +1,607 @@
1
+ import crypto from 'node:crypto';
2
+ import net from 'node:net';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { mkdtempSync, rmSync } from 'node:fs';
6
+ import crossSpawn from 'cross-spawn';
7
+ import { createOpencodeClient } from '@opencode-ai/sdk';
8
+ import { normalizeCompanionTokenUsage } from '../agentOutput.js';
9
+ import {
10
+ assertOutcomeResponseContract,
11
+ STRUCTURED_RESPONSE_CONTRACTS,
12
+ } from '../protocol.js';
13
+
14
+ function availablePort() {
15
+ return new Promise((resolve, reject) => {
16
+ const server = net.createServer();
17
+ server.unref();
18
+ server.once('error', reject);
19
+ server.listen(0, '127.0.0.1', () => {
20
+ const address = server.address();
21
+ const port = typeof address === 'object' && address ? address.port : 0;
22
+ server.close((error) => (error ? reject(error) : resolve(port)));
23
+ });
24
+ });
25
+ }
26
+
27
+ function waitForServer(child, client, timeoutMs = 10_000) {
28
+ return new Promise((resolve, reject) => {
29
+ let settled = false;
30
+ const timer = setTimeout(() => {
31
+ settled = true;
32
+ cleanup();
33
+ reject(new Error('OpenCode server did not start before the timeout.'));
34
+ }, timeoutMs);
35
+ timer.unref?.();
36
+ const failed = (error) => {
37
+ if (settled) return;
38
+ settled = true;
39
+ cleanup();
40
+ reject(error instanceof Error ? error : new Error(String(error)));
41
+ };
42
+ const exited = (code) =>
43
+ failed(new Error(`OpenCode server exited before startup completed (${code ?? 'unknown'}).`));
44
+ function cleanup() {
45
+ clearTimeout(timer);
46
+ child.off('error', failed);
47
+ child.off('exit', exited);
48
+ }
49
+ child.once('error', failed);
50
+ child.once('exit', exited);
51
+ const probe = async () => {
52
+ while (!settled) {
53
+ try {
54
+ await client.provider.list();
55
+ settled = true;
56
+ cleanup();
57
+ resolve();
58
+ return;
59
+ } catch {
60
+ await new Promise((next) => setTimeout(next, 100));
61
+ }
62
+ }
63
+ };
64
+ void probe();
65
+ });
66
+ }
67
+
68
+ function basicAuth(password) {
69
+ return `Basic ${Buffer.from(`opencode:${password}`, 'utf8').toString('base64')}`;
70
+ }
71
+
72
+ function modelParts(value) {
73
+ const raw = String(value || '').trim();
74
+ const slash = raw.indexOf('/');
75
+ return slash > 0 ? { providerID: raw.slice(0, slash), modelID: raw.slice(slash + 1) } : undefined;
76
+ }
77
+
78
+ function responseText(response) {
79
+ const data = response?.data || response;
80
+ const parts = Array.isArray(data?.parts) ? data.parts : [];
81
+ return parts
82
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
83
+ .map((part) => part.text)
84
+ .join('');
85
+ }
86
+
87
+ function openCodeEventSessionId(event) {
88
+ const properties = event?.properties || {};
89
+ return (
90
+ properties.sessionID ||
91
+ properties.sessionId ||
92
+ properties.info?.sessionID ||
93
+ properties.info?.sessionId ||
94
+ properties.part?.sessionID ||
95
+ properties.part?.sessionId ||
96
+ null
97
+ );
98
+ }
99
+
100
+ function openCodeProgressKind(event) {
101
+ if (event?.type === 'message.part.updated') {
102
+ return event.properties?.part?.type === 'reasoning' ? 'reasoning_delta' : 'output_delta';
103
+ }
104
+ if (event?.type === 'message.updated') return 'item_completed';
105
+ if (event?.type === 'session.status') {
106
+ return event.properties?.status?.type === 'retry' ? 'provider_retry' : 'item_started';
107
+ }
108
+ return null;
109
+ }
110
+
111
+ export function openCodeOutcomeConfig(harnessTools, directory) {
112
+ const framerHarness = Array.isArray(harnessTools?.definitions)
113
+ && harnessTools.definitions.some((tool) =>
114
+ String(tool?.name || '').startsWith('framer_'));
115
+ return {
116
+ share: 'disabled',
117
+ autoupdate: false,
118
+ snapshot: false,
119
+ plugin: [],
120
+ mcp: harnessTools?.mcpServer
121
+ ? {
122
+ instawebai: {
123
+ type: 'local',
124
+ command: [
125
+ harnessTools.mcpServer.command,
126
+ ...(harnessTools.mcpServer.args || []),
127
+ ],
128
+ cwd: harnessTools.mcpServer.cwd || directory,
129
+ environment: harnessTools.mcpServer.environment || {},
130
+ enabled: true,
131
+ timeout: 30_000,
132
+ },
133
+ }
134
+ : {},
135
+ lsp: false,
136
+ formatter: false,
137
+ instructions: [],
138
+ permission: {
139
+ edit: framerHarness ? 'deny' : 'allow',
140
+ bash: framerHarness ? 'deny' : 'allow',
141
+ webfetch: framerHarness ? 'deny' : 'allow',
142
+ doom_loop: 'deny',
143
+ external_directory: 'deny',
144
+ },
145
+ tools: {
146
+ bash: !framerHarness,
147
+ edit: !framerHarness,
148
+ write: !framerHarness,
149
+ read: !framerHarness,
150
+ glob: !framerHarness,
151
+ grep: !framerHarness,
152
+ webfetch: !framerHarness,
153
+ websearch: false,
154
+ codesearch: false,
155
+ task: false,
156
+ },
157
+ };
158
+ }
159
+
160
+ export function createOpenCodeAdapter({ profile, env = process.env, trace } = {}) {
161
+ if (!profile?.id) throw new Error('An OpenCode runtime profile is required.');
162
+ const command = profile.command || env.DEXTER_BRIDGE_OPENCODE_BIN || 'opencode';
163
+ const sessions = new Map();
164
+ const activeTurns = new Map();
165
+ let server = null;
166
+ let starting = null;
167
+ const workspace = mkdtempSync(path.join(os.tmpdir(), 'instawebai-opencode-'));
168
+
169
+ async function ensureServer() {
170
+ if (server) return server;
171
+ if (starting) return starting;
172
+ starting = (async () => {
173
+ const port = await availablePort();
174
+ const password = crypto.randomBytes(32).toString('base64url');
175
+ const config = {
176
+ share: 'disabled',
177
+ autoupdate: false,
178
+ snapshot: false,
179
+ plugin: [],
180
+ mcp: {},
181
+ lsp: false,
182
+ formatter: false,
183
+ instructions: [],
184
+ permission: {
185
+ edit: 'deny',
186
+ bash: 'deny',
187
+ webfetch: 'deny',
188
+ doom_loop: 'deny',
189
+ external_directory: 'deny',
190
+ },
191
+ tools: {
192
+ bash: false,
193
+ edit: false,
194
+ write: false,
195
+ read: false,
196
+ glob: false,
197
+ grep: false,
198
+ webfetch: false,
199
+ websearch: false,
200
+ codesearch: false,
201
+ task: false,
202
+ },
203
+ };
204
+ const child = crossSpawn(command, ['serve', '--hostname=127.0.0.1', `--port=${port}`], {
205
+ cwd: workspace,
206
+ env: {
207
+ ...env,
208
+ OPENCODE_SERVER_PASSWORD: password,
209
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
210
+ },
211
+ stdio: ['ignore', 'pipe', 'pipe'],
212
+ });
213
+ child.stdout?.resume();
214
+ child.stderr?.resume();
215
+ const url = `http://127.0.0.1:${port}`;
216
+ const client = createOpencodeClient({
217
+ baseUrl: url,
218
+ directory: workspace,
219
+ headers: { Authorization: basicAuth(password) },
220
+ throwOnError: true,
221
+ });
222
+ try {
223
+ await waitForServer(child, client);
224
+ } catch (error) {
225
+ if (!child.killed) child.kill('SIGTERM');
226
+ throw error;
227
+ }
228
+ server = { child, client, url };
229
+ child.once('exit', () => {
230
+ if (server?.child === child) server = null;
231
+ });
232
+ return server;
233
+ })();
234
+ try {
235
+ return await starting;
236
+ } finally {
237
+ starting = null;
238
+ }
239
+ }
240
+
241
+ async function detect() {
242
+ return new Promise((resolve) => {
243
+ const child = crossSpawn(command, ['--version'], {
244
+ env,
245
+ stdio: ['ignore', 'pipe', 'pipe'],
246
+ });
247
+ let output = '';
248
+ child.stdout?.on('data', (chunk) => {
249
+ output = `${output}${chunk.toString('utf8')}`.slice(-4096);
250
+ });
251
+ child.stderr?.on('data', (chunk) => {
252
+ output = `${output}${chunk.toString('utf8')}`.slice(-4096);
253
+ });
254
+ child.once('error', (error) =>
255
+ resolve({
256
+ ok: false,
257
+ installed: false,
258
+ signedIn: false,
259
+ agent: 'opencode',
260
+ error: error.message,
261
+ }),
262
+ );
263
+ child.once('exit', (code) =>
264
+ resolve({
265
+ ok: code === 0,
266
+ installed: code === 0,
267
+ signedIn: code === 0,
268
+ agent: 'opencode',
269
+ version: output.trim() || null,
270
+ ...(code === 0 ? {} : { error: output.trim() || 'OpenCode is unavailable.' }),
271
+ }),
272
+ );
273
+ });
274
+ }
275
+
276
+ async function models() {
277
+ const active = await ensureServer();
278
+ const response = await active.client.provider.list();
279
+ const data = response?.data || response;
280
+ const providers = Array.isArray(data?.all) ? data.all : Array.isArray(data) ? data : [];
281
+ const connected = new Set(Array.isArray(data?.connected) ? data.connected : []);
282
+ return providers
283
+ .filter((provider) => connected.has(provider.id))
284
+ .flatMap((provider) =>
285
+ Object.values(provider?.models || {}).map((model) => ({
286
+ id: `opencode:${provider.id}/${model.id}`,
287
+ agent: 'opencode',
288
+ provider: provider.id,
289
+ displayName: model.name || model.id,
290
+ invocationName: `${provider.id}/${model.id}`,
291
+ costTier: '$$',
292
+ description: `OpenCode ${provider.name || provider.id} model.`,
293
+ capabilities: {
294
+ tools: model.tool_call !== false,
295
+ vision: Boolean(model.attachment || model.modalities?.input?.includes?.('image')),
296
+ structuredOutput: true,
297
+ sessionResume: true,
298
+ },
299
+ })),
300
+ );
301
+ }
302
+
303
+ async function ensureSession(key) {
304
+ if (sessions.has(key)) return sessions.get(key);
305
+ const active = await ensureServer();
306
+ const response = await active.client.session.create({
307
+ body: {
308
+ title: 'InstaWebAI model session',
309
+ },
310
+ });
311
+ const sessionId = response?.data?.id || response?.id;
312
+ if (!sessionId) throw new Error('OpenCode did not create a session.');
313
+ sessions.set(key, sessionId);
314
+ return sessionId;
315
+ }
316
+
317
+ async function runModelTurn({
318
+ runId,
319
+ sessionId,
320
+ prompt,
321
+ model,
322
+ timeoutMs = 180_000,
323
+ maxDurationMs = 15 * 60_000,
324
+ onProgress,
325
+ } = {}) {
326
+ const turnKey = sessionId || runId;
327
+ if (!turnKey) throw new Error('OpenCode model turn requires a session id.');
328
+ const active = await ensureServer();
329
+ const openCodeSessionId = await ensureSession(turnKey);
330
+ const controller = new AbortController();
331
+ let deadlineReached = false;
332
+ let deadlineAbort = Promise.resolve();
333
+ const timer = setTimeout(() => {
334
+ deadlineReached = true;
335
+ controller.abort();
336
+ deadlineAbort = active.client.session
337
+ .abort({
338
+ path: { id: openCodeSessionId },
339
+ })
340
+ .catch(() => undefined);
341
+ }, maxDurationMs);
342
+ timer.unref?.();
343
+ activeTurns.set(turnKey, { controller, openCodeSessionId });
344
+ const eventController = new AbortController();
345
+ const eventMonitor = active.client.event
346
+ .subscribe({ signal: eventController.signal })
347
+ .then(async (subscription) => {
348
+ for await (const event of subscription.stream) {
349
+ if (openCodeEventSessionId(event) && openCodeEventSessionId(event) !== openCodeSessionId) {
350
+ continue;
351
+ }
352
+ const kind = openCodeProgressKind(event);
353
+ if (!kind) continue;
354
+ onProgress?.({
355
+ kind,
356
+ provider: 'opencode',
357
+ occurredAt: new Date().toISOString(),
358
+ });
359
+ }
360
+ })
361
+ .catch((error) => {
362
+ if (!eventController.signal.aborted) {
363
+ trace?.warn?.('opencode_progress_stream_failed', {
364
+ profileId: profile.id,
365
+ message: error instanceof Error ? error.message : String(error),
366
+ });
367
+ }
368
+ });
369
+ try {
370
+ onProgress?.({
371
+ kind: 'turn_started',
372
+ provider: 'opencode',
373
+ occurredAt: new Date().toISOString(),
374
+ });
375
+ const response = await active.client.session.prompt({
376
+ path: { id: openCodeSessionId },
377
+ body: {
378
+ ...(modelParts(model) ? { model: modelParts(model) } : {}),
379
+ tools: {
380
+ bash: false,
381
+ edit: false,
382
+ write: false,
383
+ read: false,
384
+ glob: false,
385
+ grep: false,
386
+ webfetch: false,
387
+ websearch: false,
388
+ codesearch: false,
389
+ task: false,
390
+ },
391
+ parts: [{ type: 'text', text: String(prompt || '') }],
392
+ },
393
+ signal: controller.signal,
394
+ });
395
+ const data = response?.data || response;
396
+ return {
397
+ text: responseText(response),
398
+ sessionId: openCodeSessionId,
399
+ tokenUsage: normalizeCompanionTokenUsage(data?.info?.tokens || {}),
400
+ usageAvailable: Boolean(data?.info?.tokens),
401
+ usageSource: 'opencode',
402
+ usageAccuracy: 'reported',
403
+ };
404
+ } catch (error) {
405
+ if (deadlineReached) {
406
+ await deadlineAbort;
407
+ throw Object.assign(new Error(`The model turn exceeded the ${maxDurationMs}ms safety limit.`), {
408
+ code: 'APP_AGENT_TURN_HARD_TIMEOUT',
409
+ retryable: true,
410
+ });
411
+ }
412
+ if (controller.signal.aborted) {
413
+ throw Object.assign(new Error('OpenCode model turn was cancelled.'), {
414
+ code: 'RUN_CANCELLED',
415
+ });
416
+ }
417
+ throw error;
418
+ } finally {
419
+ clearTimeout(timer);
420
+ eventController.abort();
421
+ await eventMonitor;
422
+ activeTurns.delete(turnKey);
423
+ }
424
+ }
425
+
426
+ async function runOutcome({
427
+ runId,
428
+ sessionId,
429
+ prompt,
430
+ model,
431
+ cwd,
432
+ maxDurationMs = 15 * 60_000,
433
+ onProgress,
434
+ harnessTools,
435
+ outputSchema,
436
+ responseContract = STRUCTURED_RESPONSE_CONTRACTS.OUTCOME,
437
+ resumeSessionId,
438
+ } = {}) {
439
+ assertOutcomeResponseContract(responseContract, outputSchema);
440
+ const turnKey = sessionId || runId;
441
+ if (!turnKey) throw new Error('OpenCode outcome run requires a session id.');
442
+ const directory = cwd || workspace;
443
+ const port = await availablePort();
444
+ const password = crypto.randomBytes(32).toString('base64url');
445
+ const config = openCodeOutcomeConfig(harnessTools, directory);
446
+ const framerHarness = Array.isArray(harnessTools?.definitions)
447
+ && harnessTools.definitions.some((tool) =>
448
+ String(tool?.name || '').startsWith('framer_'));
449
+ const child = crossSpawn(command, ['serve', '--hostname=127.0.0.1', `--port=${port}`], {
450
+ cwd: directory,
451
+ env: {
452
+ ...env,
453
+ OPENCODE_SERVER_PASSWORD: password,
454
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
455
+ },
456
+ stdio: ['ignore', 'pipe', 'pipe'],
457
+ });
458
+ child.stdout?.resume();
459
+ child.stderr?.resume();
460
+ const client = createOpencodeClient({
461
+ baseUrl: `http://127.0.0.1:${port}`,
462
+ directory,
463
+ headers: { Authorization: basicAuth(password) },
464
+ throwOnError: true,
465
+ });
466
+ const controller = new AbortController();
467
+ const timer = setTimeout(() => controller.abort(), maxDurationMs);
468
+ timer.unref?.();
469
+ activeTurns.set(turnKey, {
470
+ controller,
471
+ openCodeSessionId: null,
472
+ client,
473
+ child,
474
+ });
475
+ try {
476
+ await waitForServer(child, client);
477
+ let openCodeSessionId = sessions.get(turnKey) || resumeSessionId;
478
+ if (!openCodeSessionId) {
479
+ const created = await client.session.create({
480
+ body: { title: 'InstaWebAI app-builder session' },
481
+ });
482
+ openCodeSessionId = created?.data?.id || created?.id;
483
+ if (!openCodeSessionId) {
484
+ throw new Error('OpenCode did not create an outcome session.');
485
+ }
486
+ sessions.set(turnKey, openCodeSessionId);
487
+ }
488
+ activeTurns.set(turnKey, {
489
+ controller,
490
+ openCodeSessionId,
491
+ client,
492
+ child,
493
+ });
494
+ onProgress?.({
495
+ kind: 'turn_started',
496
+ provider: 'opencode',
497
+ occurredAt: new Date().toISOString(),
498
+ });
499
+ const response = await client.session.prompt({
500
+ path: { id: openCodeSessionId },
501
+ body: {
502
+ ...(modelParts(model) ? { model: modelParts(model) } : {}),
503
+ tools: {
504
+ bash: !framerHarness,
505
+ edit: !framerHarness,
506
+ write: !framerHarness,
507
+ read: !framerHarness,
508
+ glob: !framerHarness,
509
+ grep: !framerHarness,
510
+ webfetch: false,
511
+ websearch: false,
512
+ codesearch: false,
513
+ task: false,
514
+ },
515
+ parts: [{ type: 'text', text: String(prompt || '') }],
516
+ },
517
+ signal: controller.signal,
518
+ });
519
+ const data = response?.data || response;
520
+ return {
521
+ text: responseText(response),
522
+ sessionId: openCodeSessionId,
523
+ tokenUsage: normalizeCompanionTokenUsage(data?.info?.tokens || {}),
524
+ usageAvailable: Boolean(data?.info?.tokens),
525
+ usageSource: 'opencode',
526
+ usageAccuracy: 'reported',
527
+ };
528
+ } finally {
529
+ clearTimeout(timer);
530
+ activeTurns.delete(turnKey);
531
+ if (!child.killed) child.kill('SIGTERM');
532
+ }
533
+ }
534
+
535
+ async function cancel(sessionId) {
536
+ const active = activeTurns.get(sessionId);
537
+ if (!active) return;
538
+ active.controller.abort();
539
+ const runningClient = active.client || (await ensureServer().catch(() => null))?.client;
540
+ if (active.openCodeSessionId) {
541
+ await runningClient?.session
542
+ .abort({
543
+ path: { id: active.openCodeSessionId },
544
+ })
545
+ .catch(() => undefined);
546
+ }
547
+ if (active.child && !active.child.killed) active.child.kill('SIGTERM');
548
+ }
549
+
550
+ async function resetSession(sessionId) {
551
+ const openCodeSessionId = sessions.get(sessionId);
552
+ sessions.delete(sessionId);
553
+ if (!openCodeSessionId) return;
554
+ const active = await ensureServer().catch(() => null);
555
+ await active?.client.session
556
+ .delete({
557
+ path: { id: openCodeSessionId },
558
+ })
559
+ .catch(() => undefined);
560
+ }
561
+
562
+ function close() {
563
+ for (const { controller, child } of activeTurns.values()) {
564
+ controller.abort();
565
+ if (child && !child.killed) child.kill('SIGTERM');
566
+ }
567
+ activeTurns.clear();
568
+ sessions.clear();
569
+ if (server?.child && !server.child.killed) server.child.kill('SIGTERM');
570
+ server = null;
571
+ try {
572
+ rmSync(workspace, { recursive: true, force: true });
573
+ } catch {
574
+ // The operating system can clean the temporary directory.
575
+ }
576
+ trace?.info?.('opencode_adapter_closed', { profileId: profile.id });
577
+ }
578
+
579
+ return {
580
+ id: profile.id,
581
+ driverKind: 'opencode',
582
+ label: profile.label || 'OpenCode',
583
+ detect,
584
+ models,
585
+ runModelTurn,
586
+ runOutcome,
587
+ harnessCapabilities: {
588
+ outcomeExecution: true,
589
+ workspaceSnapshot: true,
590
+ workspaceWrite: true,
591
+ shell: true,
592
+ projectShell: true,
593
+ preview: true,
594
+ browser: true,
595
+ dataInspection: true,
596
+ verification: true,
597
+ standardTools: true,
598
+ structuredResult: true,
599
+ progress: true,
600
+ cancellation: true,
601
+ sessionResume: true,
602
+ },
603
+ cancel,
604
+ resetSession,
605
+ close,
606
+ };
607
+ }