@obtoai/agent-bridge 0.1.0-beta.2 → 0.1.0-beta.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/cli/status.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- // `obto-bridge status` — read local state.json and print thread→session bindings.
4
- // Read-only; useful for "did the daemon ever drive this thread?"
3
+ // `obto-bridge status` — read local state.json and print thread→session
4
+ // bindings. Read-only. v1.1: a thread keeps one session per agent.
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
@@ -29,20 +29,33 @@ if (threads.length === 0) {
29
29
  const fmtAge = (iso) => {
30
30
  if (!iso) return '?';
31
31
  const ms = Date.now() - new Date(iso).getTime();
32
- if (ms < 60_000) return Math.floor(ms / 1000) + 's ago';
33
- if (ms < 3_600_000) return Math.floor(ms / 60_000) + 'm ago';
34
- if (ms < 86_400_000) return Math.floor(ms / 3_600_000) + 'h ago';
35
- return Math.floor(ms / 86_400_000) + 'd ago';
32
+ if (ms < 60000) return Math.floor(ms / 1000) + 's ago';
33
+ if (ms < 3600000) return Math.floor(ms / 60000) + 'm ago';
34
+ if (ms < 86400000) return Math.floor(ms / 3600000) + 'h ago';
35
+ return Math.floor(ms / 86400000) + 'd ago';
36
36
  };
37
37
 
38
- console.log('Thread Session ID Last drive');
39
- console.log('-'.repeat(95));
38
+ console.log('Thread Agent Session ID Last drive');
39
+ console.log('-'.repeat(100));
40
40
  threads.forEach((t) => {
41
- const b = bindings[t];
42
- const sid = (b.sessionId || '').slice(0, 36);
43
- console.log(t.padEnd(36) + ' ' + sid.padEnd(38) + ' ' + fmtAge(b.lastDriveAt));
41
+ const b = bindings[t] || {};
42
+ // v1.1 per-agent sessions; tolerate a stray un-migrated v1 flat binding.
43
+ const sessions = b.sessions && typeof b.sessions === 'object'
44
+ ? b.sessions
45
+ : (b.sessionId ? { claude: b } : {});
46
+ const agents = Object.keys(sessions);
47
+ if (agents.length === 0) {
48
+ console.log(t.padEnd(36) + ' (no session yet)');
49
+ return;
50
+ }
51
+ agents.forEach((agent, i) => {
52
+ const s = sessions[agent] || {};
53
+ const sid = String(s.sessionId || '').slice(0, 36);
54
+ console.log(
55
+ (i === 0 ? t : '').padEnd(36) + ' ' +
56
+ agent.padEnd(7) + ' ' +
57
+ sid.padEnd(38) + ' ' +
58
+ fmtAge(s.lastDriveAt),
59
+ );
60
+ });
44
61
  });
45
- console.log('');
46
- console.log('JSONLs at: ' + (bindings[threads[0]] && bindings[threads[0]].projectDir
47
- ? '~/.claude/projects/' + bindings[threads[0]].projectDir.replace(/\//g, '-') + '/'
48
- : 'unknown'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obtoai/agent-bridge",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
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
  }
@@ -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
@@ -2,9 +2,10 @@
2
2
 
3
3
  const { loadConfig } = require('./config');
4
4
  const { startStream } = require('./stream-client');
5
- const { loadState, saveState, getBinding, setBinding } = require('./state');
6
- const { drive, tryResolvePermission, activeAgent } = require('./driver');
7
- const { postAgentActivity } = require('./bridge-http');
5
+ const { loadState, saveState, getAgentSession, setAgentSession } = require('./state');
6
+ const { drive, tryResolvePermission, agentFor } = require('./driver');
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,12 +68,46 @@ const handleEvent = async (sseEvent) => {
67
68
  return;
68
69
  }
69
70
 
70
- const binding = getBinding(state, threadId);
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
+
102
+ // v1.1 — which agent this thread is bound to (server-set, on the event).
103
+ const agent = agentFor(payload);
104
+ const session = getAgentSession(state, threadId, agent);
71
105
  log('event', 'reply received', {
72
106
  threadId,
107
+ agent,
73
108
  author: payload.author,
74
109
  messageId: payload.messageId,
75
- hasBinding: !!binding,
110
+ hasSession: !!session,
76
111
  });
77
112
 
78
113
  // Permission-relay replies: resolve the pending request inside the driver and
@@ -85,10 +120,13 @@ const handleEvent = async (sseEvent) => {
85
120
  // indicator drops whether the turn succeeds, skips, or throws.
86
121
  emitActivity(threadId, 'working');
87
122
  try {
123
+ // The driver receives a flat per-agent session as `binding` — the v1
124
+ // driver contract is unchanged. v1.1 just keeps one such session per
125
+ // agent on the thread, so a claude<->codex switch resumes each side.
88
126
  const result = await drive({
89
127
  threadId,
90
128
  projectDir: cfg.projectDir,
91
- binding,
129
+ binding: session,
92
130
  payload,
93
131
  log,
94
132
  });
@@ -96,22 +134,27 @@ const handleEvent = async (sseEvent) => {
96
134
  if (
97
135
  result &&
98
136
  result.sessionId &&
99
- (!binding || binding.sessionId !== result.sessionId)
137
+ (!session || session.sessionId !== result.sessionId)
100
138
  ) {
101
- setBinding(state, threadId, {
102
- sessionId: result.sessionId,
103
- projectDir: result.projectDir,
104
- jsonlPath: result.jsonlPath,
105
- lastJsonlMtimeMs: result.lastJsonlMtimeMs || null,
106
- agentId: cfg.agentId,
107
- createdAt: new Date().toISOString(),
108
- lastDriveAt: new Date().toISOString(),
109
- });
110
- log('info', 'binding created', { threadId, sessionId: result.sessionId });
111
- } else if (binding && !result.skipped) {
112
- binding.lastDriveAt = new Date().toISOString();
139
+ setAgentSession(
140
+ state,
141
+ threadId,
142
+ agent,
143
+ {
144
+ sessionId: result.sessionId,
145
+ projectDir: result.projectDir,
146
+ jsonlPath: result.jsonlPath,
147
+ lastJsonlMtimeMs: result.lastJsonlMtimeMs || null,
148
+ createdAt: new Date().toISOString(),
149
+ lastDriveAt: new Date().toISOString(),
150
+ },
151
+ { agentId: cfg.agentId },
152
+ );
153
+ log('info', 'session bound', { threadId, agent, sessionId: result.sessionId });
154
+ } else if (session && !result.skipped) {
155
+ session.lastDriveAt = new Date().toISOString();
113
156
  if (result && result.lastJsonlMtimeMs) {
114
- binding.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
157
+ session.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
115
158
  }
116
159
  saveState(state);
117
160
  }
@@ -126,16 +169,23 @@ const handleEvent = async (sseEvent) => {
126
169
  };
127
170
 
128
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
+
129
176
  log('info', 'starting daemon', {
130
177
  baseUrl: cfg.baseUrl,
131
178
  accountId: cfg.accountId,
132
179
  agentId: cfg.agentId,
133
- agent: activeAgent(),
180
+ capabilities,
134
181
  projectDir: cfg.projectDir,
135
182
  boundThreads: Object.keys(state.bindings || {}),
136
183
  });
137
184
 
138
- const url = cfg.baseUrl.replace(/\/$/, '') + '/api/bridge/stream';
185
+ const url = cfg.baseUrl.replace(/\/$/, '') +
186
+ '/api/bridge/stream' +
187
+ '?agentId=' + encodeURIComponent(cfg.agentId) +
188
+ '&capabilities=' + encodeURIComponent(capabilities.join(','));
139
189
  stream = startStream({
140
190
  url,
141
191
  // Re-read config on every (re)connect so a rotated token (via
package/src/driver.js CHANGED
@@ -1,47 +1,72 @@
1
1
  'use strict';
2
2
 
3
- // Agent-agnostic driver selector. The daemon drives whichever coding agent the
4
- // operator configured — Claude (via the Claude Agent SDK) or Codex (via the
5
- // Codex SDK) — chosen by `agent` in config.json / the BRIDGE_AGENT env var.
6
- // Everything else in the daemon (SSE, state, the bridge HTTP client) is
7
- // agent-neutral; only the driver differs.
3
+ // Dual-driver dispatch (v1.1).
8
4
  //
9
- // The codex driver is require()d lazily, so a Claude-only install never loads
10
- // @openai/codex-sdk (and vice versa).
5
+ // v1 resolved ONE agent at startup and drove only that. v1.1 makes the daemon
6
+ // agent-agnostic per event: it can drive BOTH Claude (Claude Agent SDK) and
7
+ // Codex (Codex SDK), and routes each bridge event to the right driver by the
8
+ // thread's `agent` field (`payload.agent`, set server-side from the thread's
9
+ // routing record).
10
+ //
11
+ // Drivers are require()d lazily and cached — a machine that only ever runs
12
+ // Claude threads never pays to load @openai/codex-sdk, and vice versa.
11
13
 
12
14
  const { loadConfig } = require('./config');
13
15
 
14
- let resolved = null;
16
+ const KNOWN_AGENTS = ['claude', 'codex', 'opencode'];
17
+
18
+ const cache = {};
15
19
 
16
- const pick = () => {
17
- if (resolved) return resolved;
18
- let agent = 'claude';
20
+ const loadDriver = (name) => {
21
+ if (cache[name]) return cache[name];
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');
26
+ cache[name] = mod;
27
+ return mod;
28
+ };
29
+
30
+ // Fallback agent for events that arrive without an explicit `agent` — an
31
+ // older bridge, or a thread created before v1.1. Reads config.agent (the v1
32
+ // init choice), else 'claude'.
33
+ let fallbackAgent = null;
34
+ const getFallbackAgent = () => {
35
+ if (fallbackAgent) return fallbackAgent;
36
+ let a = 'claude';
19
37
  try {
20
- agent = (loadConfig().agent || 'claude').toLowerCase();
38
+ a = (loadConfig().agent || 'claude').toLowerCase();
21
39
  } catch (_) {
22
40
  // config unreadable — default to claude
23
41
  }
24
- if (agent === 'codex') {
25
- resolved = { name: 'codex', mod: require('./codex-driver') };
26
- } else {
27
- resolved = { name: 'claude', mod: require('./claude-driver') };
28
- }
29
- return resolved;
42
+ fallbackAgent = KNOWN_AGENTS.indexOf(a) !== -1 ? a : 'claude';
43
+ return fallbackAgent;
30
44
  };
31
45
 
32
- const drive = (params) => pick().mod.drive(params);
46
+ // Resolve which agent a bridge event targets.
47
+ const agentFor = (payload) => {
48
+ const a = payload && payload.agent ? String(payload.agent).toLowerCase() : '';
49
+ if (KNOWN_AGENTS.indexOf(a) !== -1) return a;
50
+ return getFallbackAgent();
51
+ };
52
+
53
+ // Drive one bridge event with the agent its thread is bound to.
54
+ const drive = (params) => {
55
+ const name = agentFor(params && params.payload);
56
+ return loadDriver(name).drive(params);
57
+ };
33
58
 
34
- // Permission relay is Claude-only — Codex exposes no per-tool callback. For a
35
- // Codex daemon this is always a no-op so the reply path goes straight to
36
- // drive(); for Claude it delegates to the real relay resolver.
59
+ // Permission relay is Claude-only — the Codex SDK exposes no per-tool callback.
60
+ // We delegate to the claude driver's resolver regardless of the thread's agent:
61
+ // it keys on threadId against its own pending-request map, so a codex thread
62
+ // (which never has a pending claude request) simply returns false and the
63
+ // reply falls through to drive().
37
64
  const tryResolvePermission = (threadId, body, log) => {
38
- const p = pick();
39
- if (typeof p.mod.tryResolvePermission === 'function') {
40
- return p.mod.tryResolvePermission(threadId, body, log);
65
+ const claude = loadDriver('claude');
66
+ if (typeof claude.tryResolvePermission === 'function') {
67
+ return claude.tryResolvePermission(threadId, body, log);
41
68
  }
42
69
  return false;
43
70
  };
44
71
 
45
- const activeAgent = () => pick().name;
46
-
47
- module.exports = { drive, tryResolvePermission, activeAgent };
72
+ module.exports = { drive, tryResolvePermission, agentFor, KNOWN_AGENTS };
@@ -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
- // "lastDriveAt": "iso-ts"
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
- const setBinding = (state, threadId, info) => {
50
- state.bindings[threadId] = info;
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
- setBinding,
115
+ getAgentSession,
116
+ setAgentSession,
61
117
  };