@obtoai/agent-bridge 0.1.0-beta.2 → 0.1.0-beta.21
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/README.md +55 -31
- package/bin/obto-bridge.js +8 -5
- package/cli/init.js +149 -17
- package/cli/status.js +28 -15
- package/package.json +3 -2
- package/src/bridge-http.js +16 -0
- package/src/capabilities.js +31 -0
- package/src/daemon.js +191 -22
- package/src/driver.js +53 -28
- package/src/external-scanner.js +546 -0
- package/src/opencode-driver.js +220 -0
- package/src/state.js +64 -8
|
@@ -0,0 +1,546 @@
|
|
|
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
|
+
// Claude Desktop app's local-agent mode stores session JSONLs under
|
|
29
|
+
// ~/Library/Application Support/Claude/local-agent-mode-sessions/<uuid>/<uuid>/local_<uuid>/.claude/projects/<encoded>/<sid>.jsonl
|
|
30
|
+
// The file format is identical to Claude Code CLI's — same queue-operation
|
|
31
|
+
// preamble, same user/assistant message shape. Just a different root.
|
|
32
|
+
// Phase 6.2.5 — beta.19: surface these so the bridge sees every Claude
|
|
33
|
+
// session on disk, not just the CLI/extension subset.
|
|
34
|
+
const CLAUDE_DESKTOP_BASE = path.join(
|
|
35
|
+
os.homedir(),
|
|
36
|
+
'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const PREVIEW_MAX_CHARS = 200;
|
|
40
|
+
const TITLE_MAX_CHARS = 80;
|
|
41
|
+
const TAIL_READ_BYTES = 16384; // fixed-budget tail (cheap; used for ai-title + lastMessage)
|
|
42
|
+
const TAIL_STREAM_MAX_BYTES = 524288;// streaming tail cap — 512KB max read for recents (Phase 6.2.4)
|
|
43
|
+
const TAIL_STREAM_CHUNK = 65536; // 64KB chunk size for the backward stream
|
|
44
|
+
const TAIL_STREAM_MIN_MESSAGES = 10; // stop reading once we have this many text-bearing lines
|
|
45
|
+
const TITLE_MAX_LINES = 40; // scan up to this many lines for a real first message
|
|
46
|
+
const TITLE_MAX_BYTES = 65536; // hard ceiling per file even if MAX_LINES never reached
|
|
47
|
+
|
|
48
|
+
// Read the tail of a (potentially large) JSONL file without slurping the
|
|
49
|
+
// whole thing into memory. Returns a string (UTF-8) or '' on any failure.
|
|
50
|
+
const readTail = (filePath, maxBytes = TAIL_READ_BYTES) => {
|
|
51
|
+
let fd = null;
|
|
52
|
+
try {
|
|
53
|
+
const stat = fs.statSync(filePath);
|
|
54
|
+
const size = stat.size;
|
|
55
|
+
const start = Math.max(0, size - maxBytes);
|
|
56
|
+
const len = size - start;
|
|
57
|
+
if (len <= 0) return '';
|
|
58
|
+
fd = fs.openSync(filePath, 'r');
|
|
59
|
+
const buf = Buffer.alloc(len);
|
|
60
|
+
fs.readSync(fd, buf, 0, len, start);
|
|
61
|
+
return buf.toString('utf8');
|
|
62
|
+
} catch (_) {
|
|
63
|
+
return '';
|
|
64
|
+
} finally {
|
|
65
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Phase 6.2.4 — streaming tail. For sessions where individual JSONL records
|
|
70
|
+
// exceed the fixed 16KB budget (long assistant messages with lots of inline
|
|
71
|
+
// code), the fixed tail returns 0 parseable lines. This reads backward in
|
|
72
|
+
// 64KB chunks until we either have ≥TAIL_STREAM_MIN_MESSAGES text-bearing
|
|
73
|
+
// user/assistant lines OR hit TAIL_STREAM_MAX_BYTES (512KB) — whichever
|
|
74
|
+
// comes first. The first partial line at the buffer boundary is dropped
|
|
75
|
+
// because it won't JSON.parse cleanly; that's fine, the next chunk will
|
|
76
|
+
// pick it up complete.
|
|
77
|
+
const readTailUntilMessages = (filePath) => {
|
|
78
|
+
let fd = null;
|
|
79
|
+
try {
|
|
80
|
+
const stat = fs.statSync(filePath);
|
|
81
|
+
if (stat.size === 0) return '';
|
|
82
|
+
fd = fs.openSync(filePath, 'r');
|
|
83
|
+
let buffer = '';
|
|
84
|
+
let pos = stat.size;
|
|
85
|
+
while (pos > 0 && buffer.length < TAIL_STREAM_MAX_BYTES) {
|
|
86
|
+
const readSize = Math.min(TAIL_STREAM_CHUNK, pos);
|
|
87
|
+
pos -= readSize;
|
|
88
|
+
const chunk = Buffer.alloc(readSize);
|
|
89
|
+
fs.readSync(fd, chunk, 0, readSize, pos);
|
|
90
|
+
buffer = chunk.toString('utf8') + buffer;
|
|
91
|
+
// Cheap completeness check: count parseable user/assistant lines with text.
|
|
92
|
+
const lines = buffer.split(/\r?\n/);
|
|
93
|
+
let count = 0;
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (!line.trim()) continue;
|
|
96
|
+
let obj;
|
|
97
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
98
|
+
const role = (obj && (obj.role || (obj.message && obj.message.role))) || null;
|
|
99
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
100
|
+
const raw = (obj.message && obj.message.content) || obj.content;
|
|
101
|
+
if (!raw) continue;
|
|
102
|
+
if (typeof raw === 'string') {
|
|
103
|
+
if (raw.trim()) count++;
|
|
104
|
+
} else if (Array.isArray(raw)) {
|
|
105
|
+
for (const p of raw) {
|
|
106
|
+
if (p && (p.type === 'text' || typeof p.text === 'string') && String(p.text || '').trim()) {
|
|
107
|
+
count++; break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (count >= TAIL_STREAM_MIN_MESSAGES) break;
|
|
113
|
+
}
|
|
114
|
+
return buffer;
|
|
115
|
+
} catch (_) {
|
|
116
|
+
return '';
|
|
117
|
+
} finally {
|
|
118
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Pull the last N user/assistant *logical turns* from the tail, oldest-first.
|
|
123
|
+
// Phase 6.2.3 — beta.17 fix: Claude Code streams a single assistant reply as
|
|
124
|
+
// MULTIPLE JSONL lines (text → tool_use → tool_result → text → …), each its
|
|
125
|
+
// own JSONL record. The earlier extractor took one line per turn and
|
|
126
|
+
// fragmented responses into single-shard previews. Now we walk forward,
|
|
127
|
+
// parse every text-bearing user/assistant line, and **coalesce consecutive
|
|
128
|
+
// same-role lines into one logical turn** before slicing the last N. Result:
|
|
129
|
+
// a real conversation, not a single mid-sentence excerpt.
|
|
130
|
+
const RECENT_MESSAGE_BODY_MAX = 3000;
|
|
131
|
+
const RECENT_TURN_COUNT = 10;
|
|
132
|
+
|
|
133
|
+
const extractRecentMessages = (jsonlTail, n = RECENT_TURN_COUNT) => {
|
|
134
|
+
if (!jsonlTail) return [];
|
|
135
|
+
const lines = jsonlTail.split(/\r?\n/);
|
|
136
|
+
const parsed = [];
|
|
137
|
+
for (const lineRaw of lines) {
|
|
138
|
+
const line = lineRaw.trim();
|
|
139
|
+
if (!line) continue;
|
|
140
|
+
let obj;
|
|
141
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
142
|
+
let role = null, raw = null;
|
|
143
|
+
if (obj && obj.message && (obj.message.role || obj.type)) {
|
|
144
|
+
role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
|
|
145
|
+
raw = obj.message.content;
|
|
146
|
+
} else if (obj && obj.role && (obj.content || obj.text)) {
|
|
147
|
+
role = obj.role;
|
|
148
|
+
raw = obj.content != null ? obj.content : obj.text;
|
|
149
|
+
}
|
|
150
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
151
|
+
if (raw == null) continue;
|
|
152
|
+
let text = '';
|
|
153
|
+
if (typeof raw === 'string') text = raw;
|
|
154
|
+
else if (Array.isArray(raw)) {
|
|
155
|
+
text = raw
|
|
156
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
157
|
+
.map((p) => String(p.text || ''))
|
|
158
|
+
.join('\n');
|
|
159
|
+
}
|
|
160
|
+
// Phase 6.2.6 — keep newlines so the bridge UI can render markdown
|
|
161
|
+
// (tables, lists, code blocks, paragraphs). Collapsing every \s+ to a
|
|
162
|
+
// single space was stripping all the formatting before storage. We
|
|
163
|
+
// still collapse runs of horizontal whitespace and excessive blank
|
|
164
|
+
// lines, but real line breaks survive.
|
|
165
|
+
text = text.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
|
166
|
+
if (!text) continue; // skip pure tool_use / tool_result lines (no text body)
|
|
167
|
+
parsed.push({ role, text, ts: obj.timestamp || null });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Coalesce consecutive same-role lines (one logical turn split across JSONL
|
|
171
|
+
// records). This is the actual fix — without it, assistant turns with mid-
|
|
172
|
+
// reply tool calls fragment into the first text shard only. Joining with
|
|
173
|
+
// \n\n preserves paragraph boundaries between coalesced chunks so markdown
|
|
174
|
+
// renders correctly.
|
|
175
|
+
const coalesced = [];
|
|
176
|
+
for (const p of parsed) {
|
|
177
|
+
const last = coalesced[coalesced.length - 1];
|
|
178
|
+
if (last && last.role === p.role) {
|
|
179
|
+
last.text = (last.text + '\n\n' + p.text).replace(/\n{3,}/g, '\n\n').trim();
|
|
180
|
+
last.ts = p.ts || last.ts;
|
|
181
|
+
} else {
|
|
182
|
+
coalesced.push({ role: p.role, text: p.text, ts: p.ts });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Filter user turns that are pure platform-injection noise. Assistant turns
|
|
187
|
+
// are never filtered — they're always real Claude/Codex output.
|
|
188
|
+
const filtered = coalesced.filter((m) => {
|
|
189
|
+
if (m.role !== 'user') return true;
|
|
190
|
+
return !isInjectionMessage(m.text);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const sliced = filtered.slice(-n);
|
|
194
|
+
return sliced.map((m) => ({
|
|
195
|
+
role: m.role,
|
|
196
|
+
body: m.text.length > RECENT_MESSAGE_BODY_MAX ? m.text.slice(0, RECENT_MESSAGE_BODY_MAX) : m.text,
|
|
197
|
+
ts: m.ts,
|
|
198
|
+
}));
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Claude Code writes an LLM-generated title as a `type: "ai-title"` JSONL
|
|
202
|
+
// record near the end of each session file (this is the same title VSCode's
|
|
203
|
+
// session list shows — "Analyze MongoDB MCP server architecture" style).
|
|
204
|
+
// If we find one, it beats anything we could extract from the user's first
|
|
205
|
+
// raw prompt. Scan the tail backwards to hit it fast.
|
|
206
|
+
const extractAiTitleFromTail = (jsonlTail) => {
|
|
207
|
+
if (!jsonlTail) return '';
|
|
208
|
+
const lines = jsonlTail.split(/\r?\n/);
|
|
209
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
210
|
+
const line = lines[i];
|
|
211
|
+
// Cheap pre-filter so we only JSON.parse candidate lines.
|
|
212
|
+
if (line.indexOf('ai-title') === -1 && line.indexOf('aiTitle') === -1) continue;
|
|
213
|
+
try {
|
|
214
|
+
const obj = JSON.parse(line);
|
|
215
|
+
if (obj && obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
|
|
216
|
+
const t = obj.aiTitle.trim();
|
|
217
|
+
if (t) return t.length > TITLE_MAX_CHARS ? t.slice(0, TITLE_MAX_CHARS) : t;
|
|
218
|
+
}
|
|
219
|
+
} catch (_) { /* not a JSON line — keep walking */ }
|
|
220
|
+
}
|
|
221
|
+
return '';
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
// "user" messages BEFORE the human's first real prompt — system reminders,
|
|
226
|
+
// IDE state, untrusted-metadata wrappers, command blocks, etc. These are
|
|
227
|
+
// noise from a label perspective. Filter them so the first PLAIN user
|
|
228
|
+
// prompt is what we use as the thread title.
|
|
229
|
+
const isInjectionMessage = (text) => {
|
|
230
|
+
const s = String(text || '').trim();
|
|
231
|
+
if (!s) return true;
|
|
232
|
+
if (/^<[a-zA-Z!]/.test(s)) return true; // <system-reminder>, <ide_opened_file>, etc.
|
|
233
|
+
if (/^Sender \(untrusted metadata\)/i.test(s)) return true;
|
|
234
|
+
if (/^Conversation info \(untrusted metadata\)/i.test(s)) return true;
|
|
235
|
+
if (/^untrusted metadata/i.test(s)) return true;
|
|
236
|
+
if (/^Caveat:/i.test(s)) return true;
|
|
237
|
+
if (/^Claude was launched/i.test(s)) return true;
|
|
238
|
+
if (/^```json/i.test(s)) return true;
|
|
239
|
+
if (/^`<command-name>/.test(s)) return true;
|
|
240
|
+
if (/^This session is being continued/i.test(s)) return true; // /resume preamble
|
|
241
|
+
return false;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Walk a JSONL file line-by-line until we find a plain user message (skipping
|
|
245
|
+
// metadata injections) or we've checked TITLE_MAX_LINES / TITLE_MAX_BYTES.
|
|
246
|
+
// Streaming so big files don't get fully slurped. Returns a clean ≤80-char
|
|
247
|
+
// title or '' if nothing meaningful was found in the bound.
|
|
248
|
+
const extractTitleFromFile = (filePath) => {
|
|
249
|
+
let fd = null;
|
|
250
|
+
try {
|
|
251
|
+
fd = fs.openSync(filePath, 'r');
|
|
252
|
+
const CHUNK = 16 * 1024;
|
|
253
|
+
const buf = Buffer.alloc(CHUNK);
|
|
254
|
+
let carry = '';
|
|
255
|
+
let bytesRead = 0;
|
|
256
|
+
let linesChecked = 0;
|
|
257
|
+
while (bytesRead < TITLE_MAX_BYTES && linesChecked < TITLE_MAX_LINES) {
|
|
258
|
+
const n = fs.readSync(fd, buf, 0, CHUNK, bytesRead);
|
|
259
|
+
if (n === 0) break;
|
|
260
|
+
bytesRead += n;
|
|
261
|
+
carry += buf.toString('utf8', 0, n);
|
|
262
|
+
const lines = carry.split(/\r?\n/);
|
|
263
|
+
carry = lines.pop() || '';
|
|
264
|
+
for (const line of lines) {
|
|
265
|
+
if (linesChecked >= TITLE_MAX_LINES) break;
|
|
266
|
+
if (!line.trim()) continue;
|
|
267
|
+
linesChecked++;
|
|
268
|
+
let obj;
|
|
269
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
270
|
+
let role = null, raw = null;
|
|
271
|
+
if (obj && obj.message && (obj.message.role || obj.type)) {
|
|
272
|
+
role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
|
|
273
|
+
raw = obj.message.content;
|
|
274
|
+
} else if (obj && obj.role && (obj.content || obj.text)) {
|
|
275
|
+
role = obj.role;
|
|
276
|
+
raw = obj.content != null ? obj.content : obj.text;
|
|
277
|
+
}
|
|
278
|
+
if (role !== 'user' || raw == null) continue;
|
|
279
|
+
let text = '';
|
|
280
|
+
if (typeof raw === 'string') text = raw;
|
|
281
|
+
else if (Array.isArray(raw)) {
|
|
282
|
+
text = raw
|
|
283
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
284
|
+
.map((p) => String(p.text || ''))
|
|
285
|
+
.join('\n');
|
|
286
|
+
}
|
|
287
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
288
|
+
if (!text) continue;
|
|
289
|
+
if (isInjectionMessage(text)) continue;
|
|
290
|
+
return text.length > TITLE_MAX_CHARS ? text.slice(0, TITLE_MAX_CHARS) : text;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return '';
|
|
294
|
+
} catch (_) {
|
|
295
|
+
return '';
|
|
296
|
+
} finally {
|
|
297
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Walk a JSONL tail backwards, parse each non-empty line as JSON, return the
|
|
302
|
+
// first one we can extract a message from. Tolerant of multiple shapes —
|
|
303
|
+
// Claude and Codex write slightly different envelopes and the formats have
|
|
304
|
+
// drifted across SDK versions.
|
|
305
|
+
const extractLastMessage = (jsonlTail) => {
|
|
306
|
+
if (!jsonlTail) return null;
|
|
307
|
+
const lines = jsonlTail.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
308
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
309
|
+
let obj;
|
|
310
|
+
try {
|
|
311
|
+
obj = JSON.parse(lines[i]);
|
|
312
|
+
} catch (_) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Claude Code session JSONL shapes ─────────────────────────────────
|
|
317
|
+
// Common:
|
|
318
|
+
// { type: 'user', message: { role: 'user', content: '...' } }
|
|
319
|
+
// { type: 'assistant', message: { role: 'assistant', content: [{type:'text', text:'...'}, ...] } }
|
|
320
|
+
if (obj && obj.message && (obj.message.role || obj.type)) {
|
|
321
|
+
const role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
|
|
322
|
+
let raw = obj.message.content;
|
|
323
|
+
let text = '';
|
|
324
|
+
if (typeof raw === 'string') text = raw;
|
|
325
|
+
else if (Array.isArray(raw)) {
|
|
326
|
+
text = raw
|
|
327
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
328
|
+
.map((p) => String(p.text || ''))
|
|
329
|
+
.join('\n');
|
|
330
|
+
}
|
|
331
|
+
text = text.trim();
|
|
332
|
+
if (text) return { author: role === 'user' ? 'user' : 'assistant', preview: text.slice(0, PREVIEW_MAX_CHARS) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Codex SDK rollout shapes ────────────────────────────────────────
|
|
336
|
+
// { record_type: 'message', role: 'assistant', content: [{type:'text', text:'...'}] }
|
|
337
|
+
// { type: 'message', role: 'user', content: '...' }
|
|
338
|
+
// { event: 'output_text', text: '...', role: 'assistant' }
|
|
339
|
+
if (obj && (obj.role || obj.event) && (obj.content || obj.text)) {
|
|
340
|
+
const role = obj.role || (obj.event === 'input_text' ? 'user' : 'assistant');
|
|
341
|
+
let raw = obj.content != null ? obj.content : obj.text;
|
|
342
|
+
let text = '';
|
|
343
|
+
if (typeof raw === 'string') text = raw;
|
|
344
|
+
else if (Array.isArray(raw)) {
|
|
345
|
+
text = raw
|
|
346
|
+
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
|
|
347
|
+
.map((p) => String(p.text || ''))
|
|
348
|
+
.join('\n');
|
|
349
|
+
}
|
|
350
|
+
text = text.trim();
|
|
351
|
+
if (text) return { author: role === 'user' ? 'user' : 'assistant', preview: text.slice(0, PREVIEW_MAX_CHARS) };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Decode Claude's project-dir filename encoding back to a path-like string.
|
|
358
|
+
// Claude turns `/Users/divyansh/foo` → `-Users-divyansh-foo`. We can't
|
|
359
|
+
// perfectly reverse it (project names with literal `-` are ambiguous), but
|
|
360
|
+
// for display purposes leading-dash → leading-slash + dashes → slashes is
|
|
361
|
+
// usually close enough. The BridgeExternal stores both the raw encoded
|
|
362
|
+
// projectDir AND a decoded label; the view route picks the friendlier one.
|
|
363
|
+
const decodeClaudeProjectDir = (encoded) => {
|
|
364
|
+
if (!encoded) return '';
|
|
365
|
+
if (encoded.startsWith('-')) {
|
|
366
|
+
return '/' + encoded.slice(1).replace(/-/g, '/');
|
|
367
|
+
}
|
|
368
|
+
return encoded.replace(/-/g, '/');
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// Discover all Claude Desktop "local-agent-mode" project roots.
|
|
372
|
+
// Structure is 3 levels deep before we hit `.claude/projects/`:
|
|
373
|
+
// <BASE>/<accountUuid>/<workspaceUuid>/local_<sessionUuid>/.claude/projects/
|
|
374
|
+
// We collect every leaf `.claude/projects` dir, then walk each like we walk
|
|
375
|
+
// the CLI's ~/.claude/projects.
|
|
376
|
+
const findClaudeDesktopProjectRoots = () => {
|
|
377
|
+
const out = [];
|
|
378
|
+
let l1;
|
|
379
|
+
try { l1 = fs.readdirSync(CLAUDE_DESKTOP_BASE); } catch (_) { return out; }
|
|
380
|
+
for (const a of l1) {
|
|
381
|
+
const aPath = path.join(CLAUDE_DESKTOP_BASE, a);
|
|
382
|
+
let aStat; try { aStat = fs.statSync(aPath); } catch (_) { continue; }
|
|
383
|
+
if (!aStat.isDirectory()) continue;
|
|
384
|
+
let l2; try { l2 = fs.readdirSync(aPath); } catch (_) { continue; }
|
|
385
|
+
for (const b of l2) {
|
|
386
|
+
const bPath = path.join(aPath, b);
|
|
387
|
+
let bStat; try { bStat = fs.statSync(bPath); } catch (_) { continue; }
|
|
388
|
+
if (!bStat.isDirectory()) continue;
|
|
389
|
+
let l3; try { l3 = fs.readdirSync(bPath); } catch (_) { continue; }
|
|
390
|
+
for (const c of l3) {
|
|
391
|
+
if (!c.startsWith('local_')) continue;
|
|
392
|
+
const projects = path.join(bPath, c, '.claude', 'projects');
|
|
393
|
+
try { if (fs.statSync(projects).isDirectory()) out.push(projects); } catch (_) {}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Walk a single Claude projects root (works for both ~/.claude/projects and
|
|
401
|
+
// each of the Desktop app's local-agent-mode project roots — file format is
|
|
402
|
+
// identical, only the path differs).
|
|
403
|
+
const walkClaudeProjectsRoot = (root) => {
|
|
404
|
+
const out = [];
|
|
405
|
+
let topEntries;
|
|
406
|
+
try { topEntries = fs.readdirSync(root); } catch (_) { return out; }
|
|
407
|
+
|
|
408
|
+
for (const entry of topEntries) {
|
|
409
|
+
const projectPath = path.join(root, entry);
|
|
410
|
+
let projectStat;
|
|
411
|
+
try { projectStat = fs.statSync(projectPath); } catch (_) { continue; }
|
|
412
|
+
if (!projectStat.isDirectory()) continue;
|
|
413
|
+
|
|
414
|
+
let sessionFiles;
|
|
415
|
+
try { sessionFiles = fs.readdirSync(projectPath); } catch (_) { continue; }
|
|
416
|
+
|
|
417
|
+
for (const file of sessionFiles) {
|
|
418
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
419
|
+
const sessionId = file.slice(0, -'.jsonl'.length);
|
|
420
|
+
const filePath = path.join(projectPath, file);
|
|
421
|
+
let stat;
|
|
422
|
+
try { stat = fs.statSync(filePath); } catch (_) { continue; }
|
|
423
|
+
const tail = readTail(filePath);
|
|
424
|
+
const lastMsg = extractLastMessage(tail);
|
|
425
|
+
let title = extractAiTitleFromTail(tail);
|
|
426
|
+
if (!title) title = extractTitleFromFile(filePath);
|
|
427
|
+
const recentMessages = extractRecentMessages(readTailUntilMessages(filePath));
|
|
428
|
+
out.push({
|
|
429
|
+
source: 'claude',
|
|
430
|
+
sessionId,
|
|
431
|
+
projectDir: entry,
|
|
432
|
+
projectName: decodeClaudeProjectDir(entry),
|
|
433
|
+
title: title,
|
|
434
|
+
recentMessages: recentMessages,
|
|
435
|
+
lastActivityAt: stat.mtimeMs,
|
|
436
|
+
lastMessagePreview: lastMsg ? lastMsg.preview : '',
|
|
437
|
+
lastMessageAuthor: lastMsg ? lastMsg.author : null,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Scan all Claude session storage on this machine — CLI + VSCode extension
|
|
445
|
+
// (~/.claude/projects) AND every Claude Desktop local-agent-mode subdir
|
|
446
|
+
// (~/Library/Application Support/Claude/local-agent-mode-sessions/.../).
|
|
447
|
+
const scanClaude = () => {
|
|
448
|
+
const roots = [CLAUDE_DIR].concat(findClaudeDesktopProjectRoots());
|
|
449
|
+
const out = [];
|
|
450
|
+
for (const root of roots) {
|
|
451
|
+
out.push(...walkClaudeProjectsRoot(root));
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Scan ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sessionId>.jsonl
|
|
457
|
+
// The first JSONL line for a Codex rollout is a session-meta record that
|
|
458
|
+
// contains the working directory; we read it once for projectDir.
|
|
459
|
+
const scanCodex = () => {
|
|
460
|
+
const out = [];
|
|
461
|
+
let years;
|
|
462
|
+
try { years = fs.readdirSync(CODEX_DIR); } catch (_) { return out; }
|
|
463
|
+
|
|
464
|
+
for (const y of years) {
|
|
465
|
+
if (!/^\d{4}$/.test(y)) continue;
|
|
466
|
+
const yPath = path.join(CODEX_DIR, y);
|
|
467
|
+
let months;
|
|
468
|
+
try { months = fs.readdirSync(yPath); } catch (_) { continue; }
|
|
469
|
+
for (const m of months) {
|
|
470
|
+
if (!/^\d{2}$/.test(m)) continue;
|
|
471
|
+
const mPath = path.join(yPath, m);
|
|
472
|
+
let days;
|
|
473
|
+
try { days = fs.readdirSync(mPath); } catch (_) { continue; }
|
|
474
|
+
for (const d of days) {
|
|
475
|
+
if (!/^\d{2}$/.test(d)) continue;
|
|
476
|
+
const dPath = path.join(mPath, d);
|
|
477
|
+
let files;
|
|
478
|
+
try { files = fs.readdirSync(dPath); } catch (_) { continue; }
|
|
479
|
+
for (const f of files) {
|
|
480
|
+
if (!f.startsWith('rollout-') || !f.endsWith('.jsonl')) continue;
|
|
481
|
+
// session id is the last hex/uuid block before .jsonl
|
|
482
|
+
const sidMatch = f.match(/-([0-9a-f-]{8,})\.jsonl$/i);
|
|
483
|
+
if (!sidMatch) continue;
|
|
484
|
+
const sessionId = sidMatch[1];
|
|
485
|
+
const filePath = path.join(dPath, f);
|
|
486
|
+
let stat;
|
|
487
|
+
try { stat = fs.statSync(filePath); } catch (_) { continue; }
|
|
488
|
+
|
|
489
|
+
// Read the first KB to pull the session-meta's working directory.
|
|
490
|
+
let projectDir = '';
|
|
491
|
+
let fd = null;
|
|
492
|
+
try {
|
|
493
|
+
fd = fs.openSync(filePath, 'r');
|
|
494
|
+
const headBuf = Buffer.alloc(Math.min(2048, stat.size));
|
|
495
|
+
fs.readSync(fd, headBuf, 0, headBuf.length, 0);
|
|
496
|
+
const firstLine = headBuf.toString('utf8').split(/\r?\n/)[0] || '';
|
|
497
|
+
try {
|
|
498
|
+
const meta = JSON.parse(firstLine);
|
|
499
|
+
projectDir = String(
|
|
500
|
+
meta?.cwd ||
|
|
501
|
+
meta?.workingDirectory ||
|
|
502
|
+
meta?.working_directory ||
|
|
503
|
+
meta?.session_meta?.cwd ||
|
|
504
|
+
meta?.payload?.cwd ||
|
|
505
|
+
''
|
|
506
|
+
);
|
|
507
|
+
} catch (_) { /* not a meta line — leave projectDir blank */ }
|
|
508
|
+
} catch (_) {} finally {
|
|
509
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const tail = readTail(filePath);
|
|
513
|
+
const lastMsg = extractLastMessage(tail);
|
|
514
|
+
const title = extractTitleFromFile(filePath);
|
|
515
|
+
// Recent messages get the streaming tail (handles huge per-message
|
|
516
|
+
// assistant turns that overflow the 16KB fixed budget).
|
|
517
|
+
const recentMessages = extractRecentMessages(readTailUntilMessages(filePath));
|
|
518
|
+
out.push({
|
|
519
|
+
source: 'codex',
|
|
520
|
+
sessionId,
|
|
521
|
+
projectDir: projectDir || `${y}/${m}/${d}`,
|
|
522
|
+
projectName: projectDir || null,
|
|
523
|
+
title: title,
|
|
524
|
+
recentMessages: recentMessages,
|
|
525
|
+
lastActivityAt: stat.mtimeMs,
|
|
526
|
+
lastMessagePreview: lastMsg ? lastMsg.preview : '',
|
|
527
|
+
lastMessageAuthor: lastMsg ? lastMsg.author : null,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// Public entry: returns a flat list of every external session found.
|
|
537
|
+
// Synchronous on purpose — the daemon calls this on a 30s timer and the
|
|
538
|
+
// total IO is dominated by readdir + a single readSync per file. Async
|
|
539
|
+
// would only complicate retry/cancel semantics with no real benefit.
|
|
540
|
+
const scanAll = () => {
|
|
541
|
+
const claude = scanClaude();
|
|
542
|
+
const codex = scanCodex();
|
|
543
|
+
return claude.concat(codex);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
module.exports = { scanAll, scanClaude, scanCodex, extractLastMessage, decodeClaudeProjectDir };
|