@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/LICENSE +190 -0
- package/README.md +120 -0
- package/bin/obto-bridge.js +52 -0
- package/cli/init.js +157 -0
- package/cli/logout.js +23 -0
- package/cli/rotate-token.js +88 -0
- package/cli/start.js +4 -0
- package/cli/status.js +48 -0
- package/cli/whoami.js +70 -0
- package/package.json +37 -0
- package/src/bridge-http.js +87 -0
- package/src/bridge-mcp-server.js +125 -0
- package/src/claude-driver.js +433 -0
- package/src/codex-driver.js +206 -0
- package/src/config.js +65 -0
- package/src/daemon.js +171 -0
- package/src/driver.js +47 -0
- package/src/session-scanner.js +53 -0
- package/src/state.js +61 -0
- package/src/stream-client.js +144 -0
package/cli/status.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
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?"
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const STATE_PATH = path.join(os.homedir(), '.agent-bridge-daemon', 'state.json');
|
|
11
|
+
|
|
12
|
+
let state;
|
|
13
|
+
try {
|
|
14
|
+
state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
|
15
|
+
} catch (_) {
|
|
16
|
+
console.log('No state file yet at ' + STATE_PATH);
|
|
17
|
+
console.log('(The daemon writes this after it drives its first thread session.)');
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const bindings = state && state.bindings ? state.bindings : {};
|
|
22
|
+
const threads = Object.keys(bindings).sort();
|
|
23
|
+
|
|
24
|
+
if (threads.length === 0) {
|
|
25
|
+
console.log('No thread bindings yet.');
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fmtAge = (iso) => {
|
|
30
|
+
if (!iso) return '?';
|
|
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';
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
console.log('Thread Session ID Last drive');
|
|
39
|
+
console.log('-'.repeat(95));
|
|
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));
|
|
44
|
+
});
|
|
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/cli/whoami.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `obto-bridge whoami` — hit GET /api/bridge/whoami with the configured
|
|
4
|
+
// Bearer token and print the result. Verifies (1) config is valid,
|
|
5
|
+
// (2) bridge is reachable, (3) token resolves to an active account.
|
|
6
|
+
|
|
7
|
+
const { loadConfig } = require('../src/config');
|
|
8
|
+
|
|
9
|
+
const main = async () => {
|
|
10
|
+
let cfg;
|
|
11
|
+
try {
|
|
12
|
+
cfg = loadConfig();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error('error: ' + (err && err.message ? err.message : err));
|
|
15
|
+
console.error('Run `obto-bridge init` first.');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const url = cfg.baseUrl.replace(/\/$/, '') + '/api/bridge/whoami';
|
|
20
|
+
let res;
|
|
21
|
+
try {
|
|
22
|
+
res = await fetch(url, {
|
|
23
|
+
method: 'GET',
|
|
24
|
+
headers: {
|
|
25
|
+
Accept: 'application/json',
|
|
26
|
+
'OBTO-ORIGIN-HOST': cfg.originHost,
|
|
27
|
+
Authorization: 'Bearer ' + cfg.apiToken,
|
|
28
|
+
},
|
|
29
|
+
cache: 'no-store',
|
|
30
|
+
});
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error('error: cannot reach ' + url);
|
|
33
|
+
console.error(' ' + (err && err.message ? err.message : err));
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const body = await res.text();
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(body);
|
|
41
|
+
} catch (_) {
|
|
42
|
+
parsed = { _rawBody: body };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
console.error('HTTP ' + res.status + ' — ' + (parsed && parsed.error ? parsed.error : 'request failed'));
|
|
47
|
+
if (res.status === 401) {
|
|
48
|
+
console.error('Your API token is invalid or the account is suspended. Email support@obto.co.');
|
|
49
|
+
}
|
|
50
|
+
process.exit(3);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const a = parsed.account || {};
|
|
54
|
+
console.log('✓ Connected to ' + cfg.baseUrl);
|
|
55
|
+
console.log('');
|
|
56
|
+
console.log(' Account: ' + (a.accountId || '?'));
|
|
57
|
+
console.log(' Username: @' + (a.basicAuthUser || '?'));
|
|
58
|
+
console.log(' Email: ' + (a.email || '?'));
|
|
59
|
+
console.log(' Status: ' + (a.status || '?'));
|
|
60
|
+
console.log(' Token: ' + (a.apiTokenPrefix || '?') + '…');
|
|
61
|
+
console.log(' Agent name: ' + (cfg.agentId || '?'));
|
|
62
|
+
console.log(' Project: ' + (cfg.projectDir || '?'));
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log('Server time: ' + (parsed.server && parsed.server.time ? parsed.server.time : '?'));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
main().catch((err) => {
|
|
68
|
+
console.error('whoami failed: ' + (err && err.message ? err.message : err));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@obtoai/agent-bridge",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
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
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "OBTO Inc.",
|
|
7
|
+
"homepage": "https://github.com/obto-inc/agent-bridge",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/obto-inc/agent-bridge.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/obto-inc/agent-bridge/issues",
|
|
13
|
+
"main": "src/daemon.js",
|
|
14
|
+
"bin": {
|
|
15
|
+
"obto-bridge": "bin/obto-bridge.js"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node bin/obto-bridge.js start"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"src",
|
|
23
|
+
"cli",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18.17"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.126",
|
|
35
|
+
"@openai/codex-sdk": "^0.130.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// HTTP client for talking to the OBTO bridge from the daemon. Bearer auth
|
|
4
|
+
// (per-account token), sets the OBTO-ORIGIN-HOST header so requests route
|
|
5
|
+
// to the ob-agent-bridge app even when its DNS hasn't propagated.
|
|
6
|
+
|
|
7
|
+
const { loadConfig } = require('./config');
|
|
8
|
+
|
|
9
|
+
// No caching. loadConfig() always re-reads ~/.obto-bridge/config.json so a
|
|
10
|
+
// token rotation (which rewrites the file) takes effect on the next request.
|
|
11
|
+
// File reads are local and tiny — cost is negligible.
|
|
12
|
+
const getCfg = () => loadConfig();
|
|
13
|
+
|
|
14
|
+
const buildHeaders = (extra) => {
|
|
15
|
+
const c = getCfg();
|
|
16
|
+
return Object.assign(
|
|
17
|
+
{
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
'OBTO-ORIGIN-HOST': c.originHost,
|
|
20
|
+
Authorization: 'Bearer ' + c.apiToken,
|
|
21
|
+
},
|
|
22
|
+
extra || {},
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const postJson = async (path, body) => {
|
|
27
|
+
const c = getCfg();
|
|
28
|
+
const url = c.baseUrl.replace(/\/$/, '') + path;
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: buildHeaders(),
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
let data;
|
|
35
|
+
const text = await res.text();
|
|
36
|
+
try {
|
|
37
|
+
data = text ? JSON.parse(text) : null;
|
|
38
|
+
} catch (_) {
|
|
39
|
+
data = { _rawBody: text };
|
|
40
|
+
}
|
|
41
|
+
return { status: res.status, ok: res.ok, data };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getJson = async (path) => {
|
|
45
|
+
const c = getCfg();
|
|
46
|
+
const url = c.baseUrl.replace(/\/$/, '') + path;
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
method: 'GET',
|
|
49
|
+
headers: buildHeaders({ Accept: 'application/json' }),
|
|
50
|
+
cache: 'no-store',
|
|
51
|
+
});
|
|
52
|
+
let data;
|
|
53
|
+
const text = await res.text();
|
|
54
|
+
try {
|
|
55
|
+
data = text ? JSON.parse(text) : null;
|
|
56
|
+
} catch (_) {
|
|
57
|
+
data = { _rawBody: text };
|
|
58
|
+
}
|
|
59
|
+
return { status: res.status, ok: res.ok, data };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Post a message via the agent-side /api/message route. Used both for normal
|
|
63
|
+
// agent posts (status/result/etc.) and for permission-relay questions.
|
|
64
|
+
const postMessage = (payload) => postJson('/api/message', payload);
|
|
65
|
+
|
|
66
|
+
// Read recent messages on a thread, optionally since an ISO cursor.
|
|
67
|
+
const getMessages = (threadId, sinceCursor) => {
|
|
68
|
+
const qs =
|
|
69
|
+
'threadId=' +
|
|
70
|
+
encodeURIComponent(String(threadId || '')) +
|
|
71
|
+
(sinceCursor ? '&since=' + encodeURIComponent(String(sinceCursor)) : '');
|
|
72
|
+
return getJson('/api/messages?' + qs);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Transient "agent is working / idle" signal. Fire-and-forget from the daemon
|
|
76
|
+
// around each Claude turn so the browser UI can show a thinking indicator.
|
|
77
|
+
// Not persisted server-side — pure RMQ pub/sub via BridgeBroker.
|
|
78
|
+
const postAgentActivity = (threadId, state) =>
|
|
79
|
+
postJson('/api/bridge/agent-activity', { threadId, state });
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
getCfg,
|
|
83
|
+
buildHeaders,
|
|
84
|
+
postMessage,
|
|
85
|
+
getMessages,
|
|
86
|
+
postAgentActivity,
|
|
87
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// In-process MCP server exposed to daemon-spawned Claude sessions. Each tool
|
|
4
|
+
// wraps the daemon's authenticated HTTP client (basic auth → /api/...). Avoids
|
|
5
|
+
// requiring the spawned session to OAuth into OBTO's external MCP server.
|
|
6
|
+
|
|
7
|
+
const { z } = require('zod/v4');
|
|
8
|
+
const bridgeHttp = require('./bridge-http');
|
|
9
|
+
|
|
10
|
+
let cached = null;
|
|
11
|
+
|
|
12
|
+
const buildBridgeMcpServer = async ({ log }) => {
|
|
13
|
+
if (cached) return cached;
|
|
14
|
+
|
|
15
|
+
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
16
|
+
const { createSdkMcpServer, tool } = sdk;
|
|
17
|
+
|
|
18
|
+
const bridgePost = tool(
|
|
19
|
+
'bridge_post',
|
|
20
|
+
'Post a message to a thread on the OBTO Agent Bridge. THIS IS THE ONLY WAY for ' +
|
|
21
|
+
'this session to communicate with the human who triggered the work. Plain text ' +
|
|
22
|
+
'replies in the conversation are invisible to them. Use kind="result" for the ' +
|
|
23
|
+
'finished answer, "status" for a progress update, "question" if you need ' +
|
|
24
|
+
'clarification before continuing, "error" if something failed.',
|
|
25
|
+
{
|
|
26
|
+
threadId: z.string().min(1).describe(
|
|
27
|
+
'Thread to post to. Use the same threadId that was given to you in the ' +
|
|
28
|
+
'envelope of the message you are responding to.',
|
|
29
|
+
),
|
|
30
|
+
body: z.string().min(1).describe('Message content. Plain text or markdown.'),
|
|
31
|
+
kind: z.enum(['status', 'question', 'result', 'error']).optional().describe(
|
|
32
|
+
'Message kind. Defaults to "result".',
|
|
33
|
+
),
|
|
34
|
+
author: z.string().optional().describe(
|
|
35
|
+
'Optional author override; defaults to "claude-bridge".',
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
async ({ threadId, body, kind, author }) => {
|
|
39
|
+
try {
|
|
40
|
+
const r = await bridgeHttp.postMessage({
|
|
41
|
+
threadId,
|
|
42
|
+
body,
|
|
43
|
+
kind: kind || 'result',
|
|
44
|
+
author: author || 'claude-bridge',
|
|
45
|
+
role: 'agent',
|
|
46
|
+
});
|
|
47
|
+
if (!r.ok) {
|
|
48
|
+
if (log) log('error', 'bridge_post HTTP failed', { status: r.status });
|
|
49
|
+
return {
|
|
50
|
+
content: [
|
|
51
|
+
{
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: 'bridge_post failed: HTTP ' + r.status + ' ' + JSON.stringify(r.data),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
isError: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
content: [
|
|
61
|
+
{
|
|
62
|
+
type: 'text',
|
|
63
|
+
text: 'Posted to bridge thread "' + threadId + '" (kind=' + (kind || 'result') + '). messageId=' +
|
|
64
|
+
((r.data && r.data.message && r.data.message._id) || '?'),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (log) log('error', 'bridge_post threw', { error: e && e.message });
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{ type: 'text', text: 'bridge_post threw: ' + (e && e.message ? e.message : String(e)) },
|
|
73
|
+
],
|
|
74
|
+
isError: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const bridgeThreadRead = tool(
|
|
81
|
+
'bridge_thread_read',
|
|
82
|
+
'Read recent messages on a bridge thread. Use to load context when resuming a ' +
|
|
83
|
+
'session, or to check for human replies after asking a question.',
|
|
84
|
+
{
|
|
85
|
+
threadId: z.string().min(1).describe('Thread to read.'),
|
|
86
|
+
sinceCursor: z.string().optional().describe(
|
|
87
|
+
'ISO 8601 timestamp; only return messages newer than this. Useful for ' +
|
|
88
|
+
'incremental polling so you do not re-process old messages.',
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
async ({ threadId, sinceCursor }) => {
|
|
92
|
+
try {
|
|
93
|
+
const r = await bridgeHttp.getMessages(threadId, sinceCursor);
|
|
94
|
+
if (!r.ok) {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text', text: 'bridge_thread_read failed: HTTP ' + r.status }],
|
|
97
|
+
isError: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }],
|
|
102
|
+
};
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return {
|
|
105
|
+
content: [
|
|
106
|
+
{ type: 'text', text: 'bridge_thread_read threw: ' + (e && e.message ? e.message : String(e)) },
|
|
107
|
+
],
|
|
108
|
+
isError: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
cached = createSdkMcpServer({
|
|
115
|
+
name: 'bridge',
|
|
116
|
+
version: '0.1.0',
|
|
117
|
+
tools: [bridgePost, bridgeThreadRead],
|
|
118
|
+
alwaysLoad: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (log) log('info', 'in-process bridge MCP server ready', { tools: ['bridge_post', 'bridge_thread_read'] });
|
|
122
|
+
return cached;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
module.exports = { buildBridgeMcpServer };
|