residoo 0.1.0 → 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.
Files changed (52) hide show
  1. package/README.md +334 -46
  2. package/SECURITY.md +29 -22
  3. package/package.json +1 -1
  4. package/src/cli.js +249 -17
  5. package/src/integrity.js +689 -0
  6. package/src/patterns.js +78 -5
  7. package/src/report.js +188 -8
  8. package/src/rotation.js +834 -0
  9. package/src/sources/agent-configs.js +308 -0
  10. package/src/sources/aider.js +361 -0
  11. package/src/sources/amazon-q.js +199 -0
  12. package/src/sources/antigravity-cli.js +155 -0
  13. package/src/sources/cline.js +208 -0
  14. package/src/sources/codebuff.js +295 -0
  15. package/src/sources/codex-cli.js +258 -0
  16. package/src/sources/cody.js +325 -0
  17. package/src/sources/continue.js +408 -0
  18. package/src/sources/copilot-chat.js +272 -0
  19. package/src/sources/copilot-cli.js +300 -0
  20. package/src/sources/crush.js +364 -0
  21. package/src/sources/cursor.js +374 -0
  22. package/src/sources/devin-cli.js +241 -0
  23. package/src/sources/factory-droid.js +153 -0
  24. package/src/sources/fx.js +136 -0
  25. package/src/sources/gemini-cli.js +242 -0
  26. package/src/sources/goose.js +366 -0
  27. package/src/sources/grok-cli.js +267 -0
  28. package/src/sources/hermes.js +282 -0
  29. package/src/sources/index.js +172 -8
  30. package/src/sources/jetbrains-ai-assistant.js +343 -0
  31. package/src/sources/jetbrains-junie.js +292 -0
  32. package/src/sources/kilo-code.js +430 -0
  33. package/src/sources/kimi-code.js +147 -0
  34. package/src/sources/kiro-cli.js +393 -0
  35. package/src/sources/kiro-ide.js +230 -0
  36. package/src/sources/llm.js +328 -0
  37. package/src/sources/mentat.js +143 -0
  38. package/src/sources/open-interpreter.js +224 -0
  39. package/src/sources/openclaw.js +218 -0
  40. package/src/sources/opencode.js +379 -0
  41. package/src/sources/openhands.js +181 -0
  42. package/src/sources/pearai.js +151 -0
  43. package/src/sources/pi-agent.js +130 -0
  44. package/src/sources/project-artifacts.js +355 -0
  45. package/src/sources/qodo-gen.js +189 -0
  46. package/src/sources/qwen-code.js +244 -0
  47. package/src/sources/roo-code.js +239 -0
  48. package/src/sources/trae.js +294 -0
  49. package/src/sources/void.js +273 -0
  50. package/src/sources/warp.js +395 -0
  51. package/src/sources/windsurf.js +256 -0
  52. package/src/sources/zed.js +374 -0
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+
7
+ /**
8
+ * Codebuff (codebuff.com, formerly named "Manicode" — the on-disk config
9
+ * directory still uses the old name) local chat history.
10
+ *
11
+ * VERIFICATION STATUS: NOT checked against a real Codebuff install — neither
12
+ * `codebuff` nor `manicode` is on PATH, and no `~/.config/manicode*`
13
+ * directory exists on the machine this adapter was built on (checked PATH,
14
+ * npm -g, mdfind, the paths below directly). Ships anyway per
15
+ * CONTRIBUTING.md rule 3, on the strength of three independent, current,
16
+ * mutually-corroborating sources:
17
+ *
18
+ * 1. Codebuff's own official docs (codebuff.com/docs/advanced), which
19
+ * document the `CODEBUFF_DATA_DIR` override and the fact that history is
20
+ * scoped per "channel" (production/dev/staging).
21
+ * 2. A real, independent, third-party tool's own written documentation of
22
+ * the format: CodexBar (github.com/steipete/CodexBar,
23
+ * docs/codebuff.md), which states conversation history "is stored
24
+ * locally at ~/.config/manicode/projects/<project-name>/chats" and that
25
+ * `~/.config/manicode/credentials.json` is written after `codebuff
26
+ * login` — the "formerly manicode" naming and directory layout agree
27
+ * exactly with source 3 below.
28
+ * 3. The strongest source: ccusage (github.com/ccusage/ccusage, a real,
29
+ * independently maintained, actively developed CLI usage tracker with
30
+ * 18k+ real GitHub stars — sanity-checked directly, not assumed) ships
31
+ * its OWN tested Rust adapter for Codebuff
32
+ * (rust/adapters/codebuff/src/{paths,parser,loader}.rs), fetched and
33
+ * read directly, not summarized secondhand. Its unit tests embed a real
34
+ * fixture file at
35
+ * `projects/project-a/chats/2026-01-02T03-04-05.000Z/chat-messages.json`
36
+ * containing actual message-object shapes
37
+ * (`{"role":"user","text":"hello"}`,
38
+ * `{"id":"...","role":"assistant","timestamp":"...","metadata":{"model":"...","usage":{...}},"credits":1.25}`)
39
+ * — this is a real, working, unrelated tool's reverse-engineered
40
+ * understanding of the exact same file this source reads, the same
41
+ * evidentiary bar cursor.js's own docstring cites approvingly for its
42
+ * own two corroborating community tools.
43
+ *
44
+ * Directory layout (agreed by all three sources): one root per "channel" —
45
+ * `~/.config/manicode` (production), `~/.config/manicode-dev`,
46
+ * `~/.config/manicode-staging` — each containing
47
+ * `projects/<project>/chats/<chatId>/chat-messages.json`, overridable via
48
+ * the `CODEBUFF_DATA_DIR` env var (a comma-separated list of channel roots;
49
+ * source 1 and source 3 agree on both the env var name and its comma-list
50
+ * shape). `chat-messages.json` is a JSON ARRAY of message objects — not
51
+ * line-delimited — so, per the adapter-contract note about non-line-
52
+ * delimited storage, each message object becomes one scanned "line" (see
53
+ * extractTopLevelJsonObjects() below).
54
+ *
55
+ * Deliberately NOT scanned: `credentials.json` (Codebuff's own CLI auth
56
+ * token for the logged-in account) — it is not a session transcript, and
57
+ * every other source in this project scans transcripts only, not each
58
+ * tool's own credential store (cursor.js, for instance, does not read
59
+ * Cursor's OS keychain entries either).
60
+ */
61
+ const CODEBUFF_DATA_DIR_ENV = "CODEBUFF_DATA_DIR";
62
+ const CHANNELS = ["manicode", "manicode-dev", "manicode-staging"];
63
+
64
+ function id() { return "codebuff"; }
65
+ function label() { return "Codebuff"; }
66
+
67
+ /**
68
+ * Resolve the "projects" root for every configured channel, deduped.
69
+ *
70
+ * Mirrors ccusage's own `codebuff_project_roots()` exactly: when
71
+ * CODEBUFF_DATA_DIR is set, each comma-separated entry is used as-is if its
72
+ * basename is already "projects", otherwise "projects" is appended — this
73
+ * lets a user point the env var either at a channel root or directly at its
74
+ * projects subdirectory, matching Codebuff's own documented flexibility.
75
+ */
76
+ function codebuffProjectRoots() {
77
+ const envVal = process.env[CODEBUFF_DATA_DIR_ENV];
78
+ let roots;
79
+ if (envVal && envVal.trim() !== "") {
80
+ roots = envVal
81
+ .split(",")
82
+ .map((s) => s.trim())
83
+ .filter((s) => s !== "")
84
+ .map((p) => path.resolve(p));
85
+ } else {
86
+ const home = os.homedir();
87
+ roots = CHANNELS.map((channel) => path.join(home, ".config", channel));
88
+ }
89
+
90
+ const seen = new Set();
91
+ const projectRoots = [];
92
+ for (const root of roots) {
93
+ const projectRoot = path.basename(root) === "projects" ? root : path.join(root, "projects");
94
+ let isDir = false;
95
+ try { isDir = fs.statSync(projectRoot).isDirectory(); } catch { isDir = false; }
96
+ if (isDir && !seen.has(projectRoot)) {
97
+ seen.add(projectRoot);
98
+ projectRoots.push(projectRoot);
99
+ }
100
+ }
101
+ return projectRoots;
102
+ }
103
+
104
+ function available() {
105
+ return codebuffProjectRoots().length > 0;
106
+ }
107
+
108
+ /**
109
+ * Same defensive symlink-following pattern as claude-code.js's
110
+ * isDirFollowingSymlink/isFileFollowingSymlink — duplicated rather than
111
+ * imported per this project's one-small-self-contained-file-per-source
112
+ * convention (see cursor.js's own docstring for why).
113
+ */
114
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
115
+ if (checkFn(dirent)) return true;
116
+ if (!dirent.isSymbolicLink()) return false;
117
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
118
+ }
119
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
120
+
121
+ /**
122
+ * Resolve one candidate `chat-messages.json` path into zero or one files()
123
+ * entries — same lstat-first, follow-if-symlink shape as cursor.js's
124
+ * statIfPresent, and for the same reason: this path is constructed (joined
125
+ * onto an already-resolved chat directory), not discovered via a Dirent, so
126
+ * there is no Dirent to reuse the isKindFollowingSymlink check against. A
127
+ * chat directory with no chat-messages.json yet (a brand new, still-empty
128
+ * chat) is normal and NOT broken; only a symlink that fails to resolve is.
129
+ */
130
+ function* statFileIfPresent(file) {
131
+ let lst;
132
+ try { lst = fs.lstatSync(file); }
133
+ catch { return; }
134
+
135
+ if (lst.isSymbolicLink()) {
136
+ try {
137
+ const st = fs.statSync(file);
138
+ if (!st.isFile()) { yield { file, broken: true }; return; }
139
+ yield { file, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
140
+ } catch {
141
+ yield { file, broken: true };
142
+ }
143
+ return;
144
+ }
145
+
146
+ if (!lst.isFile()) return;
147
+ yield { file, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
148
+ }
149
+
150
+ /**
151
+ * Yield { file, mtimeMs, sizeBytes, broken } for every chat-messages.json
152
+ * found under every configured channel's projects root:
153
+ * <projectsRoot>/<project>/chats/<chatId>/chat-messages.json.
154
+ */
155
+ function* files() {
156
+ for (const projectsRoot of codebuffProjectRoots()) {
157
+ let projectEntries;
158
+ try { projectEntries = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
159
+ catch { continue; }
160
+
161
+ for (const proj of projectEntries) {
162
+ const projDir = path.join(projectsRoot, proj.name);
163
+ if (!isDirFollowingSymlink(projDir, proj)) {
164
+ if (proj.isSymbolicLink()) yield { file: projDir, broken: true };
165
+ continue;
166
+ }
167
+
168
+ const chatsDir = path.join(projDir, "chats");
169
+ let chatEntries;
170
+ try { chatEntries = fs.readdirSync(chatsDir, { withFileTypes: true }); }
171
+ catch { continue; } // no "chats" subdir yet — normal for a project with no chat history
172
+
173
+ for (const chat of chatEntries) {
174
+ const chatDir = path.join(chatsDir, chat.name);
175
+ if (!isDirFollowingSymlink(chatDir, chat)) {
176
+ if (chat.isSymbolicLink()) yield { file: chatDir, broken: true };
177
+ continue;
178
+ }
179
+ yield* statFileIfPresent(path.join(chatDir, "chat-messages.json"));
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ // A single chat's message history has not been observed anywhere in this
186
+ // source's research to approach this size — like cursor.js's MAX_DB_BYTES,
187
+ // this is a generous, untested-against-a-real-huge-file backstop against a
188
+ // corrupted or pathological file, not an empirically derived ceiling.
189
+ const MAX_BYTES = 512 * 1024 * 1024;
190
+ const READ_TIMEOUT_MS = 60_000;
191
+
192
+ /**
193
+ * Read the whole file as text, bounded by a wall-clock deadline the same
194
+ * way claude-code.js bounds its line-by-line read: no timeout exists
195
+ * natively anywhere in Node's stream stack, so a symlink retargeted onto
196
+ * something with no natural EOF (e.g. a FIFO with no writer) would otherwise
197
+ * hang forever with no 'error' or 'end' ever firing. Destroying the stream
198
+ * is what actually unblocks that.
199
+ *
200
+ * Resolves with whatever text WAS accumulated even when the read errors or
201
+ * times out partway — the caller (readLines) still extracts every complete
202
+ * top-level JSON object out of that partial text rather than discarding it,
203
+ * the same "partial read is still real content" principle claude-code.js's
204
+ * and cursor.js's own docstrings insist on.
205
+ */
206
+ function readWholeFileBounded(file) {
207
+ return new Promise((resolve) => {
208
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
209
+ let text = "";
210
+ let bytesRead = 0;
211
+ let errored = false;
212
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
213
+ stream.on("data", (chunk) => {
214
+ text += chunk;
215
+ bytesRead += Buffer.byteLength(chunk, "utf-8");
216
+ });
217
+ stream.on("end", () => {
218
+ clearTimeout(timer);
219
+ resolve({ text, bytesRead, complete: true });
220
+ });
221
+ stream.on("error", () => {
222
+ if (errored) return; // 'error' can fire once for the destroy() and once natively
223
+ errored = true;
224
+ clearTimeout(timer);
225
+ resolve({ text, bytesRead, complete: false });
226
+ });
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Extract every complete top-level `{...}` object out of arbitrary JSON
232
+ * text, tracking brace depth and string/escape state, WITHOUT requiring the
233
+ * surrounding `[ ... ]` array to be syntactically complete.
234
+ *
235
+ * This is what lets a partial read (timeout, mid-write file, I/O error)
236
+ * still surface every message that was fully written before the cutoff,
237
+ * instead of the whole file being discarded because JSON.parse on truncated
238
+ * input throws. It is also what lets a fully-successful read skip a real
239
+ * JSON.parse of the whole array entirely: each returned substring is used
240
+ * verbatim as one scanned "line", the same "don't re-serialize, keep the
241
+ * exact bytes the regexes depend on" reasoning cursor.js's valueToText()
242
+ * docstring gives for not round-tripping through JSON.parse/stringify.
243
+ */
244
+ function extractTopLevelJsonObjects(text) {
245
+ const objects = [];
246
+ let depth = 0;
247
+ let start = -1;
248
+ let inString = false;
249
+ let escape = false;
250
+ for (let i = 0; i < text.length; i++) {
251
+ const ch = text[i];
252
+ if (inString) {
253
+ if (escape) escape = false;
254
+ else if (ch === "\\") escape = true;
255
+ else if (ch === '"') inString = false;
256
+ continue;
257
+ }
258
+ if (ch === '"') { inString = true; continue; }
259
+ if (ch === "{") {
260
+ if (depth === 0) start = i;
261
+ depth++;
262
+ } else if (ch === "}") {
263
+ if (depth > 0) depth--;
264
+ if (depth === 0 && start !== -1) {
265
+ objects.push(text.slice(start, i + 1));
266
+ start = -1;
267
+ }
268
+ }
269
+ }
270
+ return objects;
271
+ }
272
+
273
+ /**
274
+ * Read one chat-messages.json as an array of raw text "lines", one per
275
+ * top-level message object. Returns { lines, status, bytesRead } with the
276
+ * same status vocabulary as claude-code.js/cursor.js: "complete", "partial",
277
+ * "too-large", "failed".
278
+ */
279
+ async function readLines(file) {
280
+ let stat;
281
+ try { stat = fs.statSync(file); }
282
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
283
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
284
+
285
+ const { text, bytesRead, complete } = await readWholeFileBounded(file);
286
+ const lines = extractTopLevelJsonObjects(text);
287
+
288
+ if (complete) return { lines, status: "complete", bytesRead };
289
+ // Errored or timed out partway: whatever complete objects WERE recovered
290
+ // are real content and may contain a real secret — never discard them
291
+ // just because the tail of the file didn't finish cleanly.
292
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
293
+ }
294
+
295
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const { createInterface } = require("readline/promises");
7
+
8
+ /**
9
+ * OpenAI's Codex CLI (the `codex` coding-agent binary — see
10
+ * https://github.com/openai/codex — NOT the retired "Codex" language model
11
+ * from 2021). Also referred to below as "Codex CLI" throughout to keep that
12
+ * distinction unambiguous.
13
+ *
14
+ * VERIFICATION STATUS: this source was NOT checked against a real Codex CLI
15
+ * install — `codex` is not installed on the machine this adapter was built
16
+ * on (checked: no `codex` on PATH, no `~/.codex`, no Homebrew/npm-global
17
+ * install, mdfind turned up nothing for a local CLI install — the only
18
+ * "codex" hits on this machine were the ChatGPT desktop app's own unrelated
19
+ * `com.openai.chat` local cache for its cloud-hosted "Codex" task feature,
20
+ * which is a different product with no local session transcripts of its
21
+ * own to scan; it runs in a remote container, not on this machine). Per
22
+ * CONTRIBUTING.md this ships anyway because it clears that bar a different
23
+ * way: multiple independent, credible, and largely recent sources agree
24
+ * with each other on the exact path and schema below, including official
25
+ * OpenAI documentation, the tool's own GitHub issue tracker describing this
26
+ * exact file layout as a live bug surface, and more than one third-party
27
+ * tool that reads these same files for a living. Specifically:
28
+ *
29
+ * - Official docs (developers.openai.com/codex/environment-variables,
30
+ * redirects to learn.chatgpt.com/docs/config-file/environment-variables):
31
+ * CODEX_HOME "sets the root directory for Codex state, including config,
32
+ * auth, logs, sessions, skills, and standalone package metadata,"
33
+ * defaulting to `~/.codex`.
34
+ * - openai/codex GitHub issue #21660 ("rollout: session JSONL files are
35
+ * created world-readable (mode 0644) on Unix") and issue #20864
36
+ * ("Codex Desktop App becomes laggy because it scans all
37
+ * `~/.codex/sessions` rollout files...") — both filed against the real
38
+ * tool, both independently naming this exact directory.
39
+ * - openai/codex GitHub Discussion #24042, a real, working, open-source
40
+ * native macOS viewer built specifically to read `~/.codex/sessions/
41
+ * *.jsonl`, and community tools codex-trace (PixelPaw-Labs) and
42
+ * codex-history-list (shinshin86) doing the same — the kind of "a
43
+ * maintained tool reads the same files" corroboration CONTRIBUTING.md
44
+ * calls out explicitly.
45
+ * - openai/codex GitHub issue #17000 ("Auto-archive and zstd-compress
46
+ * inactive local rollout files...") independently confirms the
47
+ * `rollout-*.jsonl` naming and the date-partitioned directory shape by
48
+ * proposing changes to it. NOTE: this issue also shows the zstd
49
+ * compression feature it proposes is NOT yet shipped as of this
50
+ * research — a compression detail that appeared in one AI-generated
51
+ * summary was traced back to this still-open proposal, not a shipped
52
+ * behavior, so no zstd decompression is assumed live here. It's still
53
+ * handled defensively below (see ZSTD_RE) in case that changes.
54
+ * - Independent write-ups (a dev.to reverse-engineering post showing real
55
+ * rollout JSONL line shapes, prismmd.app and betelgeuse.work blog posts,
56
+ * and a Codex-Knowledge-Base article on session archiving) all agree on
57
+ * the same `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`
58
+ * shape and on a parallel `~/.codex/archived_sessions/` tree used when a
59
+ * session is archived (the rollout JSONL is moved, not transformed).
60
+ * - Multiple of the above also describe a flat `~/.codex/history.jsonl` —
61
+ * one line per user turn, holding just the raw text the user typed for
62
+ * that turn (not the full conversation) — which is real, secret-scanning
63
+ * -relevant content (a pasted key or token lands here) distinct from the
64
+ * per-session rollout files, so it's read too.
65
+ *
66
+ * Deliberately NOT read: `~/.codex/session_index.jsonl`. Independent
67
+ * sources agree it is a lightweight metadata cache only (id, timestamp,
68
+ * cwd, model, status) that explicitly does NOT duplicate rollout content —
69
+ * scanning it would add file-walk cost with no realistic chance of a
70
+ * secret-bearing line. Also not read: anything under `~/.codex` that isn't
71
+ * one of the three content locations above — chiefly `config.toml`,
72
+ * `auth.json`, and `log/`, which are Codex's own config/credential/log
73
+ * files, not session transcript content, mirroring how claude-code.js and
74
+ * cursor.js each stay scoped to actual conversation data rather than a
75
+ * tool's entire state directory.
76
+ *
77
+ * If you have Codex CLI installed, the most useful thing you can do is run
78
+ * `residoo scan` and confirm `sourcesScanned`/`filesScanned` look right for
79
+ * what you know is actually on disk under `~/.codex`, then report back
80
+ * either way — see CONTRIBUTING.md.
81
+ */
82
+ function codexHome() {
83
+ // Honoring CODEX_HOME (rather than hardcoding ~/.codex) mirrors how
84
+ // cursor.js honors XDG_CONFIG_HOME — the tool's own documented override,
85
+ // not a guess, and the official docs above are explicit that sessions,
86
+ // not just config, live under this root.
87
+ if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
88
+ return path.join(os.homedir(), ".codex");
89
+ }
90
+
91
+ const ROOT = codexHome();
92
+ const SESSIONS_DIR = path.join(ROOT, "sessions");
93
+ const ARCHIVED_SESSIONS_DIR = path.join(ROOT, "archived_sessions");
94
+ const HISTORY_FILE = path.join(ROOT, "history.jsonl");
95
+
96
+ // A rollout file this large has not been reported anywhere in this source's
97
+ // research; kept identical to claude-code.js's bound (same underlying
98
+ // concern — V8's whole-string ceiling doesn't apply here since this source
99
+ // also streams line-by-line, but a shared, already-reasoned-about number
100
+ // beats inventing a new one with no evidence behind it).
101
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
102
+ const READ_TIMEOUT_MS = 60_000;
103
+
104
+ // Symlink-loop safety for the recursive sessions/archived_sessions walk.
105
+ // claude-code.js never needed a depth bound — its walk is exactly two
106
+ // levels (project dir, then files in it). Walking an arbitrary
107
+ // YYYY/MM/DD(/...)? tree that may itself contain a symlink is a genuinely
108
+ // new risk this source introduces, so it gets a guard claude-code.js didn't
109
+ // need. 12 gives generous headroom over the documented 3-level date
110
+ // partitioning while still bounding a pathological symlink cycle.
111
+ const MAX_WALK_DEPTH = 12;
112
+
113
+ // See the ZSTD note in the module docstring: not confirmed shipped, but
114
+ // handled honestly rather than assumed absent forever. A zero-dependency
115
+ // project has no built-in Zstandard decoder available across the supported
116
+ // Node range (>=18), so a matching file is surfaced as a normal, resolvable
117
+ // file entry (not "broken" — it's not unresolvable, just undecodable by
118
+ // this tool) and readLines() reports it "failed" rather than silently
119
+ // omitting it from the walk.
120
+ const ZSTD_RE = /\.zst$/i;
121
+ const JSONL_RE = /\.jsonl$/i;
122
+
123
+ function id() { return "codex-cli"; }
124
+ function label() { return "Codex CLI"; }
125
+
126
+ function available() {
127
+ try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
128
+ }
129
+
130
+ /**
131
+ * Same lstat-vs-stat symlink-following pattern as claude-code.js's
132
+ * isKindFollowingSymlink — duplicated rather than imported, matching this
133
+ * project's convention (cursor.js's docstring on the same duplication:
134
+ * "each source in this project is meant to be a small, self-contained file
135
+ * a reviewer can audit on its own").
136
+ */
137
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
138
+ if (checkFn(dirent)) return true;
139
+ if (!dirent.isSymbolicLink()) return false;
140
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
141
+ }
142
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
143
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
144
+
145
+ /**
146
+ * Recursively walk one directory (sessions/ or archived_sessions/) yielding
147
+ * { file, mtimeMs, sizeBytes, broken } for every `*.jsonl` (scanned) and
148
+ * `*.zst` (surfaced, see ZSTD_RE above) file found at any depth, following
149
+ * symlinks the same way claude-code.js's files() does for project dirs and
150
+ * jsonl files, and reporting a dangling symlink as broken rather than
151
+ * skipping it silently. Any other file extension under this tree is out of
152
+ * scope, same as claude-code.js ignoring non-`.jsonl` entries.
153
+ */
154
+ function* walkSessionDir(dir, depth) {
155
+ if (depth > MAX_WALK_DEPTH) return;
156
+ let entries;
157
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
158
+ catch { return; } // dir vanished or unreadable mid-walk — not reportable, nothing was ever yielded for it
159
+
160
+ for (const e of entries) {
161
+ const full = path.join(dir, e.name);
162
+
163
+ if (!e.isFile() && !e.isDirectory()) {
164
+ // Symlink (or other special entry) — resolve to find out which kind.
165
+ if (isDirFollowingSymlink(full, e)) { yield* walkSessionDir(full, depth + 1); continue; }
166
+ if (isFileFollowingSymlink(full, e)) {
167
+ if (!JSONL_RE.test(e.name) && !ZSTD_RE.test(e.name)) continue;
168
+ let stat;
169
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
170
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
171
+ continue;
172
+ }
173
+ // Didn't resolve to either — a dangling symlink is the plausible
174
+ // real-world case (see claude-code.js's identical reasoning).
175
+ if (e.isSymbolicLink()) yield { file: full, broken: true };
176
+ continue;
177
+ }
178
+
179
+ if (e.isDirectory()) { yield* walkSessionDir(full, depth + 1); continue; }
180
+
181
+ if (!JSONL_RE.test(e.name) && !ZSTD_RE.test(e.name)) continue;
182
+ let stat;
183
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
184
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Yield { file, mtimeMs, sizeBytes, broken } for every rollout file under
190
+ * sessions/ and archived_sessions/, plus the single flat history.jsonl —
191
+ * see the module docstring for why each of these three (and only these
192
+ * three) locations is read.
193
+ */
194
+ function* files() {
195
+ yield* walkSessionDir(SESSIONS_DIR, 0);
196
+ yield* walkSessionDir(ARCHIVED_SESSIONS_DIR, 0);
197
+
198
+ // history.jsonl is a single fixed-name file, not a directory to walk —
199
+ // same lstat-first, follow-if-symlink handling as cursor.js's
200
+ // statIfPresent, and a path that simply doesn't exist (most installs,
201
+ // depending on version/config) is normal, not broken.
202
+ let lst;
203
+ try { lst = fs.lstatSync(HISTORY_FILE); }
204
+ catch { return; }
205
+
206
+ if (lst.isSymbolicLink()) {
207
+ try {
208
+ const st = fs.statSync(HISTORY_FILE);
209
+ if (!st.isFile()) { yield { file: HISTORY_FILE, broken: true }; return; }
210
+ yield { file: HISTORY_FILE, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
211
+ } catch {
212
+ yield { file: HISTORY_FILE, broken: true };
213
+ }
214
+ return;
215
+ }
216
+ if (!lst.isFile()) return;
217
+ yield { file: HISTORY_FILE, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
218
+ }
219
+
220
+ /**
221
+ * Read one file as an array of raw text lines. Identical streaming strategy
222
+ * to claude-code.js's readLines (see that file's docstring for the full
223
+ * reasoning on why streaming + a read timeout + honest partial-read
224
+ * handling all matter) — duplicated rather than shared, per this project's
225
+ * one-file-per-source convention. The one addition is the zstd short-circuit
226
+ * at the top; see ZSTD_RE above.
227
+ */
228
+ async function readLines(file) {
229
+ if (ZSTD_RE.test(file)) return { lines: [], status: "failed", bytesRead: 0 };
230
+
231
+ let stat;
232
+ try { stat = fs.statSync(file); }
233
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
234
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
235
+
236
+ const lines = [];
237
+ let bytesRead = 0;
238
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
239
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
240
+
241
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
242
+
243
+ try {
244
+ for await (const line of rl) {
245
+ lines.push(line);
246
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
247
+ }
248
+ return { lines, status: "complete", bytesRead };
249
+ } catch {
250
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
251
+ } finally {
252
+ clearTimeout(timer);
253
+ rl.close();
254
+ stream.destroy();
255
+ }
256
+ }
257
+
258
+ module.exports = { id, label, available, files, readLines };