@obtoai/agent-bridge 0.1.0-beta.2 → 0.1.0-beta.3

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.3",
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.",
package/src/daemon.js CHANGED
@@ -2,8 +2,8 @@
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');
5
+ const { loadState, saveState, getAgentSession, setAgentSession } = require('./state');
6
+ const { drive, tryResolvePermission, agentFor } = require('./driver');
7
7
  const { postAgentActivity } = require('./bridge-http');
8
8
 
9
9
  const log = (level, msg, data) => {
@@ -67,12 +67,15 @@ const handleEvent = async (sseEvent) => {
67
67
  return;
68
68
  }
69
69
 
70
- const binding = getBinding(state, threadId);
70
+ // v1.1 which agent this thread is bound to (server-set, on the event).
71
+ const agent = agentFor(payload);
72
+ const session = getAgentSession(state, threadId, agent);
71
73
  log('event', 'reply received', {
72
74
  threadId,
75
+ agent,
73
76
  author: payload.author,
74
77
  messageId: payload.messageId,
75
- hasBinding: !!binding,
78
+ hasSession: !!session,
76
79
  });
77
80
 
78
81
  // Permission-relay replies: resolve the pending request inside the driver and
@@ -85,10 +88,13 @@ const handleEvent = async (sseEvent) => {
85
88
  // indicator drops whether the turn succeeds, skips, or throws.
86
89
  emitActivity(threadId, 'working');
87
90
  try {
91
+ // The driver receives a flat per-agent session as `binding` — the v1
92
+ // driver contract is unchanged. v1.1 just keeps one such session per
93
+ // agent on the thread, so a claude<->codex switch resumes each side.
88
94
  const result = await drive({
89
95
  threadId,
90
96
  projectDir: cfg.projectDir,
91
- binding,
97
+ binding: session,
92
98
  payload,
93
99
  log,
94
100
  });
@@ -96,22 +102,27 @@ const handleEvent = async (sseEvent) => {
96
102
  if (
97
103
  result &&
98
104
  result.sessionId &&
99
- (!binding || binding.sessionId !== result.sessionId)
105
+ (!session || session.sessionId !== result.sessionId)
100
106
  ) {
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();
107
+ setAgentSession(
108
+ state,
109
+ threadId,
110
+ agent,
111
+ {
112
+ sessionId: result.sessionId,
113
+ projectDir: result.projectDir,
114
+ jsonlPath: result.jsonlPath,
115
+ lastJsonlMtimeMs: result.lastJsonlMtimeMs || null,
116
+ createdAt: new Date().toISOString(),
117
+ lastDriveAt: new Date().toISOString(),
118
+ },
119
+ { agentId: cfg.agentId },
120
+ );
121
+ log('info', 'session bound', { threadId, agent, sessionId: result.sessionId });
122
+ } else if (session && !result.skipped) {
123
+ session.lastDriveAt = new Date().toISOString();
113
124
  if (result && result.lastJsonlMtimeMs) {
114
- binding.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
125
+ session.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
115
126
  }
116
127
  saveState(state);
117
128
  }
@@ -130,7 +141,7 @@ const start = () => {
130
141
  baseUrl: cfg.baseUrl,
131
142
  accountId: cfg.accountId,
132
143
  agentId: cfg.agentId,
133
- agent: activeAgent(),
144
+ agents: ['claude', 'codex'],
134
145
  projectDir: cfg.projectDir,
135
146
  boundThreads: Object.keys(state.bindings || {}),
136
147
  });
package/src/driver.js CHANGED
@@ -1,47 +1,71 @@
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'];
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
+ const mod = name === 'codex'
23
+ ? require('./codex-driver')
24
+ : require('./claude-driver');
25
+ cache[name] = mod;
26
+ return mod;
27
+ };
28
+
29
+ // Fallback agent for events that arrive without an explicit `agent` — an
30
+ // older bridge, or a thread created before v1.1. Reads config.agent (the v1
31
+ // init choice), else 'claude'.
32
+ let fallbackAgent = null;
33
+ const getFallbackAgent = () => {
34
+ if (fallbackAgent) return fallbackAgent;
35
+ let a = 'claude';
19
36
  try {
20
- agent = (loadConfig().agent || 'claude').toLowerCase();
37
+ a = (loadConfig().agent || 'claude').toLowerCase();
21
38
  } catch (_) {
22
39
  // config unreadable — default to claude
23
40
  }
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;
41
+ fallbackAgent = a === 'codex' ? 'codex' : 'claude';
42
+ return fallbackAgent;
30
43
  };
31
44
 
32
- const drive = (params) => pick().mod.drive(params);
45
+ // Resolve which agent a bridge event targets.
46
+ const agentFor = (payload) => {
47
+ const a = payload && payload.agent ? String(payload.agent).toLowerCase() : '';
48
+ if (KNOWN_AGENTS.indexOf(a) !== -1) return a;
49
+ return getFallbackAgent();
50
+ };
51
+
52
+ // Drive one bridge event with the agent its thread is bound to.
53
+ const drive = (params) => {
54
+ const name = agentFor(params && params.payload);
55
+ return loadDriver(name).drive(params);
56
+ };
33
57
 
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.
58
+ // Permission relay is Claude-only — the Codex SDK exposes no per-tool callback.
59
+ // We delegate to the claude driver's resolver regardless of the thread's agent:
60
+ // it keys on threadId against its own pending-request map, so a codex thread
61
+ // (which never has a pending claude request) simply returns false and the
62
+ // reply falls through to drive().
37
63
  const tryResolvePermission = (threadId, body, log) => {
38
- const p = pick();
39
- if (typeof p.mod.tryResolvePermission === 'function') {
40
- return p.mod.tryResolvePermission(threadId, body, log);
64
+ const claude = loadDriver('claude');
65
+ if (typeof claude.tryResolvePermission === 'function') {
66
+ return claude.tryResolvePermission(threadId, body, log);
41
67
  }
42
68
  return false;
43
69
  };
44
70
 
45
- const activeAgent = () => pick().name;
46
-
47
- module.exports = { drive, tryResolvePermission, activeAgent };
71
+ module.exports = { drive, tryResolvePermission, agentFor, KNOWN_AGENTS };
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
  };