agentel 0.2.8 → 0.3.0

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.
@@ -0,0 +1,405 @@
1
+ "use strict";
2
+
3
+ // SQLite-backed session metadata index.
4
+ //
5
+ // Why this exists:
6
+ // listSessions used to walk every *.metadata.json under the archive on each
7
+ // read (~85ms warm for 3,900 sessions on the dev archive). A JSON
8
+ // session-list index later replaced the walk, but parsing the 22MB file
9
+ // still cost ~125ms on every cold list-endpoint hit. SQLite gives us
10
+ // indexed ORDER BY / WHERE / GROUP BY with sub-millisecond reads and
11
+ // incremental upserts on import.
12
+ //
13
+ // Source of truth: still the *.metadata.json files on disk. This DB is a
14
+ // derived cache, rebuildable any time. If it goes missing/corrupt the
15
+ // archive.js fallback rebuilds it by walking the filesystem.
16
+ //
17
+ // Schema is a hybrid: hot columns are extracted so SQL can index/sort/group;
18
+ // everything else is stored as an opaque JSON blob in session_json. This
19
+ // avoids maintaining 30+ ALTER-able columns as session metadata evolves.
20
+
21
+ const fs = require("fs");
22
+ const path = require("path");
23
+ const { ensureDir } = require("./paths");
24
+
25
+ const SESSION_STORE_VERSION = 2;
26
+
27
+ let _Database = null;
28
+ let _driverLoaded = false;
29
+ let _driverLoadError = null;
30
+ let _driverBroken = false;
31
+
32
+ const _connState = {
33
+ path: "",
34
+ ino: 0,
35
+ db: null,
36
+ prepared: null,
37
+ broken: false
38
+ };
39
+
40
+ function loadDriver() {
41
+ if (_driverLoaded) return _driverBroken ? null : _Database;
42
+ _driverLoaded = true;
43
+ try {
44
+ _Database = require("better-sqlite3");
45
+ } catch (error) {
46
+ _Database = null;
47
+ _driverLoadError = error;
48
+ }
49
+ return _Database;
50
+ }
51
+
52
+ function markDriverBroken() {
53
+ _driverBroken = true;
54
+ _Database = null;
55
+ closeConnection();
56
+ }
57
+
58
+ function closeConnection() {
59
+ if (_connState.db) {
60
+ try { _connState.db.close(); } catch { /* ignore */ }
61
+ }
62
+ _connState.db = null;
63
+ _connState.prepared = null;
64
+ _connState.path = "";
65
+ _connState.ino = 0;
66
+ _connState.broken = false;
67
+ }
68
+
69
+ /**
70
+ * Path to the sessions SQLite file inside an archive root. Kept as its own
71
+ * file so an FTS rebuild (different version, different cadence) does not
72
+ * disturb the session index.
73
+ */
74
+ function sessionStorePath(archiveRoot) {
75
+ return path.join(archiveRoot, "indexes", "sessions", "sessions.sqlite");
76
+ }
77
+
78
+ const SCHEMA_SQL = [
79
+ "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
80
+ "CREATE TABLE IF NOT EXISTS sessions (",
81
+ " session_id TEXT PRIMARY KEY,",
82
+ " metadata_path TEXT NOT NULL,",
83
+ " mtime_ms INTEGER NOT NULL,",
84
+ " size INTEGER NOT NULL,",
85
+ " started_at TEXT,",
86
+ " ended_at TEXT,",
87
+ " provider TEXT,",
88
+ " source_type TEXT,",
89
+ " repo_canonical TEXT,",
90
+ " scope_canonical TEXT,",
91
+ " storage_scope TEXT,",
92
+ " conversation_kind TEXT,",
93
+ " is_subagent INTEGER NOT NULL DEFAULT 0,",
94
+ // source_path is the original importer-side path the session came from
95
+ // (e.g. a Cursor agent-transcripts folder). Used by the Cursor/Devin
96
+ // multi-session-store provider flow to supersede prior snapshots written
97
+ // under the same source.
98
+ " source_path TEXT,",
99
+ " session_json TEXT NOT NULL",
100
+ ");",
101
+ "CREATE INDEX IF NOT EXISTS idx_sessions_started_at_desc ON sessions(started_at DESC);",
102
+ "CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider);",
103
+ "CREATE INDEX IF NOT EXISTS idx_sessions_repo_canonical ON sessions(repo_canonical);",
104
+ "CREATE INDEX IF NOT EXISTS idx_sessions_is_subagent ON sessions(is_subagent);",
105
+ "CREATE INDEX IF NOT EXISTS idx_sessions_metadata_path ON sessions(metadata_path);",
106
+ "CREATE INDEX IF NOT EXISTS idx_sessions_provider_source_path ON sessions(provider, source_path);"
107
+ ].join("\n");
108
+
109
+ /**
110
+ * Open or reuse a connection. Returns null if the driver is unavailable
111
+ * (the caller then falls back to the JSON / filesystem path). A wedged
112
+ * driver (ABI mismatch surfacing at first `new Database()`) latches so we
113
+ * do not re-attempt on every call.
114
+ */
115
+ function openConnection(archiveRoot) {
116
+ if (_driverBroken) return null;
117
+ const Driver = loadDriver();
118
+ if (!Driver) return null;
119
+ const filePath = sessionStorePath(archiveRoot);
120
+ let stat = null;
121
+ try { stat = fs.statSync(filePath); } catch { stat = null; }
122
+ if (_connState.db && _connState.path === filePath && stat && _connState.ino === stat.ino) {
123
+ return _connState;
124
+ }
125
+ closeConnection();
126
+ ensureDir(path.dirname(filePath));
127
+ let db;
128
+ try {
129
+ db = new Driver(filePath);
130
+ } catch (error) {
131
+ if (isAbiMismatch(error)) {
132
+ // Wedged native binding — latch so we don't retry this process. The
133
+ // archive.js fallback path takes over.
134
+ markDriverBroken();
135
+ return null;
136
+ }
137
+ // Any other failure (truncated file, partial write from an OS crash,
138
+ // unreadable bytes, schema mismatch SQLite can't parse) means the file
139
+ // on disk is unusable. Since this DB is a derived cache rebuildable
140
+ // from *.metadata.json files, unlink so the next caller gets a fresh
141
+ // db instead of pinning the broken state for the rest of the process.
142
+ try { fs.unlinkSync(filePath); } catch { /* ignore */ }
143
+ try { fs.unlinkSync(`${filePath}-wal`); } catch { /* ignore */ }
144
+ try { fs.unlinkSync(`${filePath}-shm`); } catch { /* ignore */ }
145
+ return null;
146
+ }
147
+ try {
148
+ db.pragma("journal_mode = WAL");
149
+ db.pragma("synchronous = NORMAL");
150
+ db.pragma("temp_store = MEMORY");
151
+ // Derived cache: bumping SESSION_STORE_VERSION wipes the sessions table
152
+ // so we can change the schema without writing ALTERs. The on-disk
153
+ // metadata.json files are the source of truth; the next list call
154
+ // repopulates from a filesystem walk.
155
+ let storedVersion = "";
156
+ try {
157
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
158
+ storedVersion = String(row?.value || "");
159
+ } catch {
160
+ // meta table may not exist on a fresh db — fine.
161
+ }
162
+ if (storedVersion && storedVersion !== String(SESSION_STORE_VERSION)) {
163
+ try { db.exec("DROP TABLE IF EXISTS sessions"); } catch { /* ignore */ }
164
+ }
165
+ db.exec(SCHEMA_SQL);
166
+ upsertMeta(db, "version", String(SESSION_STORE_VERSION));
167
+ upsertMeta(db, "archive_root", archiveRoot);
168
+ } catch (error) {
169
+ // Schema setup failed — the file is likely partially populated or has
170
+ // an unreadable on-disk schema header. Unlink so the next caller gets
171
+ // a clean DB instead of pinning this broken state for the process.
172
+ try { db.close(); } catch { /* ignore */ }
173
+ try { fs.unlinkSync(filePath); } catch { /* ignore */ }
174
+ try { fs.unlinkSync(`${filePath}-wal`); } catch { /* ignore */ }
175
+ try { fs.unlinkSync(`${filePath}-shm`); } catch { /* ignore */ }
176
+ return null;
177
+ }
178
+ let nextStat = null;
179
+ try { nextStat = fs.statSync(filePath); } catch { nextStat = null; }
180
+ _connState.path = filePath;
181
+ _connState.ino = nextStat?.ino || 0;
182
+ _connState.db = db;
183
+ _connState.prepared = new Map();
184
+ _connState.broken = false;
185
+ return _connState;
186
+ }
187
+
188
+ function isAbiMismatch(error) {
189
+ const message = String(error?.message || "");
190
+ return /NODE_MODULE_VERSION|was compiled against a different Node\.js version/i.test(message);
191
+ }
192
+
193
+ function prepareCached(state, sql) {
194
+ let stmt = state.prepared.get(sql);
195
+ if (!stmt) {
196
+ stmt = state.db.prepare(sql);
197
+ state.prepared.set(sql, stmt);
198
+ }
199
+ return stmt;
200
+ }
201
+
202
+ function upsertMeta(db, key, value) {
203
+ db.prepare("INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
204
+ }
205
+
206
+ function entryToRowParams(entry) {
207
+ const session = entry.session || {};
208
+ const isSubagent = /_subagent$/.test(String(session.conversationKind || "")) ? 1 : 0;
209
+ return {
210
+ session_id: session.sessionId || "",
211
+ metadata_path: entry.metadataPath || session.metadataPath || "",
212
+ mtime_ms: Math.trunc(Number(entry.mtimeMs) || 0),
213
+ size: Math.trunc(Number(entry.size) || 0),
214
+ started_at: session.startedAt || "",
215
+ ended_at: session.endedAt || "",
216
+ provider: session.provider || "",
217
+ source_type: session.sourceType || "",
218
+ repo_canonical: session.repoCanonical || "",
219
+ scope_canonical: session.scopeCanonical || "",
220
+ storage_scope: session.storageScope || "",
221
+ conversation_kind: session.conversationKind || "",
222
+ is_subagent: isSubagent,
223
+ source_path: session.sourcePath || "",
224
+ session_json: JSON.stringify(session)
225
+ };
226
+ }
227
+
228
+ function rowToEntry(row) {
229
+ if (!row || !row.session_json) return null;
230
+ let session = null;
231
+ try { session = JSON.parse(row.session_json); } catch { return null; }
232
+ if (!session || typeof session !== "object") return null;
233
+ return {
234
+ metadataPath: row.metadata_path || "",
235
+ mtimeMs: Number(row.mtime_ms) || 0,
236
+ size: Number(row.size) || 0,
237
+ session
238
+ };
239
+ }
240
+
241
+ const ROW_INSERT_SQL =
242
+ "INSERT INTO sessions(" +
243
+ " session_id, metadata_path, mtime_ms, size, started_at, ended_at, provider, source_type, " +
244
+ " repo_canonical, scope_canonical, storage_scope, conversation_kind, is_subagent, source_path, session_json" +
245
+ ") VALUES(" +
246
+ " @session_id, @metadata_path, @mtime_ms, @size, @started_at, @ended_at, @provider, @source_type, " +
247
+ " @repo_canonical, @scope_canonical, @storage_scope, @conversation_kind, @is_subagent, @source_path, @session_json" +
248
+ ") ON CONFLICT(session_id) DO UPDATE SET " +
249
+ " metadata_path = excluded.metadata_path, mtime_ms = excluded.mtime_ms, size = excluded.size, " +
250
+ " started_at = excluded.started_at, ended_at = excluded.ended_at, provider = excluded.provider, " +
251
+ " source_type = excluded.source_type, repo_canonical = excluded.repo_canonical, " +
252
+ " scope_canonical = excluded.scope_canonical, storage_scope = excluded.storage_scope, " +
253
+ " conversation_kind = excluded.conversation_kind, is_subagent = excluded.is_subagent, " +
254
+ " source_path = excluded.source_path, session_json = excluded.session_json";
255
+
256
+ const ROW_SELECT_ALL_SQL =
257
+ "SELECT session_id, metadata_path, mtime_ms, size, session_json FROM sessions";
258
+
259
+ const ROW_DELETE_SQL = "DELETE FROM sessions WHERE session_id = ?";
260
+ const ROW_DELETE_BY_PATH_SQL = "DELETE FROM sessions WHERE metadata_path = ?";
261
+ // Used by the Cursor/Devin provider flow to evict prior snapshots written
262
+ // for the same (provider, source_path) before upserting the fresh one.
263
+ const ROW_DELETE_BY_SOURCE_PATH_SQL =
264
+ "DELETE FROM sessions WHERE provider = ? AND source_path = ? AND source_path != '' AND session_id != ?";
265
+
266
+ /**
267
+ * Bulk-replace the entire sessions table from a rebuilt entry list. Used by
268
+ * the filesystem-walk fallback after it scans the archive. Done in a single
269
+ * transaction so concurrent readers either see the old or new set.
270
+ */
271
+ function replaceAllSessions(entries, archiveRoot) {
272
+ const state = openConnection(archiveRoot);
273
+ if (!state) return false;
274
+ const insertStmt = prepareCached(state, ROW_INSERT_SQL);
275
+ try {
276
+ const replace = state.db.transaction(function (rows) {
277
+ state.db.exec("DELETE FROM sessions");
278
+ // Track actually-inserted rows rather than rows.length so the count
279
+ // meta reflects what's queryable in the sessions table. Entries that
280
+ // lack session_id or metadata_path are skipped above and must not
281
+ // count toward the stored total.
282
+ let inserted = 0;
283
+ for (const entry of rows) {
284
+ if (!entry || !entry.session) continue;
285
+ const params = entryToRowParams(entry);
286
+ if (!params.session_id || !params.metadata_path) continue;
287
+ insertStmt.run(params);
288
+ inserted += 1;
289
+ }
290
+ upsertMeta(state.db, "count", String(inserted));
291
+ upsertMeta(state.db, "updated_at", new Date().toISOString());
292
+ });
293
+ replace(entries);
294
+ return true;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Single-row upsert used from writeSession after a session is committed to
302
+ * disk. Cheap path: one INSERT ... ON CONFLICT, no walk.
303
+ */
304
+ function upsertSession(entry, archiveRoot) {
305
+ const state = openConnection(archiveRoot);
306
+ if (!state) return false;
307
+ if (!entry || !entry.session) return false;
308
+ const params = entryToRowParams(entry);
309
+ if (!params.session_id || !params.metadata_path) return false;
310
+ try {
311
+ prepareCached(state, ROW_INSERT_SQL).run(params);
312
+ return true;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Delete by session id or by metadata path. Either is sufficient since both
320
+ * are indexed; both are accepted because callers may know only one.
321
+ */
322
+ function deleteSession({ sessionId, metadataPath }, archiveRoot) {
323
+ const state = openConnection(archiveRoot);
324
+ if (!state) return false;
325
+ try {
326
+ if (sessionId) prepareCached(state, ROW_DELETE_SQL).run(sessionId);
327
+ if (metadataPath) prepareCached(state, ROW_DELETE_BY_PATH_SQL).run(metadataPath);
328
+ return true;
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Cursor/Devin replace-old-snapshot path: evict every prior session row that
336
+ * shares (provider, source_path) with the incoming session, except the
337
+ * incoming session itself. Returns the count of deleted rows for observability.
338
+ */
339
+ function deleteSameSourceSessions({ provider, sourcePath, keepSessionId }, archiveRoot) {
340
+ const state = openConnection(archiveRoot);
341
+ if (!state) return 0;
342
+ if (!provider || !sourcePath) return 0;
343
+ try {
344
+ const result = prepareCached(state, ROW_DELETE_BY_SOURCE_PATH_SQL).run(provider, sourcePath, keepSessionId || "");
345
+ return Number(result?.changes || 0);
346
+ } catch {
347
+ return 0;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Read all session entries from the store. Returns null when the store is
353
+ * unavailable or empty so the caller can fall back to the JSON file / a
354
+ * filesystem walk and then populate the store.
355
+ */
356
+ function readAllSessions(archiveRoot) {
357
+ const state = openConnection(archiveRoot);
358
+ if (!state) return null;
359
+ let rows;
360
+ try {
361
+ rows = prepareCached(state, ROW_SELECT_ALL_SQL).all();
362
+ } catch {
363
+ return null;
364
+ }
365
+ if (!rows.length) return null;
366
+ const entries = rows.map(rowToEntry).filter(Boolean);
367
+ return entries.length ? entries : null;
368
+ }
369
+
370
+ function countSessions(archiveRoot) {
371
+ const state = openConnection(archiveRoot);
372
+ if (!state) return null;
373
+ try {
374
+ const row = prepareCached(state, "SELECT COUNT(*) AS c FROM sessions").get();
375
+ return row?.c || 0;
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ function storeAvailable(archiveRoot) {
382
+ return Boolean(openConnection(archiveRoot));
383
+ }
384
+
385
+ function resetStateForTests() {
386
+ closeConnection();
387
+ _driverBroken = false;
388
+ _driverLoaded = false;
389
+ _Database = null;
390
+ _driverLoadError = null;
391
+ }
392
+
393
+ module.exports = {
394
+ SESSION_STORE_VERSION,
395
+ sessionStorePath,
396
+ replaceAllSessions,
397
+ upsertSession,
398
+ deleteSession,
399
+ deleteSameSourceSessions,
400
+ readAllSessions,
401
+ countSessions,
402
+ storeAvailable,
403
+ closeConnection,
404
+ resetStateForTests
405
+ };
@@ -0,0 +1,293 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ // Filesystem events are triggers, not data: an event only marks its source
8
+ // dirty, so watcher memory stays O(sources) no matter how fast agents write.
9
+ // The first event for a source opens a fixed-delay coalesce window and later
10
+ // events fold into it, so a continuously writing session cannot starve its
11
+ // own import (a trailing-edge debounce would).
12
+ const DEFAULT_COALESCE_MS = 3 * 1000;
13
+ // SQLite-backed stores churn their WAL while the app is open, including for
14
+ // state unrelated to conversations, so they get a wider window.
15
+ const SQLITE_COALESCE_MS = 20 * 1000;
16
+ // Roots that don't exist yet (tool not installed) or whose watcher died are
17
+ // re-checked on this cadence.
18
+ const WATCH_RETRY_MS = 5 * 60 * 1000;
19
+
20
+ function homeDir(env = process.env) {
21
+ return env.HOME || env.USERPROFILE || os.homedir();
22
+ }
23
+
24
+ function geminiHome(env, home) {
25
+ return env.AGENTLOG_GEMINI_HOME_DIR || env.GEMINI_HOME || path.join(home, ".gemini");
26
+ }
27
+
28
+ function claudeAppSupportRoot(env, home) {
29
+ return env.CLAUDE_APP_SUPPORT || path.join(home, "Library", "Application Support", "Claude");
30
+ }
31
+
32
+ function root(dir, options = {}) {
33
+ return {
34
+ dir: path.resolve(dir),
35
+ recursive: options.recursive !== false,
36
+ filter: options.filter || null,
37
+ coalesceMs: options.coalesceMs || DEFAULT_COALESCE_MS
38
+ };
39
+ }
40
+
41
+ function sqliteStateDbFilter(name) {
42
+ return path.basename(name).startsWith("state.vscdb");
43
+ }
44
+
45
+ // Watch roots per import source, mirroring the importer path resolution in
46
+ // src/importers.js (including its env overrides) without loading that module:
47
+ // the supervisor process should stay small, and a root that drifts slightly
48
+ // from what the importer reads only costs a no-op import or falls back to the
49
+ // heartbeat poll. Sources with no entry (aider scans whole project trees;
50
+ // watching those would fire on every git operation in every repo) stay on the
51
+ // polling cadence.
52
+ function watchRootsForSource(source, env = process.env) {
53
+ const home = homeDir(env);
54
+ switch (source) {
55
+ case "codex-cli":
56
+ case "codex-desktop":
57
+ case "codex-sdk": {
58
+ const codexHome = env.CODEX_HOME || path.join(home, ".codex");
59
+ return [root(path.join(codexHome, "sessions")), root(path.join(codexHome, "archived_sessions"))];
60
+ }
61
+ case "claude":
62
+ return [root(path.join(home, ".claude", "projects"))];
63
+ case "claude-code-desktop":
64
+ return [root(path.join(claudeAppSupportRoot(env, home), "claude-code-sessions"))];
65
+ case "claude-cowork":
66
+ return [root(path.join(claudeAppSupportRoot(env, home), "local-agent-mode-sessions"))];
67
+ case "gemini-cli":
68
+ // Antigravity homes live inside the Gemini home; exclude them so an
69
+ // Antigravity session doesn't ping the Gemini importer.
70
+ return [root(geminiHome(env, home), { filter: (name) => !name.startsWith("antigravity") })];
71
+ case "antigravity":
72
+ return [root(env.AGENTLOG_ANTIGRAVITY_HOME_DIR || path.join(geminiHome(env, home), "antigravity"))];
73
+ case "antigravity-cli":
74
+ return [root(env.AGENTLOG_ANTIGRAVITY_CLI_HOME_DIR || path.join(geminiHome(env, home), "antigravity-cli"))];
75
+ case "antigravity-ide":
76
+ return [root(env.AGENTLOG_ANTIGRAVITY_IDE_HOME_DIR || path.join(geminiHome(env, home), "antigravity-ide"))];
77
+ case "devin-cli": {
78
+ const db = env.AGENTLOG_DEVIN_SESSIONS_DB || path.join(home, ".local", "share", "devin", "cli", "sessions.db");
79
+ return [root(path.dirname(db), { recursive: false, coalesceMs: SQLITE_COALESCE_MS })];
80
+ }
81
+ case "devin-desktop": {
82
+ const appRoots = [
83
+ path.join(home, "Library", "Application Support", "Devin"),
84
+ path.join(home, ".config", "Devin")
85
+ ];
86
+ return appRoots.flatMap((appRoot) => [
87
+ root(path.join(appRoot, "User", "acp-events")),
88
+ root(path.join(appRoot, "User", "globalStorage"), {
89
+ recursive: false,
90
+ filter: sqliteStateDbFilter,
91
+ coalesceMs: SQLITE_COALESCE_MS
92
+ })
93
+ ]);
94
+ }
95
+ case "windsurf": {
96
+ const windsurfHome =
97
+ env.AGENTLOG_WINDSURF_HOME_DIR ||
98
+ env.AGENTLOG_CODEIUM_WINDSURF_HOME_DIR ||
99
+ path.join(env.AGENTLOG_CODEIUM_HOME_DIR || path.join(home, ".codeium"), "windsurf");
100
+ return [root(windsurfHome)];
101
+ }
102
+ case "copilot-cli": {
103
+ if (env.AGENTLOG_COPILOT_SESSION_STATE_DIR) return [root(env.AGENTLOG_COPILOT_SESSION_STATE_DIR)];
104
+ const copilotHome = env.AGENTLOG_COPILOT_HOME || env.COPILOT_HOME || path.join(home, ".copilot");
105
+ return [root(path.join(copilotHome, "session-state"))];
106
+ }
107
+ case "factory": {
108
+ if (env.AGENTLOG_FACTORY_SESSIONS_DIR) return [root(env.AGENTLOG_FACTORY_SESSIONS_DIR)];
109
+ return [root(path.join(env.FACTORY_HOME_OVERRIDE || home, ".factory", "sessions"))];
110
+ }
111
+ case "grok-build": {
112
+ if (env.AGENTLOG_GROK_SESSIONS_DIR) return [root(env.AGENTLOG_GROK_SESSIONS_DIR)];
113
+ const grokHome = env.AGENTLOG_GROK_HOME || env.GROK_HOME || path.join(home, ".grok");
114
+ return [root(path.join(grokHome, "sessions"))];
115
+ }
116
+ case "pi": {
117
+ const explicit = env.AGENTLOG_PI_SESSION_DIR || env.PI_CODING_AGENT_SESSION_DIR;
118
+ if (explicit) return [root(explicit)];
119
+ const agentDir = env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent");
120
+ return [root(path.join(agentDir, "sessions"))];
121
+ }
122
+ case "cursor": {
123
+ const globalStorage =
124
+ env.AGENTLOG_CURSOR_GLOBAL_STORAGE_DIR ||
125
+ path.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage");
126
+ const workspaceStorage =
127
+ env.AGENTLOG_CURSOR_WORKSPACE_STORAGE_DIR ||
128
+ path.join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage");
129
+ return [
130
+ root(globalStorage, { recursive: false, filter: sqliteStateDbFilter, coalesceMs: SQLITE_COALESCE_MS }),
131
+ root(workspaceStorage, { filter: sqliteStateDbFilter, coalesceMs: SQLITE_COALESCE_MS })
132
+ ];
133
+ }
134
+ case "cline": {
135
+ // VS Code variants only; JetBrains installs rely on the heartbeat poll.
136
+ const appData = env.APPDATA || env.AppData;
137
+ return [
138
+ path.join(home, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev"),
139
+ path.join(home, "Library", "Application Support", "Code - Insiders", "User", "globalStorage", "saoudrizwan.claude-dev"),
140
+ path.join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev"),
141
+ appData ? path.join(appData, "Code", "User", "globalStorage", "saoudrizwan.claude-dev") : ""
142
+ ]
143
+ .filter(Boolean)
144
+ .map((dir) => root(dir));
145
+ }
146
+ case "opencode-cli":
147
+ case "opencode-desktop":
148
+ case "opencode-web": {
149
+ const configured = env.AGENTLOG_OPENCODE_DATA_DIR || env.OPENCODE_DATA_DIR;
150
+ if (configured) return [root(configured)];
151
+ const cliRoot = path.join(home, ".local", "share", "opencode");
152
+ if (source === "opencode-cli") return [root(cliRoot)];
153
+ const desktopRoots = [
154
+ path.join(home, "Library", "Application Support", "ai.opencode.desktop"),
155
+ path.join(home, "Library", "Application Support", "opencode"),
156
+ path.join(home, ".local", "share", "ai.opencode.app"),
157
+ path.join(home, "Library", "Application Support", "ai.opencode.app")
158
+ ];
159
+ const roots = source === "opencode-web" ? [cliRoot, ...desktopRoots] : desktopRoots;
160
+ return roots.map((dir) => root(dir));
161
+ }
162
+ default:
163
+ return [];
164
+ }
165
+ }
166
+
167
+ // Watches the source roots and reports dirty sources after their coalesce
168
+ // window. Roots are deduped so sources sharing a directory (codex-cli and
169
+ // codex-desktop both read ~/.codex/sessions) share one OS watch. Returns a
170
+ // handle: isWatched(source) tells the supervisor whether the source can rely
171
+ // on events (true while roots are merely missing — nothing to import then
172
+ // either — false once a watch attempt errors), refresh() retries missing or
173
+ // dead watches, close() tears everything down.
174
+ function startSourceWatchers(sources, env, onSourceDirty, options = {}) {
175
+ const coalesceOverrideMs = options.coalesceMs || 0;
176
+ const retryMs = options.retryMs || WATCH_RETRY_MS;
177
+ const onLog = typeof options.onLog === "function" ? options.onLog : () => {};
178
+
179
+ const byDir = new Map();
180
+ const sourcesWithRoots = new Set();
181
+ const failedSources = new Set();
182
+ for (const source of sources || []) {
183
+ for (const entry of watchRootsForSource(source, env)) {
184
+ sourcesWithRoots.add(source);
185
+ const existing = byDir.get(entry.dir);
186
+ if (existing) {
187
+ existing.recursive = existing.recursive || entry.recursive;
188
+ existing.subscribers.push({ source, filter: entry.filter, coalesceMs: entry.coalesceMs });
189
+ } else {
190
+ byDir.set(entry.dir, {
191
+ dir: entry.dir,
192
+ recursive: entry.recursive,
193
+ watcher: null,
194
+ subscribers: [{ source, filter: entry.filter, coalesceMs: entry.coalesceMs }]
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ const timers = new Map();
201
+ let closed = false;
202
+
203
+ function markDirty(source, coalesceMs) {
204
+ if (closed || timers.has(source)) return;
205
+ const timer = setTimeout(() => {
206
+ timers.delete(source);
207
+ if (!closed) onSourceDirty(source);
208
+ }, coalesceOverrideMs || coalesceMs);
209
+ if (typeof timer.unref === "function") timer.unref();
210
+ timers.set(source, timer);
211
+ }
212
+
213
+ function handleEvent(entry, filename) {
214
+ const name = typeof filename === "string" ? filename : "";
215
+ for (const subscriber of entry.subscribers) {
216
+ // A null filename means the platform couldn't say what changed; treat
217
+ // it as a match rather than risk missing activity.
218
+ if (subscriber.filter && name && !subscriber.filter(name)) continue;
219
+ markDirty(subscriber.source, subscriber.coalesceMs);
220
+ }
221
+ }
222
+
223
+ function markEntryFailed(entry, error) {
224
+ for (const subscriber of entry.subscribers) failedSources.add(subscriber.source);
225
+ onLog(`watch failed for ${entry.dir}: ${error.message}`);
226
+ }
227
+
228
+ function attach(entry) {
229
+ if (entry.watcher || closed) return;
230
+ let stat = null;
231
+ try {
232
+ stat = fs.statSync(entry.dir);
233
+ } catch {
234
+ return; // Missing root: nothing to import from it yet; refresh retries.
235
+ }
236
+ if (!stat.isDirectory()) return;
237
+ try {
238
+ const watcher = fs.watch(entry.dir, { recursive: entry.recursive, persistent: false });
239
+ watcher.on("change", (eventType, filename) => handleEvent(entry, filename));
240
+ watcher.on("error", () => {
241
+ // Watches die when the root is replaced; refresh re-attaches.
242
+ try {
243
+ watcher.close();
244
+ } catch {}
245
+ entry.watcher = null;
246
+ });
247
+ entry.watcher = watcher;
248
+ } catch (error) {
249
+ markEntryFailed(entry, error);
250
+ }
251
+ }
252
+
253
+ function refresh() {
254
+ if (closed) return;
255
+ for (const entry of byDir.values()) attach(entry);
256
+ }
257
+
258
+ refresh();
259
+ const retryTimer = setInterval(refresh, retryMs);
260
+ if (typeof retryTimer.unref === "function") retryTimer.unref();
261
+
262
+ return {
263
+ isWatched(source) {
264
+ return sourcesWithRoots.has(source) && !failedSources.has(source);
265
+ },
266
+ activeWatchCount() {
267
+ let count = 0;
268
+ for (const entry of byDir.values()) if (entry.watcher) count += 1;
269
+ return count;
270
+ },
271
+ refresh,
272
+ close() {
273
+ closed = true;
274
+ clearInterval(retryTimer);
275
+ for (const timer of timers.values()) clearTimeout(timer);
276
+ timers.clear();
277
+ for (const entry of byDir.values()) {
278
+ if (!entry.watcher) continue;
279
+ try {
280
+ entry.watcher.close();
281
+ } catch {}
282
+ entry.watcher = null;
283
+ }
284
+ }
285
+ };
286
+ }
287
+
288
+ module.exports = {
289
+ DEFAULT_COALESCE_MS,
290
+ SQLITE_COALESCE_MS,
291
+ startSourceWatchers,
292
+ watchRootsForSource
293
+ };