@obtoai/agent-bridge 0.1.0-beta.1

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/src/config.js ADDED
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ // Loads daemon config from `~/.obto-bridge/config.json`, with env overrides.
4
+ // Env always wins so users can run the daemon non-interactively in CI / launchd
5
+ // without touching the config file.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ const CONFIG_DIR = path.join(os.homedir(), '.obto-bridge');
12
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
13
+
14
+ const loadConfig = () => {
15
+ let file = {};
16
+ try {
17
+ file = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
18
+ } catch (_) {
19
+ // File missing/invalid — fall back to env-only.
20
+ }
21
+
22
+ const cfg = {
23
+ baseUrl: process.env.BRIDGE_BASE_URL || file.baseUrl || 'https://agent-bridge.obto.co',
24
+ originHost: process.env.BRIDGE_ORIGIN_HOST || file.originHost || 'agent-bridge.obto.co',
25
+ accountId: process.env.BRIDGE_ACCOUNT_ID || file.accountId || '',
26
+ apiToken: process.env.BRIDGE_API_TOKEN || file.apiToken || '',
27
+ agentId: process.env.AGENT_ID || file.agentId || 'unnamed-agent',
28
+ projectDir: path.resolve(
29
+ process.env.BRIDGE_PROJECT_DIR || file.projectDir || process.cwd(),
30
+ ),
31
+ // Which coding agent the daemon drives: 'claude' (Claude Agent SDK) or
32
+ // 'codex' (Codex SDK). Claude is the default and the more capable driver
33
+ // (per-tool permission relay); Codex runs unattended in a fixed sandbox.
34
+ agent: String(process.env.BRIDGE_AGENT || file.agent || 'claude')
35
+ .trim()
36
+ .toLowerCase(),
37
+ // Codex-only: filesystem sandbox for unattended Codex sessions, since
38
+ // Codex has no per-tool human relay. read-only | workspace-write |
39
+ // danger-full-access. BRIDGE_ALLOW_ALL=1 forces danger-full-access.
40
+ codexSandbox:
41
+ process.env.BRIDGE_CODEX_SANDBOX || file.codexSandbox || 'workspace-write',
42
+ // Optional / advanced
43
+ relayPermissions: (process.env.BRIDGE_RELAY_PERMISSIONS === '1') || !!file.relayPermissions,
44
+ allowAll: (process.env.BRIDGE_ALLOW_ALL === '1') || !!file.allowAll,
45
+ relayTimeoutMs:
46
+ parseInt(process.env.BRIDGE_RELAY_TIMEOUT_MS, 10) ||
47
+ file.relayTimeoutMs ||
48
+ 600000,
49
+ liveGuardDisabled: (process.env.BRIDGE_LIVE_GUARD_DISABLED === '1') || !!file.liveGuardDisabled,
50
+ liveThresholdMs:
51
+ parseInt(process.env.BRIDGE_LIVE_THRESHOLD_MS, 10) ||
52
+ file.liveThresholdMs ||
53
+ 60000,
54
+ };
55
+
56
+ if (!cfg.apiToken) {
57
+ throw new Error(
58
+ 'No apiToken configured. Set BRIDGE_API_TOKEN env or write to ' + CONFIG_PATH,
59
+ );
60
+ }
61
+
62
+ return cfg;
63
+ };
64
+
65
+ module.exports = { loadConfig, CONFIG_DIR, CONFIG_PATH };
package/src/daemon.js ADDED
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ const { loadConfig } = require('./config');
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');
8
+
9
+ const log = (level, msg, data) => {
10
+ const line = { ts: new Date().toISOString(), level, msg };
11
+ if (data !== undefined) line.data = data;
12
+ console.log(JSON.stringify(line));
13
+ };
14
+
15
+ // Fire-and-forget activity ping. The "agent is working" indicator is a UX
16
+ // nicety — a failed ping must never interfere with the actual turn, so we
17
+ // swallow every error and never await it in the hot path. We still log both
18
+ // failure modes: a rejected promise (network error) AND a resolved-but-non-ok
19
+ // response (4xx/5xx) — fetch does not throw on HTTP errors, so a 404/401 would
20
+ // otherwise pass silently.
21
+ const emitActivity = (threadId, activityState) => {
22
+ postAgentActivity(threadId, activityState)
23
+ .then((res) => {
24
+ if (!res || !res.ok) {
25
+ log('warn', 'agent-activity ping rejected', {
26
+ threadId,
27
+ state: activityState,
28
+ status: res && res.status,
29
+ body: res && res.data,
30
+ });
31
+ }
32
+ })
33
+ .catch((err) => {
34
+ log('warn', 'agent-activity ping failed', {
35
+ threadId,
36
+ state: activityState,
37
+ error: err && err.message ? err.message : String(err),
38
+ });
39
+ });
40
+ };
41
+
42
+ let cfg;
43
+ try {
44
+ cfg = loadConfig();
45
+ } catch (err) {
46
+ log('error', 'config load failed', { error: err && err.message });
47
+ process.exit(1);
48
+ }
49
+
50
+ const state = loadState();
51
+ let stopped = false;
52
+ let stream = null;
53
+
54
+ const handleEvent = async (sseEvent) => {
55
+ if (sseEvent.event !== 'reply') return;
56
+ let payload;
57
+ try {
58
+ payload = JSON.parse(sseEvent.data);
59
+ } catch (e) {
60
+ log('error', 'unparseable sse data', { error: e.message });
61
+ return;
62
+ }
63
+
64
+ const threadId = String(payload.threadId || '').trim();
65
+ if (!threadId) {
66
+ log('warn', 'event missing threadId — dropping', { messageId: payload.messageId });
67
+ return;
68
+ }
69
+
70
+ const binding = getBinding(state, threadId);
71
+ log('event', 'reply received', {
72
+ threadId,
73
+ author: payload.author,
74
+ messageId: payload.messageId,
75
+ hasBinding: !!binding,
76
+ });
77
+
78
+ // Permission-relay replies: resolve the pending request inside the driver and
79
+ // skip starting a new turn.
80
+ if (tryResolvePermission(threadId, payload.body || '', log)) {
81
+ return;
82
+ }
83
+
84
+ // Tell the browser a turn has started. Cleared in the finally below so the
85
+ // indicator drops whether the turn succeeds, skips, or throws.
86
+ emitActivity(threadId, 'working');
87
+ try {
88
+ const result = await drive({
89
+ threadId,
90
+ projectDir: cfg.projectDir,
91
+ binding,
92
+ payload,
93
+ log,
94
+ });
95
+
96
+ if (
97
+ result &&
98
+ result.sessionId &&
99
+ (!binding || binding.sessionId !== result.sessionId)
100
+ ) {
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();
113
+ if (result && result.lastJsonlMtimeMs) {
114
+ binding.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
115
+ }
116
+ saveState(state);
117
+ }
118
+ } catch (err) {
119
+ log('error', 'handle failed', {
120
+ threadId,
121
+ error: err && err.message ? err.message : String(err),
122
+ });
123
+ } finally {
124
+ emitActivity(threadId, 'idle');
125
+ }
126
+ };
127
+
128
+ const start = () => {
129
+ log('info', 'starting daemon', {
130
+ baseUrl: cfg.baseUrl,
131
+ accountId: cfg.accountId,
132
+ agentId: cfg.agentId,
133
+ agent: activeAgent(),
134
+ projectDir: cfg.projectDir,
135
+ boundThreads: Object.keys(state.bindings || {}),
136
+ });
137
+
138
+ const url = cfg.baseUrl.replace(/\/$/, '') + '/api/bridge/stream';
139
+ stream = startStream({
140
+ url,
141
+ // Re-read config on every (re)connect so a rotated token (via
142
+ // `obto-bridge rotate-token`, which rewrites config.json) is picked up
143
+ // automatically without restarting the daemon.
144
+ getHeaders: () => {
145
+ const fresh = loadConfig();
146
+ return {
147
+ 'OBTO-ORIGIN-HOST': fresh.originHost,
148
+ Authorization: 'Bearer ' + fresh.apiToken,
149
+ };
150
+ },
151
+ onEvent: (ev) => {
152
+ handleEvent(ev).catch((err) => {
153
+ log('error', 'handleEvent threw', { error: err && err.message });
154
+ });
155
+ },
156
+ log,
157
+ });
158
+ };
159
+
160
+ const shutdown = (signal) => {
161
+ if (stopped) return;
162
+ stopped = true;
163
+ log('info', 'shutting down', { signal });
164
+ try { stream && stream.stop(); } catch (_) {}
165
+ setTimeout(() => process.exit(0), 200);
166
+ };
167
+
168
+ process.on('SIGINT', () => shutdown('SIGINT'));
169
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
170
+
171
+ start();
package/src/driver.js ADDED
@@ -0,0 +1,47 @@
1
+ 'use strict';
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.
8
+ //
9
+ // The codex driver is require()d lazily, so a Claude-only install never loads
10
+ // @openai/codex-sdk (and vice versa).
11
+
12
+ const { loadConfig } = require('./config');
13
+
14
+ let resolved = null;
15
+
16
+ const pick = () => {
17
+ if (resolved) return resolved;
18
+ let agent = 'claude';
19
+ try {
20
+ agent = (loadConfig().agent || 'claude').toLowerCase();
21
+ } catch (_) {
22
+ // config unreadable — default to claude
23
+ }
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;
30
+ };
31
+
32
+ const drive = (params) => pick().mod.drive(params);
33
+
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.
37
+ const tryResolvePermission = (threadId, body, log) => {
38
+ const p = pick();
39
+ if (typeof p.mod.tryResolvePermission === 'function') {
40
+ return p.mod.tryResolvePermission(threadId, body, log);
41
+ }
42
+ return false;
43
+ };
44
+
45
+ const activeAgent = () => pick().name;
46
+
47
+ module.exports = { drive, tryResolvePermission, activeAgent };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ // Claude Code stores session JSONLs at:
8
+ // ~/.claude/projects/<encodedAbsProjectDir>/<sessionId>.jsonl
9
+ // where the encoded dir is the absolute cwd with path separators flattened
10
+ // to `-`:
11
+ // macOS/Linux /Users/x/proj -> -Users-x-proj
12
+ // Windows C:\Users\x\proj -> C--Users-x-proj (drive colon + `\`)
13
+ // NOTE: confirm against Claude Code's own Windows encoding before treating
14
+ // this as load-bearing. The daemon only uses it for the (default-off)
15
+ // freshness guard and `obto-bridge status` — resume goes through the SDK by
16
+ // sessionId, not this path — so a Windows mismatch is non-fatal.
17
+ const encodeProjectDir = (absDir) => String(absDir).replace(/[/\\:]/g, '-');
18
+
19
+ const projectHashDir = (absDir) =>
20
+ path.join(os.homedir(), '.claude', 'projects', encodeProjectDir(absDir));
21
+
22
+ // Returns { sessionId, jsonlPath, mtimeMs } for the most-recently-modified
23
+ // session JSONL in the project, or null if the project dir doesn't exist
24
+ // or has no sessions.
25
+ const scanLatestSession = (absProjectDir) => {
26
+ const dir = projectHashDir(absProjectDir);
27
+ let entries;
28
+ try {
29
+ entries = fs.readdirSync(dir);
30
+ } catch (_) {
31
+ return null;
32
+ }
33
+
34
+ let best = null;
35
+ for (const name of entries) {
36
+ if (!name.endsWith('.jsonl')) continue;
37
+ const full = path.join(dir, name);
38
+ let st;
39
+ try {
40
+ st = fs.statSync(full);
41
+ } catch (_) {
42
+ continue;
43
+ }
44
+ if (!st.isFile()) continue;
45
+ const sessionId = name.slice(0, -'.jsonl'.length);
46
+ if (!best || st.mtimeMs > best.mtimeMs) {
47
+ best = { sessionId, jsonlPath: full, mtimeMs: st.mtimeMs };
48
+ }
49
+ }
50
+ return best;
51
+ };
52
+
53
+ module.exports = { encodeProjectDir, projectHashDir, scanLatestSession };
package/src/state.js ADDED
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const STATE_DIR = path.join(os.homedir(), '.agent-bridge-daemon');
8
+ const STATE_PATH = path.join(STATE_DIR, 'state.json');
9
+
10
+ const ensureDir = () => {
11
+ try {
12
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
13
+ } catch (_) {}
14
+ };
15
+
16
+ // Schema:
17
+ // {
18
+ // "bindings": {
19
+ // "<threadId>": {
20
+ // "sessionId": "<uuid>",
21
+ // "projectDir": "/abs/path",
22
+ // "jsonlPath": "/.../<sid>.jsonl",
23
+ // "createdAt": "iso-ts",
24
+ // "lastDriveAt": "iso-ts"
25
+ // }, ...
26
+ // }
27
+ // }
28
+ const loadState = () => {
29
+ let raw;
30
+ try {
31
+ raw = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
32
+ } catch (_) {
33
+ raw = {};
34
+ }
35
+ if (!raw.bindings || typeof raw.bindings !== 'object') {
36
+ raw.bindings = {};
37
+ }
38
+ return raw;
39
+ };
40
+
41
+ const saveState = (state) => {
42
+ ensureDir();
43
+ fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
44
+ };
45
+
46
+ const getBinding = (state, threadId) =>
47
+ state.bindings && state.bindings[threadId] ? state.bindings[threadId] : null;
48
+
49
+ const setBinding = (state, threadId, info) => {
50
+ state.bindings[threadId] = info;
51
+ saveState(state);
52
+ };
53
+
54
+ module.exports = {
55
+ STATE_DIR,
56
+ STATE_PATH,
57
+ loadState,
58
+ saveState,
59
+ getBinding,
60
+ setBinding,
61
+ };
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ // Thin SSE consumer for /api/bridge/stream. Uses built-in fetch + the response
4
+ // body's ReadableStream to parse newline-delimited SSE events. Handles
5
+ // reconnection with exponential backoff. No external deps.
6
+
7
+ const parseSseFrame = (frame) => {
8
+ // SSE frame is a sequence of "field: value" lines terminated by a blank line.
9
+ // We care about: `event:`, `data:`, `id:`. Comments (`:`) are ignored.
10
+ const out = { event: 'message', id: null, data: '' };
11
+ const lines = frame.split('\n');
12
+ for (let i = 0; i < lines.length; i++) {
13
+ const line = lines[i];
14
+ if (!line || line.charAt(0) === ':') continue;
15
+ const idx = line.indexOf(':');
16
+ let field, value;
17
+ if (idx === -1) {
18
+ field = line.trim();
19
+ value = '';
20
+ } else {
21
+ field = line.slice(0, idx).trim();
22
+ value = line.slice(idx + 1);
23
+ if (value.charAt(0) === ' ') value = value.slice(1);
24
+ }
25
+ if (field === 'event') out.event = value;
26
+ else if (field === 'id') out.id = value;
27
+ else if (field === 'data') {
28
+ out.data = out.data ? out.data + '\n' + value : value;
29
+ }
30
+ }
31
+ return out;
32
+ };
33
+
34
+ // Connect once, yield SSE events to the onEvent callback. Returns an
35
+ // AbortController so the caller can cancel.
36
+ const connectOnce = async ({ url, headers, onEvent, onError, log }) => {
37
+ const ac = new AbortController();
38
+ const res = await fetch(url, {
39
+ method: 'GET',
40
+ headers: Object.assign({ Accept: 'text/event-stream' }, headers || {}),
41
+ signal: ac.signal,
42
+ cache: 'no-store',
43
+ });
44
+
45
+ if (!res.ok) {
46
+ const err = new Error('SSE connect failed: HTTP ' + res.status);
47
+ err.status = res.status;
48
+ throw err;
49
+ }
50
+ if (!res.body) {
51
+ throw new Error('SSE connect: no response body');
52
+ }
53
+
54
+ if (log) log('info', 'sse stream connected', { status: res.status });
55
+
56
+ // Read the body as a stream of UTF-8 chunks; split on blank lines (frame
57
+ // delimiter per SSE spec). Pump until end-of-stream or abort.
58
+ (async () => {
59
+ const reader = res.body.getReader();
60
+ const decoder = new TextDecoder('utf-8');
61
+ let buf = '';
62
+ try {
63
+ while (true) {
64
+ const { value, done } = await reader.read();
65
+ if (done) break;
66
+ buf += decoder.decode(value, { stream: true });
67
+
68
+ // Split on \n\n (or \r\n\r\n) — SSE frame delimiter.
69
+ let i;
70
+ while ((i = buf.indexOf('\n\n')) !== -1 || (i = buf.indexOf('\r\n\r\n')) !== -1) {
71
+ const sep = buf.indexOf('\r\n\r\n') !== -1 && buf.indexOf('\r\n\r\n') === i ? 4 : 2;
72
+ const frame = buf.slice(0, i);
73
+ buf = buf.slice(i + sep);
74
+ if (frame.trim().length === 0) continue;
75
+ try {
76
+ onEvent(parseSseFrame(frame));
77
+ } catch (e) {
78
+ if (log) log('error', 'sse frame handler threw', { error: e && e.message });
79
+ }
80
+ }
81
+ }
82
+ if (onError) onError(new Error('stream ended'));
83
+ } catch (err) {
84
+ if (err && err.name === 'AbortError') return;
85
+ if (onError) onError(err);
86
+ }
87
+ })();
88
+
89
+ return ac;
90
+ };
91
+
92
+ // Persistent connection with exponential backoff on disconnect.
93
+ // `getHeaders` is a (sync) function — called fresh on every (re)connect so
94
+ // rotated tokens are picked up automatically.
95
+ const startStream = ({ url, getHeaders, headers, onEvent, log }) => {
96
+ let stopped = false;
97
+ let backoff = 1000;
98
+ let currentAc = null;
99
+
100
+ // Back-compat: callers can pass a static `headers` object OR a `getHeaders`
101
+ // callback. The callback wins; otherwise we wrap the static object.
102
+ const resolveHeaders = typeof getHeaders === 'function'
103
+ ? getHeaders
104
+ : () => headers;
105
+
106
+ const loop = async () => {
107
+ while (!stopped) {
108
+ try {
109
+ currentAc = await connectOnce({
110
+ url,
111
+ headers: resolveHeaders(),
112
+ onEvent,
113
+ onError: (err) => {
114
+ if (log) log('warn', 'sse stream lost', { error: err && err.message });
115
+ try { currentAc && currentAc.abort(); } catch (_) {}
116
+ },
117
+ log,
118
+ });
119
+ backoff = 1000;
120
+ // Wait until the connection ends. We don't have a clean signal from
121
+ // connectOnce for "stream closed" so we busy-poll the AbortController.
122
+ while (!stopped && currentAc && !currentAc.signal.aborted) {
123
+ await new Promise((r) => setTimeout(r, 500));
124
+ }
125
+ } catch (err) {
126
+ if (log) log('error', 'sse connect failed', { error: err && err.message, backoffMs: backoff });
127
+ }
128
+ if (stopped) break;
129
+ await new Promise((r) => setTimeout(r, backoff));
130
+ backoff = Math.min(backoff * 2, 30000);
131
+ }
132
+ };
133
+
134
+ loop();
135
+
136
+ return {
137
+ stop: () => {
138
+ stopped = true;
139
+ try { currentAc && currentAc.abort(); } catch (_) {}
140
+ },
141
+ };
142
+ };
143
+
144
+ module.exports = { startStream, parseSseFrame };