@obtoai/agent-bridge 0.1.0-beta.10 → 0.1.0-beta.12
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/daemon.js +38 -2
- package/src/external-scanner.js +60 -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.12",
|
|
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
|
@@ -102,7 +102,43 @@ const handleEvent = async (sseEvent) => {
|
|
|
102
102
|
|
|
103
103
|
// v1.1 — which agent this thread is bound to (server-set, on the event).
|
|
104
104
|
const agent = agentFor(payload);
|
|
105
|
-
|
|
105
|
+
let session = getAgentSession(state, threadId, agent);
|
|
106
|
+
let wasAdoption = false;
|
|
107
|
+
|
|
108
|
+
// Phase 6.2 — adopt external session on first turn. The bridge attaches
|
|
109
|
+
// `externalAdoption: {sessionId, projectDir, projectName, source}` to the
|
|
110
|
+
// reply payload for adopted threads. With no prior session for this
|
|
111
|
+
// (thread, agent), synthesize a binding pointing at the original engine
|
|
112
|
+
// session so the driver resumes it instead of first-touching fresh —
|
|
113
|
+
// context preserved end-to-end. After the first successful drive, the
|
|
114
|
+
// session is persisted to state.json and externalAdoption stops mattering.
|
|
115
|
+
if (!session && payload.externalAdoption && payload.externalAdoption.sessionId) {
|
|
116
|
+
const ea = payload.externalAdoption;
|
|
117
|
+
// Prefer projectName (already-decoded filesystem path) for cwd. Claude
|
|
118
|
+
// scanner emits both raw `-Users-foo` and decoded `/Users/foo`; the
|
|
119
|
+
// decoded form is what the SDK's cwd argument expects.
|
|
120
|
+
let resumeCwd = ea.projectName || ea.projectDir || cfg.projectDir;
|
|
121
|
+
if (typeof resumeCwd === 'string' && resumeCwd.startsWith('//')) {
|
|
122
|
+
resumeCwd = '/' + resumeCwd.replace(/^\/+/, '');
|
|
123
|
+
}
|
|
124
|
+
session = {
|
|
125
|
+
sessionId: ea.sessionId,
|
|
126
|
+
projectDir: resumeCwd,
|
|
127
|
+
jsonlPath: null,
|
|
128
|
+
lastJsonlMtimeMs: null,
|
|
129
|
+
createdAt: new Date().toISOString(),
|
|
130
|
+
lastDriveAt: null,
|
|
131
|
+
};
|
|
132
|
+
wasAdoption = true;
|
|
133
|
+
log('info', 'adopting external session on first touch', {
|
|
134
|
+
threadId,
|
|
135
|
+
agent,
|
|
136
|
+
sessionId: ea.sessionId,
|
|
137
|
+
cwd: resumeCwd,
|
|
138
|
+
source: ea.source,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
106
142
|
log('event', 'reply received', {
|
|
107
143
|
threadId,
|
|
108
144
|
agent,
|
|
@@ -135,7 +171,7 @@ const handleEvent = async (sseEvent) => {
|
|
|
135
171
|
if (
|
|
136
172
|
result &&
|
|
137
173
|
result.sessionId &&
|
|
138
|
-
(!session || session.sessionId !== result.sessionId)
|
|
174
|
+
(!session || session.sessionId !== result.sessionId || wasAdoption)
|
|
139
175
|
) {
|
|
140
176
|
setAgentSession(
|
|
141
177
|
state,
|
package/src/external-scanner.js
CHANGED
|
@@ -27,7 +27,9 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
|
27
27
|
const CODEX_DIR = path.join(os.homedir(), '.codex', 'sessions');
|
|
28
28
|
|
|
29
29
|
const PREVIEW_MAX_CHARS = 200;
|
|
30
|
+
const TITLE_MAX_CHARS = 80;
|
|
30
31
|
const TAIL_READ_BYTES = 8192; // read the last 8KB of each JSONL for the last message
|
|
32
|
+
const HEAD_READ_BYTES = 4096; // read the first 4KB for the first-user-message title
|
|
31
33
|
|
|
32
34
|
// Read the tail of a (potentially large) JSONL file without slurping the
|
|
33
35
|
// whole thing into memory. Returns a string (UTF-8) or '' on any failure.
|
|
@@ -50,6 +52,58 @@ const readTail = (filePath, maxBytes = TAIL_READ_BYTES) => {
|
|
|
50
52
|
}
|
|
51
53
|
};
|
|
52
54
|
|
|
55
|
+
// Read the head of a JSONL file (first ~4KB). Used to extract the first user
|
|
56
|
+
// message for a meaningful session title — "fix the auth bug" beats "OBTO".
|
|
57
|
+
const readHead = (filePath, maxBytes = HEAD_READ_BYTES) => {
|
|
58
|
+
let fd = null;
|
|
59
|
+
try {
|
|
60
|
+
const stat = fs.statSync(filePath);
|
|
61
|
+
const len = Math.min(stat.size, maxBytes);
|
|
62
|
+
if (len <= 0) return '';
|
|
63
|
+
fd = fs.openSync(filePath, 'r');
|
|
64
|
+
const buf = Buffer.alloc(len);
|
|
65
|
+
fs.readSync(fd, buf, 0, len, 0);
|
|
66
|
+
return buf.toString('utf8');
|
|
67
|
+
} catch (_) {
|
|
68
|
+
return '';
|
|
69
|
+
} finally {
|
|
70
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Walk JSONL lines forward to find the first user message. Same tolerance
|
|
75
|
+
// for the two SDKs' shapes as extractLastMessage. Returns a short title
|
|
76
|
+
// suitable for a thread name, or '' if no user message landed in the head.
|
|
77
|
+
const extractTitle = (jsonlHead) => {
|
|
78
|
+
if (!jsonlHead) return '';
|
|
79
|
+
const lines = jsonlHead.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
80
|
+
for (let i = 0; i < lines.length; i++) {
|
|
81
|
+
let obj;
|
|
82
|
+
try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
|
|
83
|
+
let role = null;
|
|
84
|
+
let raw = null;
|
|
85
|
+
if (obj && obj.message && (obj.message.role || obj.type)) {
|
|
86
|
+
role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
|
|
87
|
+
raw = obj.message.content;
|
|
88
|
+
} else if (obj && obj.role && (obj.content || obj.text)) {
|
|
89
|
+
role = obj.role;
|
|
90
|
+
raw = obj.content != null ? obj.content : obj.text;
|
|
91
|
+
}
|
|
92
|
+
if (role !== 'user' || raw == null) continue;
|
|
93
|
+
let text = '';
|
|
94
|
+
if (typeof raw === 'string') text = raw;
|
|
95
|
+
else if (Array.isArray(raw)) {
|
|
96
|
+
text = raw
|
|
97
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
98
|
+
.map((p) => String(p.text || ''))
|
|
99
|
+
.join(' ');
|
|
100
|
+
}
|
|
101
|
+
text = text.trim().replace(/\s+/g, ' ');
|
|
102
|
+
if (text) return text.slice(0, TITLE_MAX_CHARS);
|
|
103
|
+
}
|
|
104
|
+
return '';
|
|
105
|
+
};
|
|
106
|
+
|
|
53
107
|
// Walk a JSONL tail backwards, parse each non-empty line as JSON, return the
|
|
54
108
|
// first one we can extract a message from. Tolerant of multiple shapes —
|
|
55
109
|
// Claude and Codex write slightly different envelopes and the formats have
|
|
@@ -142,12 +196,15 @@ const scanClaude = () => {
|
|
|
142
196
|
let stat;
|
|
143
197
|
try { stat = fs.statSync(filePath); } catch (_) { continue; }
|
|
144
198
|
const tail = readTail(filePath);
|
|
199
|
+
const head = readHead(filePath);
|
|
145
200
|
const lastMsg = extractLastMessage(tail);
|
|
201
|
+
const title = extractTitle(head);
|
|
146
202
|
out.push({
|
|
147
203
|
source: 'claude',
|
|
148
204
|
sessionId,
|
|
149
205
|
projectDir: entry, // raw encoded form
|
|
150
206
|
projectName: decodeClaudeProjectDir(entry), // best-effort decoded
|
|
207
|
+
title: title, // first-user-message slice
|
|
151
208
|
lastActivityAt: stat.mtimeMs,
|
|
152
209
|
lastMessagePreview: lastMsg ? lastMsg.preview : '',
|
|
153
210
|
lastMessageAuthor: lastMsg ? lastMsg.author : null,
|
|
@@ -214,12 +271,15 @@ const scanCodex = () => {
|
|
|
214
271
|
}
|
|
215
272
|
|
|
216
273
|
const tail = readTail(filePath);
|
|
274
|
+
const head = readHead(filePath);
|
|
217
275
|
const lastMsg = extractLastMessage(tail);
|
|
276
|
+
const title = extractTitle(head);
|
|
218
277
|
out.push({
|
|
219
278
|
source: 'codex',
|
|
220
279
|
sessionId,
|
|
221
280
|
projectDir: projectDir || `${y}/${m}/${d}`,
|
|
222
281
|
projectName: projectDir || null,
|
|
282
|
+
title: title,
|
|
223
283
|
lastActivityAt: stat.mtimeMs,
|
|
224
284
|
lastMessagePreview: lastMsg ? lastMsg.preview : '',
|
|
225
285
|
lastMessageAuthor: lastMsg ? lastMsg.author : null,
|