@obtoai/agent-bridge 0.1.0-beta.1 → 0.1.0-beta.11
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 +64 -36
- 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 +168 -22
- package/src/driver.js +53 -28
- package/src/external-scanner.js +244 -0
- package/src/opencode-driver.js +220 -0
- package/src/state.js +64 -8
package/src/daemon.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const { loadConfig } = require('./config');
|
|
4
4
|
const { startStream } = require('./stream-client');
|
|
5
|
-
const { loadState, saveState,
|
|
6
|
-
const { drive, tryResolvePermission,
|
|
7
|
-
const { postAgentActivity } = require('./bridge-http');
|
|
5
|
+
const { loadState, saveState, getAgentSession, setAgentSession } = require('./state');
|
|
6
|
+
const { drive, tryResolvePermission, agentFor } = require('./driver');
|
|
7
|
+
const { postAgentActivity, claimThread, postExternalSync } = require('./bridge-http');
|
|
8
|
+
const { detect: detectCapabilities } = require('./capabilities');
|
|
9
|
+
const { scanAll: scanExternalSessions } = require('./external-scanner');
|
|
8
10
|
|
|
9
11
|
const log = (level, msg, data) => {
|
|
10
12
|
const line = { ts: new Date().toISOString(), level, msg };
|
|
@@ -67,12 +69,82 @@ const handleEvent = async (sseEvent) => {
|
|
|
67
69
|
return;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
// Phase 2b — multi-daemon race check. The thread's target machine is
|
|
73
|
+
// included on the event when known (postReply publishes it). If it's set
|
|
74
|
+
// and isn't us, skip. If null, attempt the atomic first-touch claim — only
|
|
75
|
+
// the winning daemon handles the event; the rest skip cleanly.
|
|
76
|
+
const targetAgentId = payload.agentId ? String(payload.agentId).trim() : null;
|
|
77
|
+
if (targetAgentId && targetAgentId !== cfg.agentId) {
|
|
78
|
+
log('event', 'skip — claimed by other daemon', {
|
|
79
|
+
threadId,
|
|
80
|
+
targetAgentId,
|
|
81
|
+
messageId: payload.messageId,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!targetAgentId) {
|
|
86
|
+
try {
|
|
87
|
+
const r = await claimThread(threadId, cfg.agentId);
|
|
88
|
+
if (!r || !r.ok || !r.data || !r.data.won) {
|
|
89
|
+
log('info', 'claim lost or failed', {
|
|
90
|
+
threadId,
|
|
91
|
+
winner: r && r.data && r.data.winner,
|
|
92
|
+
status: r && r.status,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
log('info', 'claim won', { threadId, agentId: cfg.agentId });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
log('error', 'claim threw', { threadId, error: e && e.message });
|
|
99
|
+
return; // conservative — skip on uncertainty rather than double-drive
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// v1.1 — which agent this thread is bound to (server-set, on the event).
|
|
104
|
+
const agent = agentFor(payload);
|
|
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
|
+
|
|
71
142
|
log('event', 'reply received', {
|
|
72
143
|
threadId,
|
|
144
|
+
agent,
|
|
73
145
|
author: payload.author,
|
|
74
146
|
messageId: payload.messageId,
|
|
75
|
-
|
|
147
|
+
hasSession: !!session,
|
|
76
148
|
});
|
|
77
149
|
|
|
78
150
|
// Permission-relay replies: resolve the pending request inside the driver and
|
|
@@ -85,10 +157,13 @@ const handleEvent = async (sseEvent) => {
|
|
|
85
157
|
// indicator drops whether the turn succeeds, skips, or throws.
|
|
86
158
|
emitActivity(threadId, 'working');
|
|
87
159
|
try {
|
|
160
|
+
// The driver receives a flat per-agent session as `binding` — the v1
|
|
161
|
+
// driver contract is unchanged. v1.1 just keeps one such session per
|
|
162
|
+
// agent on the thread, so a claude<->codex switch resumes each side.
|
|
88
163
|
const result = await drive({
|
|
89
164
|
threadId,
|
|
90
165
|
projectDir: cfg.projectDir,
|
|
91
|
-
binding,
|
|
166
|
+
binding: session,
|
|
92
167
|
payload,
|
|
93
168
|
log,
|
|
94
169
|
});
|
|
@@ -96,22 +171,27 @@ const handleEvent = async (sseEvent) => {
|
|
|
96
171
|
if (
|
|
97
172
|
result &&
|
|
98
173
|
result.sessionId &&
|
|
99
|
-
(!
|
|
174
|
+
(!session || session.sessionId !== result.sessionId || wasAdoption)
|
|
100
175
|
) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
176
|
+
setAgentSession(
|
|
177
|
+
state,
|
|
178
|
+
threadId,
|
|
179
|
+
agent,
|
|
180
|
+
{
|
|
181
|
+
sessionId: result.sessionId,
|
|
182
|
+
projectDir: result.projectDir,
|
|
183
|
+
jsonlPath: result.jsonlPath,
|
|
184
|
+
lastJsonlMtimeMs: result.lastJsonlMtimeMs || null,
|
|
185
|
+
createdAt: new Date().toISOString(),
|
|
186
|
+
lastDriveAt: new Date().toISOString(),
|
|
187
|
+
},
|
|
188
|
+
{ agentId: cfg.agentId },
|
|
189
|
+
);
|
|
190
|
+
log('info', 'session bound', { threadId, agent, sessionId: result.sessionId });
|
|
191
|
+
} else if (session && !result.skipped) {
|
|
192
|
+
session.lastDriveAt = new Date().toISOString();
|
|
113
193
|
if (result && result.lastJsonlMtimeMs) {
|
|
114
|
-
|
|
194
|
+
session.lastJsonlMtimeMs = result.lastJsonlMtimeMs;
|
|
115
195
|
}
|
|
116
196
|
saveState(state);
|
|
117
197
|
}
|
|
@@ -126,16 +206,23 @@ const handleEvent = async (sseEvent) => {
|
|
|
126
206
|
};
|
|
127
207
|
|
|
128
208
|
const start = () => {
|
|
209
|
+
// Phase 2b — advertise capabilities to the bridge on connect so the UI
|
|
210
|
+
// picker can offer just the agents that are actually installable here.
|
|
211
|
+
const capabilities = detectCapabilities();
|
|
212
|
+
|
|
129
213
|
log('info', 'starting daemon', {
|
|
130
214
|
baseUrl: cfg.baseUrl,
|
|
131
215
|
accountId: cfg.accountId,
|
|
132
216
|
agentId: cfg.agentId,
|
|
133
|
-
|
|
217
|
+
capabilities,
|
|
134
218
|
projectDir: cfg.projectDir,
|
|
135
219
|
boundThreads: Object.keys(state.bindings || {}),
|
|
136
220
|
});
|
|
137
221
|
|
|
138
|
-
const url = cfg.baseUrl.replace(/\/$/, '') +
|
|
222
|
+
const url = cfg.baseUrl.replace(/\/$/, '') +
|
|
223
|
+
'/api/bridge/stream' +
|
|
224
|
+
'?agentId=' + encodeURIComponent(cfg.agentId) +
|
|
225
|
+
'&capabilities=' + encodeURIComponent(capabilities.join(','));
|
|
139
226
|
stream = startStream({
|
|
140
227
|
url,
|
|
141
228
|
// Re-read config on every (re)connect so a rotated token (via
|
|
@@ -155,6 +242,64 @@ const start = () => {
|
|
|
155
242
|
},
|
|
156
243
|
log,
|
|
157
244
|
});
|
|
245
|
+
|
|
246
|
+
// Phase 6.1 — kick off the External Thread Discovery scanner. 10s startup
|
|
247
|
+
// delay (lets the SSE handshake settle first), then 30s ticks. Was defined
|
|
248
|
+
// but not invoked in beta.9 — fixed in beta.10.
|
|
249
|
+
startExternalSync();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Phase 6.1 — External Thread Discovery. Every 30s, scan ~/.claude/projects
|
|
253
|
+
// and ~/.codex/sessions for sessions that didn't originate from the bridge
|
|
254
|
+
// and POST them to /api/bridge/external/sync. Fire-and-forget; failures log
|
|
255
|
+
// and the next tick retries. The bridge dedups by (accountId, sessionId).
|
|
256
|
+
const EXTERNAL_SCAN_INTERVAL_MS = 30000;
|
|
257
|
+
let externalScanTimer = null;
|
|
258
|
+
const ownedSessionIdsFromState = () => {
|
|
259
|
+
const ids = new Set();
|
|
260
|
+
const bindings = (state && state.bindings) || {};
|
|
261
|
+
for (const tid of Object.keys(bindings)) {
|
|
262
|
+
const b = bindings[tid] || {};
|
|
263
|
+
const sessions = b.sessions && typeof b.sessions === 'object'
|
|
264
|
+
? b.sessions
|
|
265
|
+
: (b.sessionId ? { _flat: b } : {});
|
|
266
|
+
for (const k of Object.keys(sessions)) {
|
|
267
|
+
const s = sessions[k] || {};
|
|
268
|
+
if (s.sessionId) ids.add(String(s.sessionId));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return ids;
|
|
272
|
+
};
|
|
273
|
+
const externalScanTick = async () => {
|
|
274
|
+
try {
|
|
275
|
+
const all = scanExternalSessions();
|
|
276
|
+
const owned = ownedSessionIdsFromState();
|
|
277
|
+
const external = all.filter((s) => s && s.sessionId && !owned.has(String(s.sessionId)));
|
|
278
|
+
if (external.length === 0) return;
|
|
279
|
+
const r = await postExternalSync(cfg.agentId, external);
|
|
280
|
+
if (!r || !r.ok) {
|
|
281
|
+
log('warn', 'external sync rejected', {
|
|
282
|
+
status: r && r.status,
|
|
283
|
+
body: r && r.data,
|
|
284
|
+
count: external.length,
|
|
285
|
+
});
|
|
286
|
+
} else {
|
|
287
|
+
log('debug', 'external sync ok', {
|
|
288
|
+
sent: external.length,
|
|
289
|
+
upserted: (r.data && r.data.count) || 0,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} catch (e) {
|
|
293
|
+
log('warn', 'external scan failed', { error: e && e.message ? e.message : String(e) });
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const startExternalSync = () => {
|
|
297
|
+
// Wait 10s after daemon start before the first scan so the SSE connection
|
|
298
|
+
// is established first. Reduces "cold start everything at once" noise.
|
|
299
|
+
setTimeout(() => {
|
|
300
|
+
externalScanTick();
|
|
301
|
+
externalScanTimer = setInterval(externalScanTick, EXTERNAL_SCAN_INTERVAL_MS);
|
|
302
|
+
}, 10000);
|
|
158
303
|
};
|
|
159
304
|
|
|
160
305
|
const shutdown = (signal) => {
|
|
@@ -162,6 +307,7 @@ const shutdown = (signal) => {
|
|
|
162
307
|
stopped = true;
|
|
163
308
|
log('info', 'shutting down', { signal });
|
|
164
309
|
try { stream && stream.stop(); } catch (_) {}
|
|
310
|
+
if (externalScanTimer) { try { clearInterval(externalScanTimer); } catch (_) {} }
|
|
165
311
|
setTimeout(() => process.exit(0), 200);
|
|
166
312
|
};
|
|
167
313
|
|
package/src/driver.js
CHANGED
|
@@ -1,47 +1,72 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// operator configured — Claude (via the Claude Agent SDK) or Codex (via the
|
|
5
|
-
// Codex SDK) — chosen by `agent` in config.json / the BRIDGE_AGENT env var.
|
|
6
|
-
// Everything else in the daemon (SSE, state, the bridge HTTP client) is
|
|
7
|
-
// agent-neutral; only the driver differs.
|
|
3
|
+
// Dual-driver dispatch (v1.1).
|
|
8
4
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
5
|
+
// v1 resolved ONE agent at startup and drove only that. v1.1 makes the daemon
|
|
6
|
+
// agent-agnostic per event: it can drive BOTH Claude (Claude Agent SDK) and
|
|
7
|
+
// Codex (Codex SDK), and routes each bridge event to the right driver by the
|
|
8
|
+
// thread's `agent` field (`payload.agent`, set server-side from the thread's
|
|
9
|
+
// routing record).
|
|
10
|
+
//
|
|
11
|
+
// Drivers are require()d lazily and cached — a machine that only ever runs
|
|
12
|
+
// Claude threads never pays to load @openai/codex-sdk, and vice versa.
|
|
11
13
|
|
|
12
14
|
const { loadConfig } = require('./config');
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
const KNOWN_AGENTS = ['claude', 'codex', 'opencode'];
|
|
17
|
+
|
|
18
|
+
const cache = {};
|
|
15
19
|
|
|
16
|
-
const
|
|
17
|
-
if (
|
|
18
|
-
let
|
|
20
|
+
const loadDriver = (name) => {
|
|
21
|
+
if (cache[name]) return cache[name];
|
|
22
|
+
let mod;
|
|
23
|
+
if (name === 'codex') mod = require('./codex-driver');
|
|
24
|
+
else if (name === 'opencode') mod = require('./opencode-driver');
|
|
25
|
+
else mod = require('./claude-driver');
|
|
26
|
+
cache[name] = mod;
|
|
27
|
+
return mod;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Fallback agent for events that arrive without an explicit `agent` — an
|
|
31
|
+
// older bridge, or a thread created before v1.1. Reads config.agent (the v1
|
|
32
|
+
// init choice), else 'claude'.
|
|
33
|
+
let fallbackAgent = null;
|
|
34
|
+
const getFallbackAgent = () => {
|
|
35
|
+
if (fallbackAgent) return fallbackAgent;
|
|
36
|
+
let a = 'claude';
|
|
19
37
|
try {
|
|
20
|
-
|
|
38
|
+
a = (loadConfig().agent || 'claude').toLowerCase();
|
|
21
39
|
} catch (_) {
|
|
22
40
|
// config unreadable — default to claude
|
|
23
41
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} else {
|
|
27
|
-
resolved = { name: 'claude', mod: require('./claude-driver') };
|
|
28
|
-
}
|
|
29
|
-
return resolved;
|
|
42
|
+
fallbackAgent = KNOWN_AGENTS.indexOf(a) !== -1 ? a : 'claude';
|
|
43
|
+
return fallbackAgent;
|
|
30
44
|
};
|
|
31
45
|
|
|
32
|
-
|
|
46
|
+
// Resolve which agent a bridge event targets.
|
|
47
|
+
const agentFor = (payload) => {
|
|
48
|
+
const a = payload && payload.agent ? String(payload.agent).toLowerCase() : '';
|
|
49
|
+
if (KNOWN_AGENTS.indexOf(a) !== -1) return a;
|
|
50
|
+
return getFallbackAgent();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Drive one bridge event with the agent its thread is bound to.
|
|
54
|
+
const drive = (params) => {
|
|
55
|
+
const name = agentFor(params && params.payload);
|
|
56
|
+
return loadDriver(name).drive(params);
|
|
57
|
+
};
|
|
33
58
|
|
|
34
|
-
// Permission relay is Claude-only — Codex exposes no per-tool callback.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
59
|
+
// Permission relay is Claude-only — the Codex SDK exposes no per-tool callback.
|
|
60
|
+
// We delegate to the claude driver's resolver regardless of the thread's agent:
|
|
61
|
+
// it keys on threadId against its own pending-request map, so a codex thread
|
|
62
|
+
// (which never has a pending claude request) simply returns false and the
|
|
63
|
+
// reply falls through to drive().
|
|
37
64
|
const tryResolvePermission = (threadId, body, log) => {
|
|
38
|
-
const
|
|
39
|
-
if (typeof
|
|
40
|
-
return
|
|
65
|
+
const claude = loadDriver('claude');
|
|
66
|
+
if (typeof claude.tryResolvePermission === 'function') {
|
|
67
|
+
return claude.tryResolvePermission(threadId, body, log);
|
|
41
68
|
}
|
|
42
69
|
return false;
|
|
43
70
|
};
|
|
44
71
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
module.exports = { drive, tryResolvePermission, activeAgent };
|
|
72
|
+
module.exports = { drive, tryResolvePermission, agentFor, KNOWN_AGENTS };
|
|
@@ -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 };
|