@obtoai/agent-bridge 0.1.0-beta.7 → 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/cli/init.js CHANGED
@@ -159,7 +159,7 @@ const main = async () => {
159
159
  const username = cliUsername || deriveUsername(email);
160
160
  const password = cliPassword || generatePassword();
161
161
  console.log('');
162
- console.log(' Username: @' + username + (cliUsername ? '' : ' (derived from email)'));
162
+ console.log(' Username: ' + username + (cliUsername ? '' : ' (derived from email)'));
163
163
  if (!cliPassword) {
164
164
  console.log(' Password: ' + password + ' (auto-generated)');
165
165
  console.log('');
@@ -191,7 +191,7 @@ const main = async () => {
191
191
  registeredPassword = password;
192
192
  console.log(' ✓ Free account created.');
193
193
  console.log(' Account: ' + accountId);
194
- console.log(' User: @' + registeredUser);
194
+ console.log(' Username: ' + registeredUser + ' (sign in with this exact string — no @)');
195
195
  console.log(' Plan: ' + (r.data.plan || 'free'));
196
196
  console.log('');
197
197
  }
@@ -258,13 +258,13 @@ const main = async () => {
258
258
 
259
259
  if (result.ok && result.parsed && result.parsed.account) {
260
260
  const a = result.parsed.account;
261
- console.log(' ✓ Authenticated as @' + a.basicAuthUser + ' (' + a.accountId + ', status: ' + a.status + ')');
261
+ console.log(' ✓ Authenticated as ' + a.basicAuthUser + ' (' + a.accountId + ', status: ' + a.status + ')');
262
262
  console.log('');
263
263
  // The OBTO platform's root URL bounces unauthenticated users to /login.bto;
264
264
  // /api/view is the canonical bridge entry point that serves either the
265
265
  // sign-in form (unauthenticated) or the threads UI (authenticated).
266
266
  const signInUrl = baseUrl.replace(/\/$/, '') + '/api/view';
267
- console.log('Sign in at ' + signInUrl + ' as @' + a.basicAuthUser + (registeredPassword ? ' (password above)' : '') + '.');
267
+ console.log('Sign in at ' + signInUrl + ' as ' + a.basicAuthUser + (registeredPassword ? ' (password above)' : '') + '.');
268
268
  console.log('Run: obto-bridge start');
269
269
  return;
270
270
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obtoai/agent-bridge",
3
- "version": "0.1.0-beta.7",
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.",
@@ -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 };