@obtoai/agent-bridge 0.1.0-beta.3 → 0.1.0-beta.5
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 +40 -29
- package/package.json +3 -2
- package/src/bridge-http.js +8 -0
- package/src/capabilities.js +31 -0
- package/src/daemon.js +42 -3
- package/src/driver.js +6 -5
- package/src/opencode-driver.js +220 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @obtoai/agent-bridge
|
|
2
2
|
|
|
3
|
-
A local daemon that lets
|
|
3
|
+
A local daemon that lets coding agents — [Claude Code](https://claude.ai/code), [OpenAI Codex](https://developers.openai.com/codex), or [opencode](https://opencode.ai) — running on your machine be driven from the [OBTO Agent Bridge](https://obto.co) web UI, even when you're away from the keyboard.
|
|
4
4
|
|
|
5
|
-
You post a message on a thread from your phone or laptop. The daemon (running on your machine, no port forwarding required) receives it over a long-lived HTTPS stream, spawns or resumes
|
|
5
|
+
You post a message on a thread from your phone or laptop. The daemon (running on your machine, no port forwarding required) receives it over a long-lived HTTPS stream, spawns or resumes a session for the agent that thread is bound to, and the response posts back to the bridge thread within seconds.
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
@@ -11,10 +11,11 @@ You post a message on a thread from your phone or laptop. The daemon (running on
|
|
|
11
11
|
## What you'll need
|
|
12
12
|
|
|
13
13
|
- macOS, Linux, or Windows, **Node.js 18.17+**
|
|
14
|
-
-
|
|
15
|
-
- **Claude** — Claude Code / the Claude Agent SDK, billed to your Anthropic account
|
|
16
|
-
- **Codex** — the `codex` CLI (`npm i -g @openai/codex`), signed in to your OpenAI/ChatGPT account
|
|
17
|
-
-
|
|
14
|
+
- At least one coding agent installed on the machine (the daemon drives whichever ones it finds, with your own auth):
|
|
15
|
+
- **Claude** — Claude Code / the Claude Agent SDK, billed to your Anthropic account.
|
|
16
|
+
- **Codex** — the `codex` CLI (`npm i -g @openai/codex`), signed in to your OpenAI/ChatGPT account.
|
|
17
|
+
- **opencode** — `npm i -g opencode-ai` (the `opencode` CLI; the daemon bundles the `@opencode-ai/sdk`). Auth is your own provider key (Anthropic by default; override with env vars below).
|
|
18
|
+
- An invite from `support@obto.co` (gives you an `accountId`, browser username/password, and an API token).
|
|
18
19
|
|
|
19
20
|
## Install
|
|
20
21
|
|
|
@@ -34,18 +35,25 @@ npx @obtoai/agent-bridge <command>
|
|
|
34
35
|
obto-bridge init
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
Walks you through a few questions: your account ID, API token, an agent name (to distinguish multiple machines on one account),
|
|
38
|
+
Walks you through a few questions: your account ID, API token, an agent name (to distinguish multiple machines on one account), a *fallback* agent (`claude`, `codex`, or `opencode` — used only for legacy events without an explicit agent), the project directory to work in, and whether to relay tool-permission requests via the bridge. (The server URL is a built-in default; advanced / self-hosted users can override it with the `BRIDGE_BASE_URL` env var.)
|
|
38
39
|
|
|
39
40
|
Config lands at `~/.obto-bridge/config.json` (mode 0600). Safe to commit your account ID; **never commit the `apiToken`**.
|
|
40
41
|
|
|
41
|
-
### claude
|
|
42
|
+
### Agents (claude / codex / opencode)
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
v1.1 makes the daemon **agent-agnostic per event**: at startup it detects which of `claude`, `codex`, and `opencode` are installed on the machine, advertises that to the bridge, and routes each incoming reply to the right driver based on what the thread is bound to in the UI. You can switch a thread's agent live from the thread header; each engine keeps its own session for that thread, so flipping claude→codex→claude resumes each side's context.
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
- **codex** — runs the task and delivers one final answer per turn. No mid-task updates and no per-tool relay (the Codex SDK exposes neither); it runs unattended inside a sandbox (`workspace-write` by default, override with `BRIDGE_CODEX_SANDBOX`).
|
|
46
|
+
How the three differ in how they report back:
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
- **claude** — the fullest integration. Posts status updates, mid-task questions, and final results as it works (via an in-process MCP tool), and supports the human-in-the-loop tool-permission relay.
|
|
49
|
+
- **codex** — runs the turn and delivers one final answer per turn. No mid-task updates and no per-tool relay (the Codex SDK exposes neither). Runs unattended inside a sandbox (`workspace-write` by default, override with `BRIDGE_CODEX_SANDBOX`).
|
|
50
|
+
- **opencode** — same capture-model shape as codex: one final answer per turn, no mid-task chatter. Defaults to provider `anthropic` and model `claude-sonnet-4-5`; override with `BRIDGE_OPENCODE_PROVIDER` and `BRIDGE_OPENCODE_MODEL`.
|
|
51
|
+
|
|
52
|
+
Picking a model is done in the bridge UI's **+ New thread** dialog and the thread-header switcher — not in the daemon config.
|
|
53
|
+
|
|
54
|
+
### Multi-daemon (running across more than one machine)
|
|
55
|
+
|
|
56
|
+
You can run the same account's daemon on more than one machine (e.g. a Mac and a Windows box). Each daemon advertises its `agentId` (machine name) + capabilities on connect; threads are atomically **first-touch claimed** by whichever daemon gets the event first, and every other daemon skips the event cleanly. No duplicate replies, no special configuration — just install + start the daemon on each machine.
|
|
49
57
|
|
|
50
58
|
## Run
|
|
51
59
|
|
|
@@ -56,14 +64,16 @@ obto-bridge start
|
|
|
56
64
|
You'll see two log lines and then the daemon waits silently:
|
|
57
65
|
|
|
58
66
|
```
|
|
59
|
-
{"msg":"starting daemon","data":{"accountId":"acc_...","agentId":"my-mac",...}}
|
|
67
|
+
{"msg":"starting daemon","data":{"accountId":"acc_...","agentId":"my-mac","capabilities":["claude","codex"],...}}
|
|
60
68
|
{"msg":"sse stream connected","data":{"status":200}}
|
|
61
69
|
```
|
|
62
70
|
|
|
71
|
+
`capabilities` is the list of agents this daemon will accept — the bridge UI offers exactly the union across your connected machines.
|
|
72
|
+
|
|
63
73
|
Now open the bridge UI in any browser, log in with the browser credentials from your invite, and either:
|
|
64
74
|
|
|
65
|
-
- Reply on an existing thread — daemon resumes the session bound to that thread
|
|
66
|
-
- Start a new thread via the **+ New thread** button — daemon spawns a fresh session in your project directory
|
|
75
|
+
- Reply on an existing thread — daemon resumes the session bound to that thread (and to whichever agent the thread currently uses).
|
|
76
|
+
- Start a new thread via the **+ New thread** button — pick Claude, Codex, or Opencode; the daemon spawns a fresh session in your project directory.
|
|
67
77
|
|
|
68
78
|
Within ~5–10 seconds you should see the agent's reply appear back on the thread.
|
|
69
79
|
|
|
@@ -72,40 +82,41 @@ Within ~5–10 seconds you should see the agent's reply appear back on the threa
|
|
|
72
82
|
| Command | What it does |
|
|
73
83
|
|---|---|
|
|
74
84
|
| `obto-bridge whoami` | Verify your token works + show your account info |
|
|
75
|
-
| `obto-bridge status` | List
|
|
85
|
+
| `obto-bridge status` | List bindings per (thread, agent) — one row per engine that's ever driven a thread |
|
|
76
86
|
| `obto-bridge logout` | Wipe `~/.obto-bridge/config.json` |
|
|
77
87
|
|
|
78
88
|
## How it actually works
|
|
79
89
|
|
|
80
90
|
```
|
|
81
|
-
Your phone OBTO server Your machine
|
|
82
|
-
───────── ───────────
|
|
91
|
+
Your phone OBTO server Your machine(s)
|
|
92
|
+
───────── ─────────── ───────────────
|
|
83
93
|
[reply form] ──► /api/reply ─► Mongo (durable)
|
|
84
|
-
└─► RabbitMQ (publish bridge.<acct>.reply.<thread
|
|
94
|
+
└─► RabbitMQ (publish bridge.<acct>.reply.<thread>,
|
|
95
|
+
payload carries agent + agentId)
|
|
85
96
|
◄── /api/bridge/stream (SSE, Bearer auth)
|
|
86
|
-
└─► daemon
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
/api/message ◄──── bridge_post (in-process MCP tool
|
|
97
|
+
└─► daemon (dispatches per payload.agent)
|
|
98
|
+
├─► Claude Agent SDK → ~/.claude/projects/...
|
|
99
|
+
├─► @openai/codex-sdk → ~/.codex/sessions/...
|
|
100
|
+
└─► @opencode-ai/sdk → opencode server
|
|
101
|
+
/api/message ◄──── bridge_post (in-process MCP tool, Claude only)
|
|
91
102
|
[poll: /api/messages] ◄──── (4s loop)
|
|
92
103
|
```
|
|
93
104
|
|
|
94
105
|
Key bits:
|
|
95
106
|
|
|
96
107
|
- The daemon **never** holds RabbitMQ credentials; broker access stays server-side. Per-account routing key isolation enforced by `BridgeAuth`.
|
|
97
|
-
-
|
|
98
|
-
- Each bridge **thread** binds to its own
|
|
108
|
+
- For the **claude** driver, the spawned Claude session uses an **in-process MCP server** (`mcp__bridge__bridge_post`) — not the platform's hosted MCP, so the daemon's tools don't depend on a long-lived OBTO MCP proxy session. For **codex** and **opencode**, the SDKs can't auto-approve a write tool when run unattended, so the daemon captures the final response and posts it to the thread on the agent's behalf.
|
|
109
|
+
- Each bridge **thread** binds to its own session ID **per agent**. Subsequent messages on the same thread + same agent resume the same engine-specific session, so the agent keeps full context. Switching the thread's agent in the UI starts (or resumes) the other engine's session — each side's state stays intact. Your interactive sessions are unaffected — they live in separate session stores.
|
|
99
110
|
- Per-thread serialization means rapid bursts on the same thread are handled in order, never racing the same session.
|
|
100
|
-
-
|
|
111
|
+
- Multi-daemon races are killed by atomic first-touch claim against the thread record on the bridge.
|
|
101
112
|
|
|
102
113
|
## Agent costs
|
|
103
114
|
|
|
104
|
-
The daemon runs your chosen agent on your machine with **your** credentials — Anthropic for `claude` (whatever Claude Code uses: `ANTHROPIC_API_KEY` or your Claude.ai session)
|
|
115
|
+
The daemon runs your chosen agent on your machine with **your** credentials — Anthropic for `claude` (whatever Claude Code uses: `ANTHROPIC_API_KEY` or your Claude.ai session); your OpenAI/ChatGPT account for `codex`; whichever provider you've configured `opencode` to call (Anthropic by default for this daemon). Every bridge-driven turn is a normal API call billed to you. We don't proxy.
|
|
105
116
|
|
|
106
117
|
## Data handling
|
|
107
118
|
|
|
108
|
-
**Your model traffic never touches us.** The daemon runs on your machine and calls Anthropic or
|
|
119
|
+
**Your model traffic never touches us.** The daemon runs on your machine and calls Anthropic, OpenAI, or whichever provider opencode is configured for, with *your own* credentials. Your prompts, your code, and the model's responses pass directly between your machine and the model provider, under your own API account and its terms. OBTO does not proxy, route, or see that traffic.
|
|
109
120
|
|
|
110
121
|
**What the bridge stores.** For threads to work, the messages you and the agent post are saved in OBTO's database — that's what makes a thread durable and readable from your phone. Threads are strictly scoped to your account; one tenant can never see another's. Your daemon's API token is stored server-side only as a SHA-256 hash; the plaintext token never leaves your local config file.
|
|
111
122
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obtoai/agent-bridge",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.5",
|
|
4
4
|
"description": "Local consumer for the OBTO Agent Bridge. Receives bridge events over SSE and drives a coding agent (Claude Code or OpenAI Codex) on your machine.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "OBTO Inc.",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@anthropic-ai/claude-agent-sdk": "^0.2.126",
|
|
35
|
-
"@openai/codex-sdk": "^0.130.0"
|
|
35
|
+
"@openai/codex-sdk": "^0.130.0",
|
|
36
|
+
"@opencode-ai/sdk": "^1.16.1"
|
|
36
37
|
}
|
|
37
38
|
}
|
package/src/bridge-http.js
CHANGED
|
@@ -78,10 +78,18 @@ const getMessages = (threadId, sinceCursor) => {
|
|
|
78
78
|
const postAgentActivity = (threadId, state) =>
|
|
79
79
|
postJson('/api/bridge/agent-activity', { threadId, state });
|
|
80
80
|
|
|
81
|
+
// Phase 2b — atomic first-touch claim. Called by the daemon when it sees a
|
|
82
|
+
// reply event for a thread whose `agentId` is null (unrouted). The bridge's
|
|
83
|
+
// claimThread does a conditional Mongo update — only one daemon wins.
|
|
84
|
+
// Returns { ok, won, winner }: `won` is the only thing the caller acts on.
|
|
85
|
+
const claimThread = (threadId, agentId) =>
|
|
86
|
+
postJson('/api/bridge/thread/claim', { threadId, agentId });
|
|
87
|
+
|
|
81
88
|
module.exports = {
|
|
82
89
|
getCfg,
|
|
83
90
|
buildHeaders,
|
|
84
91
|
postMessage,
|
|
85
92
|
getMessages,
|
|
86
93
|
postAgentActivity,
|
|
94
|
+
claimThread,
|
|
87
95
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 2b — what this machine can drive. The Claude Agent SDK is a hard
|
|
4
|
+
// dependency of the daemon (declared in package.json), so `claude` is always
|
|
5
|
+
// available. `codex` and `opencode` need their respective CLIs on PATH —
|
|
6
|
+
// we probe with `which` (POSIX) or `where` (Windows).
|
|
7
|
+
//
|
|
8
|
+
// Sent to the bridge as `?capabilities=claude,codex,...` on SSE connect; the
|
|
9
|
+
// bridge records them in `agent_bridge_daemons` so the UI picker can offer
|
|
10
|
+
// only what's actually installable across the account's machines.
|
|
11
|
+
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const onPath = (cmd) => {
|
|
15
|
+
try {
|
|
16
|
+
const tool = process.platform === 'win32' ? 'where' : 'which';
|
|
17
|
+
const r = spawnSync(tool, [cmd], { stdio: 'ignore' });
|
|
18
|
+
return r.status === 0;
|
|
19
|
+
} catch (_) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const detect = () => {
|
|
25
|
+
const out = ['claude']; // bundled SDK; always advertised
|
|
26
|
+
if (onPath('codex')) out.push('codex');
|
|
27
|
+
if (onPath('opencode')) out.push('opencode');
|
|
28
|
+
return out;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
module.exports = { detect, onPath };
|
package/src/daemon.js
CHANGED
|
@@ -4,7 +4,8 @@ const { loadConfig } = require('./config');
|
|
|
4
4
|
const { startStream } = require('./stream-client');
|
|
5
5
|
const { loadState, saveState, getAgentSession, setAgentSession } = require('./state');
|
|
6
6
|
const { drive, tryResolvePermission, agentFor } = require('./driver');
|
|
7
|
-
const { postAgentActivity } = require('./bridge-http');
|
|
7
|
+
const { postAgentActivity, claimThread } = require('./bridge-http');
|
|
8
|
+
const { detect: detectCapabilities } = require('./capabilities');
|
|
8
9
|
|
|
9
10
|
const log = (level, msg, data) => {
|
|
10
11
|
const line = { ts: new Date().toISOString(), level, msg };
|
|
@@ -67,6 +68,37 @@ const handleEvent = async (sseEvent) => {
|
|
|
67
68
|
return;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
// Phase 2b — multi-daemon race check. The thread's target machine is
|
|
72
|
+
// included on the event when known (postReply publishes it). If it's set
|
|
73
|
+
// and isn't us, skip. If null, attempt the atomic first-touch claim — only
|
|
74
|
+
// the winning daemon handles the event; the rest skip cleanly.
|
|
75
|
+
const targetAgentId = payload.agentId ? String(payload.agentId).trim() : null;
|
|
76
|
+
if (targetAgentId && targetAgentId !== cfg.agentId) {
|
|
77
|
+
log('event', 'skip — claimed by other daemon', {
|
|
78
|
+
threadId,
|
|
79
|
+
targetAgentId,
|
|
80
|
+
messageId: payload.messageId,
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!targetAgentId) {
|
|
85
|
+
try {
|
|
86
|
+
const r = await claimThread(threadId, cfg.agentId);
|
|
87
|
+
if (!r || !r.ok || !r.data || !r.data.won) {
|
|
88
|
+
log('info', 'claim lost or failed', {
|
|
89
|
+
threadId,
|
|
90
|
+
winner: r && r.data && r.data.winner,
|
|
91
|
+
status: r && r.status,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
log('info', 'claim won', { threadId, agentId: cfg.agentId });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
log('error', 'claim threw', { threadId, error: e && e.message });
|
|
98
|
+
return; // conservative — skip on uncertainty rather than double-drive
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
// v1.1 — which agent this thread is bound to (server-set, on the event).
|
|
71
103
|
const agent = agentFor(payload);
|
|
72
104
|
const session = getAgentSession(state, threadId, agent);
|
|
@@ -137,16 +169,23 @@ const handleEvent = async (sseEvent) => {
|
|
|
137
169
|
};
|
|
138
170
|
|
|
139
171
|
const start = () => {
|
|
172
|
+
// Phase 2b — advertise capabilities to the bridge on connect so the UI
|
|
173
|
+
// picker can offer just the agents that are actually installable here.
|
|
174
|
+
const capabilities = detectCapabilities();
|
|
175
|
+
|
|
140
176
|
log('info', 'starting daemon', {
|
|
141
177
|
baseUrl: cfg.baseUrl,
|
|
142
178
|
accountId: cfg.accountId,
|
|
143
179
|
agentId: cfg.agentId,
|
|
144
|
-
|
|
180
|
+
capabilities,
|
|
145
181
|
projectDir: cfg.projectDir,
|
|
146
182
|
boundThreads: Object.keys(state.bindings || {}),
|
|
147
183
|
});
|
|
148
184
|
|
|
149
|
-
const url = cfg.baseUrl.replace(/\/$/, '') +
|
|
185
|
+
const url = cfg.baseUrl.replace(/\/$/, '') +
|
|
186
|
+
'/api/bridge/stream' +
|
|
187
|
+
'?agentId=' + encodeURIComponent(cfg.agentId) +
|
|
188
|
+
'&capabilities=' + encodeURIComponent(capabilities.join(','));
|
|
150
189
|
stream = startStream({
|
|
151
190
|
url,
|
|
152
191
|
// Re-read config on every (re)connect so a rotated token (via
|
package/src/driver.js
CHANGED
|
@@ -13,15 +13,16 @@
|
|
|
13
13
|
|
|
14
14
|
const { loadConfig } = require('./config');
|
|
15
15
|
|
|
16
|
-
const KNOWN_AGENTS = ['claude', 'codex'];
|
|
16
|
+
const KNOWN_AGENTS = ['claude', 'codex', 'opencode'];
|
|
17
17
|
|
|
18
18
|
const cache = {};
|
|
19
19
|
|
|
20
20
|
const loadDriver = (name) => {
|
|
21
21
|
if (cache[name]) return cache[name];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
let mod;
|
|
23
|
+
if (name === 'codex') mod = require('./codex-driver');
|
|
24
|
+
else if (name === 'opencode') mod = require('./opencode-driver');
|
|
25
|
+
else mod = require('./claude-driver');
|
|
25
26
|
cache[name] = mod;
|
|
26
27
|
return mod;
|
|
27
28
|
};
|
|
@@ -38,7 +39,7 @@ const getFallbackAgent = () => {
|
|
|
38
39
|
} catch (_) {
|
|
39
40
|
// config unreadable — default to claude
|
|
40
41
|
}
|
|
41
|
-
fallbackAgent = a
|
|
42
|
+
fallbackAgent = KNOWN_AGENTS.indexOf(a) !== -1 ? a : 'claude';
|
|
42
43
|
return fallbackAgent;
|
|
43
44
|
};
|
|
44
45
|
|
|
@@ -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 };
|