@obtoai/agent-bridge 0.1.0-beta.2 → 0.1.0-beta.21
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 +55 -31
- package/bin/obto-bridge.js +8 -5
- package/cli/init.js +149 -17
- package/cli/status.js +28 -15
- package/package.json +3 -2
- package/src/bridge-http.js +16 -0
- package/src/capabilities.js +31 -0
- package/src/daemon.js +191 -22
- package/src/driver.js +53 -28
- package/src/external-scanner.js +546 -0
- package/src/opencode-driver.js +220 -0
- package/src/state.js +64 -8
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Opencode driver — drives an opencode session per bridge thread, the
|
|
4
|
+
// opencode counterpart of codex-driver.js. Selected when payload.agent ===
|
|
5
|
+
// 'opencode'. Same capture-model shape as Codex: opencode runs the turn, this
|
|
6
|
+
// driver posts the agent's final response to the bridge on its behalf.
|
|
7
|
+
//
|
|
8
|
+
// Why this is shaped like the Codex driver (and not Claude):
|
|
9
|
+
//
|
|
10
|
+
// • No bridge MCP tool exposed to opencode. Easiest path is the SDK's
|
|
11
|
+
// session.prompt() and concatenating returned text parts as the answer.
|
|
12
|
+
//
|
|
13
|
+
// • No fine-grained permission relay. opencode's SDK gives a single
|
|
14
|
+
// prompt-in / parts-out call per turn. tryResolvePermission() is a no-op.
|
|
15
|
+
//
|
|
16
|
+
// SDK-specific calls are isolated in runOpencode(), verified against
|
|
17
|
+
// @opencode-ai/sdk@^1.16 (Node SDK docs as of 2026-05-21).
|
|
18
|
+
|
|
19
|
+
const { loadConfig } = require('./config');
|
|
20
|
+
const { buildEnvelope } = require('./claude-driver');
|
|
21
|
+
const bridgeHttp = require('./bridge-http');
|
|
22
|
+
|
|
23
|
+
// Per-thread promise queue — concurrent replies on one thread are serialized
|
|
24
|
+
// so first-touch completes before any resume. Mirrors codex-driver.
|
|
25
|
+
const queues = new Map();
|
|
26
|
+
|
|
27
|
+
// Defaults can be overridden per-machine via env. Anthropic Claude is the
|
|
28
|
+
// default because users running opencode usually already have Claude auth.
|
|
29
|
+
const DEFAULT_PROVIDER = process.env.BRIDGE_OPENCODE_PROVIDER || 'anthropic';
|
|
30
|
+
const DEFAULT_MODEL = process.env.BRIDGE_OPENCODE_MODEL || 'claude-sonnet-4-5';
|
|
31
|
+
|
|
32
|
+
const buildOpencodePrompt = (payload, isFirst) => {
|
|
33
|
+
const head = buildEnvelope(payload);
|
|
34
|
+
if (!isFirst) return head;
|
|
35
|
+
return head +
|
|
36
|
+
'\n\n---\n' +
|
|
37
|
+
'You are an opencode session spawned by the OBTO Agent Bridge to handle ' +
|
|
38
|
+
'thread "' + payload.threadId + '". The human who sent the message above ' +
|
|
39
|
+
'is on the OBTO bridge web UI — they do NOT see your terminal, your tool ' +
|
|
40
|
+
'calls, or any intermediate output. They see ONLY your final response, ' +
|
|
41
|
+
'delivered to them verbatim.\n\n' +
|
|
42
|
+
'Therefore: do the requested work, then make your final response a ' +
|
|
43
|
+
'complete, self-contained answer addressed to that human. Markdown is ' +
|
|
44
|
+
'supported. If you need information you do not have, make your final ' +
|
|
45
|
+
'response a single clear question. Now handle the message above.';
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Best-effort extraction of the assistant's final text from an opencode
|
|
49
|
+
// prompt result. The SDK returns `{ parts: [...] }` or `{ data: { parts } }`
|
|
50
|
+
// depending on the call; we tolerate both and concatenate every text part.
|
|
51
|
+
const extractFinalResponse = (result) => {
|
|
52
|
+
if (!result) return '';
|
|
53
|
+
const parts = (result && result.parts) ||
|
|
54
|
+
(result && result.data && result.data.parts) ||
|
|
55
|
+
[];
|
|
56
|
+
if (!Array.isArray(parts)) return '';
|
|
57
|
+
return parts
|
|
58
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
59
|
+
.map((p) => String(p.text || ''))
|
|
60
|
+
.join('\n')
|
|
61
|
+
.trim();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── SDK boundary ──────────────────────────────────────────────────────────
|
|
65
|
+
// All @opencode-ai/sdk calls. The SDK spawns a local opencode HTTP server;
|
|
66
|
+
// we tear it down at the end of every turn (cheap, simple, no shared state).
|
|
67
|
+
const runOpencode = async ({ prompt, projectDir, resumeId }) => {
|
|
68
|
+
const { createOpencode } = await import('@opencode-ai/sdk');
|
|
69
|
+
const handle = await createOpencode({ directory: projectDir });
|
|
70
|
+
const client = handle.client;
|
|
71
|
+
const closeHandle = handle.close || (handle.server && handle.server.close);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
let sessionId = resumeId;
|
|
75
|
+
if (!sessionId) {
|
|
76
|
+
const created = await client.session.create({
|
|
77
|
+
body: { title: 'obto-bridge' },
|
|
78
|
+
});
|
|
79
|
+
sessionId = (created && created.id) ||
|
|
80
|
+
(created && created.data && created.data.id) ||
|
|
81
|
+
null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result = await client.session.prompt({
|
|
85
|
+
path: { id: sessionId },
|
|
86
|
+
body: {
|
|
87
|
+
model: { providerID: DEFAULT_PROVIDER, modelID: DEFAULT_MODEL },
|
|
88
|
+
parts: [{ type: 'text', text: prompt }],
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
sessionId: sessionId || (result && result.sessionId) || null,
|
|
94
|
+
finalResponse: extractFinalResponse(result),
|
|
95
|
+
};
|
|
96
|
+
} finally {
|
|
97
|
+
try { if (typeof closeHandle === 'function') await closeHandle(); } catch (_) {}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
const postToBridge = async ({ threadId, body, kind, log }) => {
|
|
103
|
+
try {
|
|
104
|
+
const r = await bridgeHttp.postMessage({
|
|
105
|
+
threadId,
|
|
106
|
+
body,
|
|
107
|
+
kind: kind || 'result',
|
|
108
|
+
author: 'opencode-bridge',
|
|
109
|
+
role: 'agent',
|
|
110
|
+
});
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
log('error', 'opencode bridge post failed', { threadId, status: r.status });
|
|
113
|
+
}
|
|
114
|
+
return !!r.ok;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
log('error', 'opencode bridge post threw', {
|
|
117
|
+
threadId,
|
|
118
|
+
error: e && e.message ? e.message : String(e),
|
|
119
|
+
});
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const driveTurn = async ({ threadId, projectDir, resumeId, payload, log }) => {
|
|
125
|
+
const isFirst = !resumeId;
|
|
126
|
+
log('info', isFirst ? 'opencode first-touch spawn' : 'opencode resume', {
|
|
127
|
+
threadId,
|
|
128
|
+
projectDir,
|
|
129
|
+
resumeId: resumeId || undefined,
|
|
130
|
+
provider: DEFAULT_PROVIDER,
|
|
131
|
+
model: DEFAULT_MODEL,
|
|
132
|
+
messageId: payload.messageId,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const startedAt = Date.now();
|
|
136
|
+
let sessionId = resumeId || null;
|
|
137
|
+
let finalResponse = '';
|
|
138
|
+
let failure = null;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const res = await runOpencode({
|
|
142
|
+
prompt: buildOpencodePrompt(payload, isFirst),
|
|
143
|
+
projectDir,
|
|
144
|
+
resumeId,
|
|
145
|
+
});
|
|
146
|
+
sessionId = res.sessionId || sessionId;
|
|
147
|
+
finalResponse = res.finalResponse;
|
|
148
|
+
} catch (e) {
|
|
149
|
+
failure = e && e.message ? e.message : String(e);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Capture model — the driver delivers opencode's output.
|
|
153
|
+
if (failure) {
|
|
154
|
+
await postToBridge({ threadId, kind: 'error', body: 'Opencode run failed: ' + failure, log });
|
|
155
|
+
} else if (finalResponse) {
|
|
156
|
+
await postToBridge({ threadId, kind: 'result', body: finalResponse, log });
|
|
157
|
+
} else {
|
|
158
|
+
await postToBridge({
|
|
159
|
+
threadId,
|
|
160
|
+
kind: 'error',
|
|
161
|
+
body: 'Opencode completed the turn but produced no final response.',
|
|
162
|
+
log,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
log('info', isFirst ? 'opencode first-touch done' : 'opencode resume done', {
|
|
167
|
+
threadId,
|
|
168
|
+
sessionId,
|
|
169
|
+
ok: !failure && !!finalResponse,
|
|
170
|
+
assistantTextChars: finalResponse.length,
|
|
171
|
+
durationMs: Date.now() - startedAt,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (failure && !sessionId) {
|
|
175
|
+
throw new Error('opencode run failed before a session id was assigned: ' + failure);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// jsonlPath/lastJsonlMtimeMs are Claude-specific — null keeps the binding
|
|
179
|
+
// shape consistent for daemon.js / state.js.
|
|
180
|
+
return {
|
|
181
|
+
sessionId,
|
|
182
|
+
projectDir,
|
|
183
|
+
jsonlPath: null,
|
|
184
|
+
lastJsonlMtimeMs: null,
|
|
185
|
+
stopReason: failure ? 'error' : 'done',
|
|
186
|
+
assistantTextChars: finalResponse.length,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const drive = (params) => {
|
|
191
|
+
const key = params.threadId;
|
|
192
|
+
const prev = queues.get(key) || Promise.resolve();
|
|
193
|
+
const next = prev
|
|
194
|
+
.then(() => {
|
|
195
|
+
const binding = params.binding;
|
|
196
|
+
const resuming = binding && binding.sessionId;
|
|
197
|
+
return driveTurn({
|
|
198
|
+
threadId: params.threadId,
|
|
199
|
+
projectDir: resuming ? binding.projectDir : params.projectDir,
|
|
200
|
+
resumeId: resuming ? binding.sessionId : null,
|
|
201
|
+
payload: params.payload,
|
|
202
|
+
log: params.log,
|
|
203
|
+
});
|
|
204
|
+
})
|
|
205
|
+
.catch((err) => {
|
|
206
|
+
params.log('error', 'opencode drive failed', {
|
|
207
|
+
threadId: params.threadId,
|
|
208
|
+
error: err && err.message ? err.message : String(err),
|
|
209
|
+
});
|
|
210
|
+
throw err;
|
|
211
|
+
});
|
|
212
|
+
queues.set(key, next);
|
|
213
|
+
return next;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Opencode has no per-tool permission callback exposed by the SDK — there is
|
|
217
|
+
// nothing to relay, same shape as the Codex driver.
|
|
218
|
+
const tryResolvePermission = () => false;
|
|
219
|
+
|
|
220
|
+
module.exports = { drive, tryResolvePermission };
|
package/src/state.js
CHANGED
|
@@ -13,18 +13,48 @@ const ensureDir = () => {
|
|
|
13
13
|
} catch (_) {}
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
// Schema:
|
|
16
|
+
// Schema (v1.1 — per-agent sessions):
|
|
17
17
|
// {
|
|
18
18
|
// "bindings": {
|
|
19
19
|
// "<threadId>": {
|
|
20
|
-
// "sessionId": "<uuid>",
|
|
21
|
-
// "projectDir": "/abs/path",
|
|
22
|
-
// "jsonlPath": "/.../<sid>.jsonl",
|
|
23
20
|
// "createdAt": "iso-ts",
|
|
24
|
-
// "
|
|
21
|
+
// "agentId": "<machine id>",
|
|
22
|
+
// "sessions": {
|
|
23
|
+
// "claude": { "sessionId", "projectDir", "jsonlPath", "lastJsonlMtimeMs", "lastDriveAt" },
|
|
24
|
+
// "codex": { "sessionId", "projectDir", "lastDriveAt" }
|
|
25
|
+
// }
|
|
25
26
|
// }, ...
|
|
26
27
|
// }
|
|
27
28
|
// }
|
|
29
|
+
// A thread keeps one session PER agent, so switching claude<->codex and back
|
|
30
|
+
// resumes each engine's own context. v1 flat bindings are migrated on load.
|
|
31
|
+
|
|
32
|
+
// Migrate a v1 flat binding ({ sessionId, projectDir, ... }) into the v1.1
|
|
33
|
+
// per-agent shape. The flat session is filed under 'claude' — the v1 default
|
|
34
|
+
// agent. A v1 codex daemon's threads simply first-touch codex fresh after the
|
|
35
|
+
// upgrade, which is acceptable (a switch is a fresh first-touch anyway).
|
|
36
|
+
const migrateBinding = (b) => {
|
|
37
|
+
if (!b || typeof b !== 'object') {
|
|
38
|
+
return { createdAt: null, agentId: null, sessions: {} };
|
|
39
|
+
}
|
|
40
|
+
if (b.sessions && typeof b.sessions === 'object') return b; // already v1.1
|
|
41
|
+
const out = {
|
|
42
|
+
createdAt: b.createdAt || null,
|
|
43
|
+
agentId: b.agentId || null,
|
|
44
|
+
sessions: {},
|
|
45
|
+
};
|
|
46
|
+
if (b.sessionId) {
|
|
47
|
+
out.sessions.claude = {
|
|
48
|
+
sessionId: b.sessionId,
|
|
49
|
+
projectDir: b.projectDir,
|
|
50
|
+
jsonlPath: b.jsonlPath,
|
|
51
|
+
lastJsonlMtimeMs: b.lastJsonlMtimeMs || null,
|
|
52
|
+
lastDriveAt: b.lastDriveAt || null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
|
|
28
58
|
const loadState = () => {
|
|
29
59
|
let raw;
|
|
30
60
|
try {
|
|
@@ -35,6 +65,10 @@ const loadState = () => {
|
|
|
35
65
|
if (!raw.bindings || typeof raw.bindings !== 'object') {
|
|
36
66
|
raw.bindings = {};
|
|
37
67
|
}
|
|
68
|
+
// Migrate any v1 flat bindings to the per-agent shape.
|
|
69
|
+
for (const tid of Object.keys(raw.bindings)) {
|
|
70
|
+
raw.bindings[tid] = migrateBinding(raw.bindings[tid]);
|
|
71
|
+
}
|
|
38
72
|
return raw;
|
|
39
73
|
};
|
|
40
74
|
|
|
@@ -43,12 +77,33 @@ const saveState = (state) => {
|
|
|
43
77
|
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
|
|
44
78
|
};
|
|
45
79
|
|
|
80
|
+
// The full per-thread binding ({ createdAt, agentId, sessions }), or null.
|
|
46
81
|
const getBinding = (state, threadId) =>
|
|
47
82
|
state.bindings && state.bindings[threadId] ? state.bindings[threadId] : null;
|
|
48
83
|
|
|
49
|
-
|
|
50
|
-
|
|
84
|
+
// One agent's session record on a thread, or null. The driver consumes this
|
|
85
|
+
// flat shape directly — same contract as the v1 binding.
|
|
86
|
+
const getAgentSession = (state, threadId, agent) => {
|
|
87
|
+
const b = getBinding(state, threadId);
|
|
88
|
+
if (!b || !b.sessions) return null;
|
|
89
|
+
return b.sessions[agent] || null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Store/replace one agent's session on a thread. Creates the binding if absent.
|
|
93
|
+
const setAgentSession = (state, threadId, agent, session, meta) => {
|
|
94
|
+
let b = state.bindings[threadId];
|
|
95
|
+
if (!b || !b.sessions) {
|
|
96
|
+
b = {
|
|
97
|
+
createdAt: new Date().toISOString(),
|
|
98
|
+
agentId: (meta && meta.agentId) || null,
|
|
99
|
+
sessions: {},
|
|
100
|
+
};
|
|
101
|
+
state.bindings[threadId] = b;
|
|
102
|
+
}
|
|
103
|
+
if (meta && meta.agentId) b.agentId = meta.agentId;
|
|
104
|
+
b.sessions[agent] = session;
|
|
51
105
|
saveState(state);
|
|
106
|
+
return b;
|
|
52
107
|
};
|
|
53
108
|
|
|
54
109
|
module.exports = {
|
|
@@ -57,5 +112,6 @@ module.exports = {
|
|
|
57
112
|
loadState,
|
|
58
113
|
saveState,
|
|
59
114
|
getBinding,
|
|
60
|
-
|
|
115
|
+
getAgentSession,
|
|
116
|
+
setAgentSession,
|
|
61
117
|
};
|