@obtoai/agent-bridge 0.1.0-beta.8 → 0.1.0-beta.9
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/package.json +1 -1
- package/src/bridge-http.js +8 -0
- package/src/daemon.js +56 -1
- package/src/external-scanner.js +244 -0
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.9",
|
|
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/bridge-http.js
CHANGED
|
@@ -85,6 +85,13 @@ const postAgentActivity = (threadId, state) =>
|
|
|
85
85
|
const claimThread = (threadId, agentId) =>
|
|
86
86
|
postJson('/api/bridge/thread/claim', { threadId, agentId });
|
|
87
87
|
|
|
88
|
+
// Phase 6.1 — push external (non-bridge) sessions discovered by the local
|
|
89
|
+
// filesystem scanner so the bridge UI can render them alongside bridge-owned
|
|
90
|
+
// threads. Fire-and-forget; the bridge tolerates partial payloads and
|
|
91
|
+
// re-observes on the next 30s tick.
|
|
92
|
+
const postExternalSync = (agentId, sessions) =>
|
|
93
|
+
postJson('/api/bridge/external/sync', { agentId, sessions });
|
|
94
|
+
|
|
88
95
|
module.exports = {
|
|
89
96
|
getCfg,
|
|
90
97
|
buildHeaders,
|
|
@@ -92,4 +99,5 @@ module.exports = {
|
|
|
92
99
|
getMessages,
|
|
93
100
|
postAgentActivity,
|
|
94
101
|
claimThread,
|
|
102
|
+
postExternalSync,
|
|
95
103
|
};
|
package/src/daemon.js
CHANGED
|
@@ -4,8 +4,9 @@ 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, claimThread } = require('./bridge-http');
|
|
7
|
+
const { postAgentActivity, claimThread, postExternalSync } = require('./bridge-http');
|
|
8
8
|
const { detect: detectCapabilities } = require('./capabilities');
|
|
9
|
+
const { scanAll: scanExternalSessions } = require('./external-scanner');
|
|
9
10
|
|
|
10
11
|
const log = (level, msg, data) => {
|
|
11
12
|
const line = { ts: new Date().toISOString(), level, msg };
|
|
@@ -207,11 +208,65 @@ const start = () => {
|
|
|
207
208
|
});
|
|
208
209
|
};
|
|
209
210
|
|
|
211
|
+
// Phase 6.1 — External Thread Discovery. Every 30s, scan ~/.claude/projects
|
|
212
|
+
// and ~/.codex/sessions for sessions that didn't originate from the bridge
|
|
213
|
+
// and POST them to /api/bridge/external/sync. Fire-and-forget; failures log
|
|
214
|
+
// and the next tick retries. The bridge dedups by (accountId, sessionId).
|
|
215
|
+
const EXTERNAL_SCAN_INTERVAL_MS = 30000;
|
|
216
|
+
let externalScanTimer = null;
|
|
217
|
+
const ownedSessionIdsFromState = () => {
|
|
218
|
+
const ids = new Set();
|
|
219
|
+
const bindings = (state && state.bindings) || {};
|
|
220
|
+
for (const tid of Object.keys(bindings)) {
|
|
221
|
+
const b = bindings[tid] || {};
|
|
222
|
+
const sessions = b.sessions && typeof b.sessions === 'object'
|
|
223
|
+
? b.sessions
|
|
224
|
+
: (b.sessionId ? { _flat: b } : {});
|
|
225
|
+
for (const k of Object.keys(sessions)) {
|
|
226
|
+
const s = sessions[k] || {};
|
|
227
|
+
if (s.sessionId) ids.add(String(s.sessionId));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return ids;
|
|
231
|
+
};
|
|
232
|
+
const externalScanTick = async () => {
|
|
233
|
+
try {
|
|
234
|
+
const all = scanExternalSessions();
|
|
235
|
+
const owned = ownedSessionIdsFromState();
|
|
236
|
+
const external = all.filter((s) => s && s.sessionId && !owned.has(String(s.sessionId)));
|
|
237
|
+
if (external.length === 0) return;
|
|
238
|
+
const r = await postExternalSync(cfg.agentId, external);
|
|
239
|
+
if (!r || !r.ok) {
|
|
240
|
+
log('warn', 'external sync rejected', {
|
|
241
|
+
status: r && r.status,
|
|
242
|
+
body: r && r.data,
|
|
243
|
+
count: external.length,
|
|
244
|
+
});
|
|
245
|
+
} else {
|
|
246
|
+
log('debug', 'external sync ok', {
|
|
247
|
+
sent: external.length,
|
|
248
|
+
upserted: (r.data && r.data.count) || 0,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {
|
|
252
|
+
log('warn', 'external scan failed', { error: e && e.message ? e.message : String(e) });
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
const startExternalSync = () => {
|
|
256
|
+
// Wait 10s after daemon start before the first scan so the SSE connection
|
|
257
|
+
// is established first. Reduces "cold start everything at once" noise.
|
|
258
|
+
setTimeout(() => {
|
|
259
|
+
externalScanTick();
|
|
260
|
+
externalScanTimer = setInterval(externalScanTick, EXTERNAL_SCAN_INTERVAL_MS);
|
|
261
|
+
}, 10000);
|
|
262
|
+
};
|
|
263
|
+
|
|
210
264
|
const shutdown = (signal) => {
|
|
211
265
|
if (stopped) return;
|
|
212
266
|
stopped = true;
|
|
213
267
|
log('info', 'shutting down', { signal });
|
|
214
268
|
try { stream && stream.stop(); } catch (_) {}
|
|
269
|
+
if (externalScanTimer) { try { clearInterval(externalScanTimer); } catch (_) {} }
|
|
215
270
|
setTimeout(() => process.exit(0), 200);
|
|
216
271
|
};
|
|
217
272
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 6.1 — External Thread Discovery (scanner half).
|
|
4
|
+
//
|
|
5
|
+
// Scans the local filesystem for AI coding sessions started OUTSIDE the bridge
|
|
6
|
+
// and returns a flat list of session records the daemon can POST to
|
|
7
|
+
// /api/bridge/external/sync. The bridge UI then renders them alongside
|
|
8
|
+
// bridge-owned threads — single pane of glass over all the user's AI work.
|
|
9
|
+
//
|
|
10
|
+
// Sources scanned:
|
|
11
|
+
// - Claude Code (CLI + VSCode Claude extension): both write JSONL session
|
|
12
|
+
// files at ~/.claude/projects/<encoded-projectdir>/<sessionId>.jsonl
|
|
13
|
+
// - Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sessionId>.jsonl
|
|
14
|
+
// - opencode is NOT scanned — its SDK is server-bound, no shared JSONL store
|
|
15
|
+
// - Web tools (claude.ai chat, ChatGPT) are out of reach by design
|
|
16
|
+
//
|
|
17
|
+
// Privacy: we extract metadata + the LAST message preview only (1–2 lines,
|
|
18
|
+
// capped at 200 chars). Full transcripts NEVER leave the user's machine.
|
|
19
|
+
// The daemon POSTs the extracted records; the bridge stores them in the
|
|
20
|
+
// agent_bridge_external_sessions Mongo collection.
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
|
|
26
|
+
const CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
27
|
+
const CODEX_DIR = path.join(os.homedir(), '.codex', 'sessions');
|
|
28
|
+
|
|
29
|
+
const PREVIEW_MAX_CHARS = 200;
|
|
30
|
+
const TAIL_READ_BYTES = 8192; // read the last 8KB of each JSONL for the last message
|
|
31
|
+
|
|
32
|
+
// Read the tail of a (potentially large) JSONL file without slurping the
|
|
33
|
+
// whole thing into memory. Returns a string (UTF-8) or '' on any failure.
|
|
34
|
+
const readTail = (filePath, maxBytes = TAIL_READ_BYTES) => {
|
|
35
|
+
let fd = null;
|
|
36
|
+
try {
|
|
37
|
+
const stat = fs.statSync(filePath);
|
|
38
|
+
const size = stat.size;
|
|
39
|
+
const start = Math.max(0, size - maxBytes);
|
|
40
|
+
const len = size - start;
|
|
41
|
+
if (len <= 0) return '';
|
|
42
|
+
fd = fs.openSync(filePath, 'r');
|
|
43
|
+
const buf = Buffer.alloc(len);
|
|
44
|
+
fs.readSync(fd, buf, 0, len, start);
|
|
45
|
+
return buf.toString('utf8');
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return '';
|
|
48
|
+
} finally {
|
|
49
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Walk a JSONL tail backwards, parse each non-empty line as JSON, return the
|
|
54
|
+
// first one we can extract a message from. Tolerant of multiple shapes —
|
|
55
|
+
// Claude and Codex write slightly different envelopes and the formats have
|
|
56
|
+
// drifted across SDK versions.
|
|
57
|
+
const extractLastMessage = (jsonlTail) => {
|
|
58
|
+
if (!jsonlTail) return null;
|
|
59
|
+
const lines = jsonlTail.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
60
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
61
|
+
let obj;
|
|
62
|
+
try {
|
|
63
|
+
obj = JSON.parse(lines[i]);
|
|
64
|
+
} catch (_) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Claude Code session JSONL shapes ─────────────────────────────────
|
|
69
|
+
// Common:
|
|
70
|
+
// { type: 'user', message: { role: 'user', content: '...' } }
|
|
71
|
+
// { type: 'assistant', message: { role: 'assistant', content: [{type:'text', text:'...'}, ...] } }
|
|
72
|
+
if (obj && obj.message && (obj.message.role || obj.type)) {
|
|
73
|
+
const role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
|
|
74
|
+
let raw = obj.message.content;
|
|
75
|
+
let text = '';
|
|
76
|
+
if (typeof raw === 'string') text = raw;
|
|
77
|
+
else if (Array.isArray(raw)) {
|
|
78
|
+
text = raw
|
|
79
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
80
|
+
.map((p) => String(p.text || ''))
|
|
81
|
+
.join(' ');
|
|
82
|
+
}
|
|
83
|
+
text = text.trim();
|
|
84
|
+
if (text) return { author: role === 'user' ? 'user' : 'assistant', preview: text.slice(0, PREVIEW_MAX_CHARS) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Codex SDK rollout shapes ────────────────────────────────────────
|
|
88
|
+
// { record_type: 'message', role: 'assistant', content: [{type:'text', text:'...'}] }
|
|
89
|
+
// { type: 'message', role: 'user', content: '...' }
|
|
90
|
+
// { event: 'output_text', text: '...', role: 'assistant' }
|
|
91
|
+
if (obj && (obj.role || obj.event) && (obj.content || obj.text)) {
|
|
92
|
+
const role = obj.role || (obj.event === 'input_text' ? 'user' : 'assistant');
|
|
93
|
+
let raw = obj.content != null ? obj.content : obj.text;
|
|
94
|
+
let text = '';
|
|
95
|
+
if (typeof raw === 'string') text = raw;
|
|
96
|
+
else if (Array.isArray(raw)) {
|
|
97
|
+
text = raw
|
|
98
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
99
|
+
.map((p) => String(p.text || ''))
|
|
100
|
+
.join(' ');
|
|
101
|
+
}
|
|
102
|
+
text = text.trim();
|
|
103
|
+
if (text) return { author: role === 'user' ? 'user' : 'assistant', preview: text.slice(0, PREVIEW_MAX_CHARS) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Decode Claude's project-dir filename encoding back to a path-like string.
|
|
110
|
+
// Claude turns `/Users/divyansh/foo` → `-Users-divyansh-foo`. We can't
|
|
111
|
+
// perfectly reverse it (project names with literal `-` are ambiguous), but
|
|
112
|
+
// for display purposes leading-dash → leading-slash + dashes → slashes is
|
|
113
|
+
// usually close enough. The BridgeExternal stores both the raw encoded
|
|
114
|
+
// projectDir AND a decoded label; the view route picks the friendlier one.
|
|
115
|
+
const decodeClaudeProjectDir = (encoded) => {
|
|
116
|
+
if (!encoded) return '';
|
|
117
|
+
if (encoded.startsWith('-')) {
|
|
118
|
+
return '/' + encoded.slice(1).replace(/-/g, '/');
|
|
119
|
+
}
|
|
120
|
+
return encoded.replace(/-/g, '/');
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Scan ~/.claude/projects/<encoded-projectdir>/<sessionId>.jsonl
|
|
124
|
+
const scanClaude = () => {
|
|
125
|
+
const out = [];
|
|
126
|
+
let topEntries;
|
|
127
|
+
try { topEntries = fs.readdirSync(CLAUDE_DIR); } catch (_) { return out; }
|
|
128
|
+
|
|
129
|
+
for (const entry of topEntries) {
|
|
130
|
+
const projectPath = path.join(CLAUDE_DIR, entry);
|
|
131
|
+
let projectStat;
|
|
132
|
+
try { projectStat = fs.statSync(projectPath); } catch (_) { continue; }
|
|
133
|
+
if (!projectStat.isDirectory()) continue;
|
|
134
|
+
|
|
135
|
+
let sessionFiles;
|
|
136
|
+
try { sessionFiles = fs.readdirSync(projectPath); } catch (_) { continue; }
|
|
137
|
+
|
|
138
|
+
for (const file of sessionFiles) {
|
|
139
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
140
|
+
const sessionId = file.slice(0, -'.jsonl'.length);
|
|
141
|
+
const filePath = path.join(projectPath, file);
|
|
142
|
+
let stat;
|
|
143
|
+
try { stat = fs.statSync(filePath); } catch (_) { continue; }
|
|
144
|
+
const tail = readTail(filePath);
|
|
145
|
+
const lastMsg = extractLastMessage(tail);
|
|
146
|
+
out.push({
|
|
147
|
+
source: 'claude',
|
|
148
|
+
sessionId,
|
|
149
|
+
projectDir: entry, // raw encoded form
|
|
150
|
+
projectName: decodeClaudeProjectDir(entry), // best-effort decoded
|
|
151
|
+
lastActivityAt: stat.mtimeMs,
|
|
152
|
+
lastMessagePreview: lastMsg ? lastMsg.preview : '',
|
|
153
|
+
lastMessageAuthor: lastMsg ? lastMsg.author : null,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Scan ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sessionId>.jsonl
|
|
161
|
+
// The first JSONL line for a Codex rollout is a session-meta record that
|
|
162
|
+
// contains the working directory; we read it once for projectDir.
|
|
163
|
+
const scanCodex = () => {
|
|
164
|
+
const out = [];
|
|
165
|
+
let years;
|
|
166
|
+
try { years = fs.readdirSync(CODEX_DIR); } catch (_) { return out; }
|
|
167
|
+
|
|
168
|
+
for (const y of years) {
|
|
169
|
+
if (!/^\d{4}$/.test(y)) continue;
|
|
170
|
+
const yPath = path.join(CODEX_DIR, y);
|
|
171
|
+
let months;
|
|
172
|
+
try { months = fs.readdirSync(yPath); } catch (_) { continue; }
|
|
173
|
+
for (const m of months) {
|
|
174
|
+
if (!/^\d{2}$/.test(m)) continue;
|
|
175
|
+
const mPath = path.join(yPath, m);
|
|
176
|
+
let days;
|
|
177
|
+
try { days = fs.readdirSync(mPath); } catch (_) { continue; }
|
|
178
|
+
for (const d of days) {
|
|
179
|
+
if (!/^\d{2}$/.test(d)) continue;
|
|
180
|
+
const dPath = path.join(mPath, d);
|
|
181
|
+
let files;
|
|
182
|
+
try { files = fs.readdirSync(dPath); } catch (_) { continue; }
|
|
183
|
+
for (const f of files) {
|
|
184
|
+
if (!f.startsWith('rollout-') || !f.endsWith('.jsonl')) continue;
|
|
185
|
+
// session id is the last hex/uuid block before .jsonl
|
|
186
|
+
const sidMatch = f.match(/-([0-9a-f-]{8,})\.jsonl$/i);
|
|
187
|
+
if (!sidMatch) continue;
|
|
188
|
+
const sessionId = sidMatch[1];
|
|
189
|
+
const filePath = path.join(dPath, f);
|
|
190
|
+
let stat;
|
|
191
|
+
try { stat = fs.statSync(filePath); } catch (_) { continue; }
|
|
192
|
+
|
|
193
|
+
// Read the first KB to pull the session-meta's working directory.
|
|
194
|
+
let projectDir = '';
|
|
195
|
+
let fd = null;
|
|
196
|
+
try {
|
|
197
|
+
fd = fs.openSync(filePath, 'r');
|
|
198
|
+
const headBuf = Buffer.alloc(Math.min(2048, stat.size));
|
|
199
|
+
fs.readSync(fd, headBuf, 0, headBuf.length, 0);
|
|
200
|
+
const firstLine = headBuf.toString('utf8').split(/\r?\n/)[0] || '';
|
|
201
|
+
try {
|
|
202
|
+
const meta = JSON.parse(firstLine);
|
|
203
|
+
projectDir = String(
|
|
204
|
+
meta?.cwd ||
|
|
205
|
+
meta?.workingDirectory ||
|
|
206
|
+
meta?.working_directory ||
|
|
207
|
+
meta?.session_meta?.cwd ||
|
|
208
|
+
meta?.payload?.cwd ||
|
|
209
|
+
''
|
|
210
|
+
);
|
|
211
|
+
} catch (_) { /* not a meta line — leave projectDir blank */ }
|
|
212
|
+
} catch (_) {} finally {
|
|
213
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const tail = readTail(filePath);
|
|
217
|
+
const lastMsg = extractLastMessage(tail);
|
|
218
|
+
out.push({
|
|
219
|
+
source: 'codex',
|
|
220
|
+
sessionId,
|
|
221
|
+
projectDir: projectDir || `${y}/${m}/${d}`,
|
|
222
|
+
projectName: projectDir || null,
|
|
223
|
+
lastActivityAt: stat.mtimeMs,
|
|
224
|
+
lastMessagePreview: lastMsg ? lastMsg.preview : '',
|
|
225
|
+
lastMessageAuthor: lastMsg ? lastMsg.author : null,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Public entry: returns a flat list of every external session found.
|
|
235
|
+
// Synchronous on purpose — the daemon calls this on a 30s timer and the
|
|
236
|
+
// total IO is dominated by readdir + a single readSync per file. Async
|
|
237
|
+
// would only complicate retry/cancel semantics with no real benefit.
|
|
238
|
+
const scanAll = () => {
|
|
239
|
+
const claude = scanClaude();
|
|
240
|
+
const codex = scanCodex();
|
|
241
|
+
return claude.concat(codex);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
module.exports = { scanAll, scanClaude, scanCodex, extractLastMessage, decodeClaudeProjectDir };
|