residoo 0.1.0 → 0.2.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 (50) hide show
  1. package/README.md +225 -46
  2. package/SECURITY.md +29 -22
  3. package/package.json +1 -1
  4. package/src/cli.js +82 -16
  5. package/src/integrity.js +669 -0
  6. package/src/patterns.js +78 -5
  7. package/src/report.js +74 -7
  8. package/src/sources/agent-configs.js +308 -0
  9. package/src/sources/aider.js +361 -0
  10. package/src/sources/amazon-q.js +199 -0
  11. package/src/sources/antigravity-cli.js +155 -0
  12. package/src/sources/cline.js +208 -0
  13. package/src/sources/codebuff.js +295 -0
  14. package/src/sources/codex-cli.js +258 -0
  15. package/src/sources/cody.js +325 -0
  16. package/src/sources/continue.js +408 -0
  17. package/src/sources/copilot-chat.js +272 -0
  18. package/src/sources/copilot-cli.js +300 -0
  19. package/src/sources/crush.js +364 -0
  20. package/src/sources/cursor.js +374 -0
  21. package/src/sources/devin-cli.js +241 -0
  22. package/src/sources/factory-droid.js +153 -0
  23. package/src/sources/fx.js +136 -0
  24. package/src/sources/gemini-cli.js +242 -0
  25. package/src/sources/goose.js +366 -0
  26. package/src/sources/grok-cli.js +267 -0
  27. package/src/sources/hermes.js +282 -0
  28. package/src/sources/index.js +172 -8
  29. package/src/sources/jetbrains-ai-assistant.js +343 -0
  30. package/src/sources/jetbrains-junie.js +292 -0
  31. package/src/sources/kilo-code.js +430 -0
  32. package/src/sources/kimi-code.js +147 -0
  33. package/src/sources/kiro-cli.js +393 -0
  34. package/src/sources/kiro-ide.js +230 -0
  35. package/src/sources/llm.js +328 -0
  36. package/src/sources/mentat.js +143 -0
  37. package/src/sources/open-interpreter.js +224 -0
  38. package/src/sources/openclaw.js +218 -0
  39. package/src/sources/opencode.js +379 -0
  40. package/src/sources/openhands.js +181 -0
  41. package/src/sources/pearai.js +151 -0
  42. package/src/sources/pi-agent.js +130 -0
  43. package/src/sources/qodo-gen.js +189 -0
  44. package/src/sources/qwen-code.js +244 -0
  45. package/src/sources/roo-code.js +239 -0
  46. package/src/sources/trae.js +294 -0
  47. package/src/sources/void.js +273 -0
  48. package/src/sources/warp.js +395 -0
  49. package/src/sources/windsurf.js +256 -0
  50. package/src/sources/zed.js +374 -0
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { createInterface } = require("readline/promises");
5
+ const path = require("path");
6
+ const os = require("os");
7
+
8
+ /**
9
+ * Open Interpreter (the classic Python "natural language interface for
10
+ * computers" CLI, `pip install open-interpreter`, entry point `interpreter`).
11
+ *
12
+ * VERIFICATION STATUS: NOT installed on the machine this adapter was built on
13
+ * (checked: no `interpreter`/`open-interpreter` on PATH, no `open-interpreter`
14
+ * in `pip3 list`, no conversations directory under any of the paths below).
15
+ * Ships anyway per CONTRIBUTING.md rule 3, on unusually strong grounds for an
16
+ * "unverified" source: not a description of the tool, but the tool's own
17
+ * source code, read directly, plus a real, on-this-machine execution of the
18
+ * exact path-resolution call the tool itself makes at import time (only the
19
+ * `interpreter.chat()` LLM round-trip itself was not exercised, since that
20
+ * needs a model API key this research had no reason to acquire):
21
+ *
22
+ * - `interpreter/core/core.py` (fetched from the real GitHub history, tag
23
+ * v0.4.2 — see the naming note below) confirms conversation logging is ON
24
+ * by default (`conversation_history=True` in `OpenInterpreter.__init__`)
25
+ * and shows the exact write path: after every `chat()` turn, the full
26
+ * `self.messages` list is re-serialized whole with `json.dump(self.messages, f)`
27
+ * to `<conversation_history_path>/<conversation_filename>`, where the
28
+ * filename is `<first-25-chars-of-first-message-slug>__<Month_DD_YYYY_HH-MM-SS>.json`
29
+ * and `conversation_history_path` defaults to `get_storage_path("conversations")`.
30
+ * - `interpreter/terminal_interface/utils/local_storage_path.py` confirms
31
+ * `get_storage_path(sub)` is `os.path.join(platformdirs.user_config_dir("open-interpreter"), sub)`
32
+ * — i.e. a single call into the third-party `platformdirs` library, not a
33
+ * hand-rolled per-OS branch.
34
+ * - `interpreter/terminal_interface/conversation_navigator.py` and
35
+ * `.../utils/get_conversations.py` both independently confirm the
36
+ * directory is listed non-recursively for `*.json` files — no subfolders,
37
+ * no other extension.
38
+ * - `platformdirs`' OWN source (tox-dev/platformdirs, macos.py/windows.py/
39
+ * unix.py + api.py, fetched and read directly) confirms, for a call with
40
+ * only an appname (no appauthor) exactly like this one:
41
+ * * macOS: `user_config_dir` == `user_data_dir` == `~/Library/Application
42
+ * Support/<appname>` (macOS doesn't split these the way XDG does).
43
+ * * Linux/XDG: `$XDG_CONFIG_HOME/<appname>` or `~/.config/<appname>`.
44
+ * * Windows: `user_config_dir` == `user_data_dir`; `appauthor` defaults
45
+ * to `appname` when omitted (confirmed from platformdirs' own
46
+ * `api.py` docstring), giving `%LOCALAPPDATA%\<appname>\<appname>`.
47
+ * - REAL execution, on this machine, of the literal call `core.py` makes:
48
+ * a throwaway venv with only `platformdirs` installed (a zero-dependency,
49
+ * pure-path-math package — not the multi-hundred-MB `open-interpreter`
50
+ * package itself, which was not worth installing just to import one
51
+ * function) ran `platformdirs.user_config_dir("open-interpreter")` and
52
+ * printed `/Users/Dan/Library/Application Support/open-interpreter` —
53
+ * exactly matching the macOS formula derived from source above. The venv
54
+ * was deleted after use; nothing from it is installed anymore.
55
+ * - PyPI's `open-interpreter` project page confirms the latest release is
56
+ * 0.4.3 (2024-10-26) — i.e. this scheme, introduced in v0.3.0 (see below),
57
+ * is still what today's `pip install open-interpreter` ships.
58
+ *
59
+ * NAMING TRAP, resolved during this research: the GitHub org this project
60
+ * lived under (`openinterpreter`) was reused in 2026 for an unrelated, brand
61
+ * new Rust rewrite ("a coding agent for open models like Kimi K3", built on
62
+ * OpenAI's open-sourced Codex CLI — its repo tree is full of `codex-rs/*`
63
+ * paths). That is NOT this tool. This adapter targets the classic Python
64
+ * package the task actually asked for ("Python-based local-execution agent"),
65
+ * reached via the org's still-intact git tag history (tag `v0.4.2`, the last
66
+ * one cut before the rewrite) rather than the `main` branch, which now holds
67
+ * the unrelated project.
68
+ *
69
+ * VERSION DRIFT, checked and deliberately scoped around: fetching
70
+ * `local_storage_path.py` at old tags shows v0.1.x/v0.2.x used a DIFFERENT
71
+ * library and app name — `appdirs.user_config_dir("Open Interpreter")`
72
+ * (capitalized, space, no hyphen) — before v0.3.0 switched to the
73
+ * `platformdirs` + `"open-interpreter"` scheme this adapter targets as
74
+ * current. That legacy scheme was itself verified the same way: real
75
+ * execution on this machine (`appdirs.user_config_dir("Open Interpreter")`
76
+ * printed `/Users/Dan/Library/Application Support/Open Interpreter` — note
77
+ * the differing capitalization from the current path) plus reading appdirs'
78
+ * own source for its Windows/Linux formulas. Both schemes read `conversations/`
79
+ * for `*.json` files the same way in both eras (confirmed directly from
80
+ * source at old and new tags alike), so this source scans both directories
81
+ * rather than picking one — the same "format moved, so read every location
82
+ * it has lived" choice cursor.js and gemini-cli.js make for their own
83
+ * cross-version drift.
84
+ */
85
+ function platformdirsConfigDir(appname) {
86
+ const home = os.homedir();
87
+ if (process.platform === "darwin") {
88
+ return path.join(home, "Library", "Application Support", appname);
89
+ }
90
+ if (process.platform === "win32") {
91
+ // appauthor is never passed by Open Interpreter's own call, so
92
+ // platformdirs falls back to using appname as the author too (confirmed
93
+ // from platformdirs' api.py docstring) — hence the doubled segment.
94
+ const base = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
95
+ return path.join(base, appname, appname);
96
+ }
97
+ // Linux and other XDG-following unix platforms.
98
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
99
+ return path.join(configHome, appname);
100
+ }
101
+
102
+ const CURRENT_CONVERSATIONS_DIR = path.join(platformdirsConfigDir("open-interpreter"), "conversations");
103
+ const LEGACY_CONVERSATIONS_DIR = path.join(platformdirsConfigDir("Open Interpreter"), "conversations");
104
+
105
+ // Bounds for readLines() — same shape as claude-code.js's, but the number is
106
+ // NOT backed by a real large Open Interpreter conversation file this tool
107
+ // was tested against (no install available). A single conversation file here
108
+ // is a whole-session JSON re-dump, not an append-only log, so it is expected
109
+ // to be far smaller than a multi-session JSONL transcript in practice — this
110
+ // is a generous, untested backstop against a pathological file, not evidence
111
+ // of what real files look like.
112
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
113
+ const READ_TIMEOUT_MS = 60_000;
114
+
115
+ function id() { return "open-interpreter"; }
116
+ function label() { return "Open Interpreter"; }
117
+
118
+ function available() {
119
+ return dirExists(CURRENT_CONVERSATIONS_DIR) || dirExists(LEGACY_CONVERSATIONS_DIR);
120
+ }
121
+
122
+ function dirExists(p) {
123
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
124
+ }
125
+
126
+ /**
127
+ * Same defensive symlink-following pattern as claude-code.js — see that
128
+ * file's docstring for the full reasoning. Duplicated rather than imported:
129
+ * each source here is meant to be a small, self-contained file a reviewer
130
+ * can audit on its own (see CONTRIBUTING.md).
131
+ */
132
+ function isFileFollowingSymlink(fullPath, dirent) {
133
+ if (dirent.isFile()) return true;
134
+ if (!dirent.isSymbolicLink()) return false;
135
+ try { return fs.statSync(fullPath).isFile(); } catch { return false; }
136
+ }
137
+
138
+ /**
139
+ * Yield { file, mtimeMs, sizeBytes, broken } for every `*.json` file directly
140
+ * inside one conversations directory — flat, non-recursive, matching
141
+ * `get_conversations.py`'s own `os.listdir(...)` + `.endswith(".json")`
142
+ * exactly (no subfolder nesting exists in this format at either the current
143
+ * or legacy path).
144
+ */
145
+ function* walkConversationsDir(dir) {
146
+ let entries;
147
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
148
+ catch { return; } // directory doesn't exist — not broken, just nothing here yet
149
+
150
+ for (const e of entries) {
151
+ if (!e.name.endsWith(".json")) continue;
152
+ const file = path.join(dir, e.name);
153
+ if (!isFileFollowingSymlink(file, e)) {
154
+ if (e.isSymbolicLink()) yield { file, broken: true };
155
+ continue;
156
+ }
157
+ let stat;
158
+ try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
159
+ yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Yields every `*.json` conversation file under both the current
165
+ * (platformdirs) and legacy (appdirs) conversations directories — see the
166
+ * module docstring for why both are read. When both directories happen to
167
+ * resolve to the exact same path (impossible here since the app names differ
168
+ * in case/hyphenation on every supported OS, but guarded anyway in case a
169
+ * future platformdirs/appdirs release ever changes that), the second walk is
170
+ * skipped rather than double-yielding every file in it.
171
+ */
172
+ function* files() {
173
+ yield* walkConversationsDir(CURRENT_CONVERSATIONS_DIR);
174
+ if (LEGACY_CONVERSATIONS_DIR !== CURRENT_CONVERSATIONS_DIR) {
175
+ yield* walkConversationsDir(LEGACY_CONVERSATIONS_DIR);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Read one conversation file as an array of raw text lines. Identical
181
+ * streaming strategy to claude-code.js's readLines (a JSON file on disk is
182
+ * still line-delimited text as far as a byte stream is concerned) — see that
183
+ * file's docstring for the full reasoning behind streaming + a hard timeout.
184
+ *
185
+ * In practice, `json.dump(self.messages, f)` is called with no `indent=`, so
186
+ * the whole file is one single physical line (Python's json module escapes
187
+ * embedded newlines inside string values as `\n`, it doesn't emit them raw)
188
+ * — that's fine: the pattern-matcher works on raw text regardless of how
189
+ * many physical lines it's split across, exactly per the adapter contract
190
+ * cursor.js's docstring already spells out for a whole-document-per-row case.
191
+ */
192
+ async function readLines(file) {
193
+ let stat;
194
+ try { stat = fs.statSync(file); }
195
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
196
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
197
+
198
+ const lines = [];
199
+ let bytesRead = 0;
200
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
201
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
202
+
203
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
204
+
205
+ try {
206
+ for await (const line of rl) {
207
+ lines.push(line);
208
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
209
+ }
210
+ return { lines, status: "complete", bytesRead };
211
+ } catch {
212
+ // Whatever WAS read before the failure is real content and may contain a
213
+ // real secret — discarding it because the file didn't finish cleanly
214
+ // would be a silent false negative, which is worse than an honest
215
+ // "partial" label.
216
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
217
+ } finally {
218
+ clearTimeout(timer);
219
+ rl.close();
220
+ stream.destroy();
221
+ }
222
+ }
223
+
224
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { createInterface } = require("readline/promises");
5
+ const path = require("path");
6
+ const os = require("os");
7
+
8
+ /**
9
+ * OpenClaw (openclaw/openclaw, "your own personal AI assistant... the
10
+ * lobster way") session transcripts. OpenClaw is a multi-channel agent
11
+ * gateway (Discord/Slack/Telegram/iMessage/etc.) that can launch and drive
12
+ * other coding agents (Claude Code, Codex, OpenCode, Kiro CLI, Pi) as
13
+ * subprocesses — those tools' own transcripts are already covered by this
14
+ * project's other sources; this source covers OpenClaw's OWN session/chat
15
+ * logs (the conversation the user has with OpenClaw itself, across whatever
16
+ * channel it's reached from).
17
+ *
18
+ * VERIFICATION STATUS: NOT checked against a real install — no `~/.openclaw`
19
+ * (or any of the legacy names below) exists on the machine this adapter was
20
+ * built on. Ships per CONTRIBUTING.md rule 3 on two independent
21
+ * corroborating sources, WITH THE SAME INTEGRITY CAVEAT documented at length
22
+ * in hermes.js's module docstring — summarized here, read that file for the
23
+ * full reasoning: `gh api` showed openclaw/openclaw at 388,546 GitHub stars
24
+ * for a repo created 2025-11-24 (more stars in under a year than
25
+ * facebook/react has after 13 years, sanity-checked against real repos in
26
+ * the same research pass), and independent search turned up a real fake-
27
+ * GitHub-stars research paper, a Hacker News thread about a plagiarism
28
+ * dispute between Hermes Agent and OpenClaw specifically, and a GitHub topic
29
+ * literally describing "Two zero-human AI companies battle for GitHub stars
30
+ * using Hermes Agent + Paperclip." That doesn't make the format below wrong,
31
+ * but it does mean OpenClaw's own docs and Hermes' own docs may not be
32
+ * independent of each other (Hermes' docs describe importing OpenClaw's
33
+ * config directory directly) — treat this as
34
+ * multi-source-corroborated-but-unverified, not stronger, and see this
35
+ * adapter's PR description/report for the full writeup.
36
+ *
37
+ * The two sources:
38
+ *
39
+ * 1. OpenClaw's own docs (docs.openclaw.ai/cli, /gateway/configuration):
40
+ * default state directory `~/.openclaw`, overridable via
41
+ * `OPENCLAW_STATE_DIR`; `--profile <name>` isolates state under
42
+ * `~/.openclaw-<name>`; `--dev` uses `~/.openclaw-dev`.
43
+ * 2. ccusage (github.com/ccusage/ccusage — see hermes.js for why this
44
+ * project's own star count and code were trusted) ships a tested Rust
45
+ * adapter for OpenClaw (rust/adapters/openclaw/src/{paths,parser}.rs),
46
+ * fetched and read directly. It confirms: an `OPENCLAW_DIR` env var
47
+ * (comma-separated list — ccusage's own name, not confirmed identical
48
+ * to OPENCLAW_STATE_DIR above, so this source checks both rather than
49
+ * betting on one), and — absent either — four candidate home-relative
50
+ * directories: `~/.openclaw`, `~/.clawdbot`, `~/.moltbot`, `~/.moldbot`
51
+ * (evidently earlier names across OpenClaw's own rebrand history).
52
+ * Under whichever directory exists, it recursively collects files whose
53
+ * name contains `.jsonl` as an exact suffix, or as
54
+ * `.jsonl.deleted.<...>` / `.jsonl.reset.<...>` (OpenClaw's own naming
55
+ * for archived/rotated session files) — confirmed by that adapter's own
56
+ * unit test asserting exactly these three shapes. Each line is plain
57
+ * JSONL: a `{"type":"model_change",...}` or
58
+ * `{"type":"custom","customType":"model-snapshot",...}` tracking
59
+ * record, or a `{"type":"message","message":{"role":...,...}}` record —
60
+ * confirmed directly from that adapter's own parser and fixture data.
61
+ *
62
+ * One deliberate departure from ccusage's own behaviour: its directory walk
63
+ * explicitly `continue`s past any symlink it meets (`file_type.is_symlink()
64
+ * => continue`) — reasonable for a usage-stats tool, wrong for a security
65
+ * scanner. This source instead follows claude-code.js's own house
66
+ * convention: follow a symlink if it resolves, report it `broken: true` if
67
+ * it doesn't, never silently skip it either way (CONTRIBUTING.md rule 5).
68
+ */
69
+ const OPENCLAW_STATE_DIR_ENV = "OPENCLAW_STATE_DIR"; // OpenClaw's own official docs name
70
+ const OPENCLAW_DIR_ENV = "OPENCLAW_DIR"; // ccusage's name — kept as a second, unconfirmed alias
71
+ const LEGACY_HOME_NAMES = [".openclaw", ".clawdbot", ".moltbot", ".moldbot"];
72
+
73
+ function id() { return "openclaw"; }
74
+ function label() { return "OpenClaw"; }
75
+
76
+ function envRoots() {
77
+ for (const envVar of [OPENCLAW_STATE_DIR_ENV, OPENCLAW_DIR_ENV]) {
78
+ const val = process.env[envVar];
79
+ if (val && val.trim() !== "") {
80
+ return val.split(",").map((s) => s.trim()).filter((s) => s !== "").map((p) => path.resolve(p));
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Resolve every OpenClaw state root that actually exists on disk: either
88
+ * both env-var overrides (checked in envRoots()), or every legacy/renamed
89
+ * default directory under $HOME that's present, PLUS any `~/.openclaw-*`
90
+ * sibling (covers `--profile <name>` and `--dev`, per source 1 above —
91
+ * ccusage's own code doesn't do this, since it wasn't documented from
92
+ * ccusage's side, only OpenClaw's own docs).
93
+ */
94
+ function stateRoots() {
95
+ const env = envRoots();
96
+ if (env) return env.filter((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
97
+
98
+ const home = os.homedir();
99
+ const roots = [];
100
+ for (const name of LEGACY_HOME_NAMES) {
101
+ const dir = path.join(home, name);
102
+ try { if (fs.statSync(dir).isDirectory()) roots.push(dir); } catch { /* not present */ }
103
+ }
104
+
105
+ let homeEntries;
106
+ try { homeEntries = fs.readdirSync(home, { withFileTypes: true }); }
107
+ catch { homeEntries = []; }
108
+ for (const e of homeEntries) {
109
+ if (!e.name.startsWith(".openclaw-")) continue;
110
+ const dir = path.join(home, e.name);
111
+ if (roots.includes(dir)) continue;
112
+ let isDir = e.isDirectory();
113
+ if (!isDir && e.isSymbolicLink()) {
114
+ try { isDir = fs.statSync(dir).isDirectory(); } catch { isDir = false; }
115
+ }
116
+ if (isDir) roots.push(dir);
117
+ }
118
+ return roots;
119
+ }
120
+
121
+ function available() {
122
+ return stateRoots().length > 0;
123
+ }
124
+
125
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
126
+ if (checkFn(dirent)) return true;
127
+ if (!dirent.isSymbolicLink()) return false;
128
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
129
+ }
130
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
131
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
132
+
133
+ /**
134
+ * True for `*.jsonl`, `*.jsonl.deleted.<...>`, `*.jsonl.reset.<...>` —
135
+ * mirrors ccusage's own `is_openclaw_session_file` (including its own unit
136
+ * test's exact examples) byte for byte.
137
+ */
138
+ function isOpenClawSessionFile(name) {
139
+ const index = name.indexOf(".jsonl");
140
+ if (index === -1) return false;
141
+ const suffix = name.slice(index);
142
+ return suffix === ".jsonl" || suffix.startsWith(".jsonl.deleted.") || suffix.startsWith(".jsonl.reset.");
143
+ }
144
+
145
+ /**
146
+ * Recursively yield { file, mtimeMs, sizeBytes, broken } for every session
147
+ * file under one root. Depth-first, symlink-following-with-broken-reporting
148
+ * (see module docstring for why this deliberately differs from ccusage's
149
+ * own skip-on-symlink walk).
150
+ */
151
+ function* walk(dir) {
152
+ let entries;
153
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
154
+ catch { yield { file: dir, broken: true }; return; }
155
+
156
+ for (const e of entries) {
157
+ const full = path.join(dir, e.name);
158
+ if (e.isDirectory() || (e.isSymbolicLink() && isDirFollowingSymlink(full, e))) {
159
+ yield* walk(full);
160
+ continue;
161
+ }
162
+ if (!isOpenClawSessionFile(e.name)) continue;
163
+ if (!e.isFile()) {
164
+ const resolved = isFileFollowingSymlink(full, e);
165
+ if (!resolved) {
166
+ if (e.isSymbolicLink()) yield { file: full, broken: true };
167
+ continue;
168
+ }
169
+ }
170
+ let stat;
171
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
172
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
173
+ }
174
+ }
175
+
176
+ function* files() {
177
+ for (const root of stateRoots()) yield* walk(root);
178
+ }
179
+
180
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same untested-against-a-real-huge-file backstop
181
+ // claude-code.js uses, for the same stated reason.
182
+ const READ_TIMEOUT_MS = 60_000;
183
+
184
+ /**
185
+ * Read one session file as an array of raw text lines — identical
186
+ * streamed/bounded/timed-out shape to claude-code.js's own readLines(), for
187
+ * the same reasons documented there (JSONL, so this needs no JSON-array
188
+ * reconstruction the way codebuff.js's chat-messages.json does).
189
+ */
190
+ async function readLines(file) {
191
+ let stat;
192
+ try { stat = fs.statSync(file); }
193
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
194
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
195
+
196
+ const lines = [];
197
+ let bytesRead = 0;
198
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
199
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
200
+
201
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
202
+
203
+ try {
204
+ for await (const line of rl) {
205
+ lines.push(line);
206
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
207
+ }
208
+ return { lines, status: "complete", bytesRead };
209
+ } catch {
210
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
211
+ } finally {
212
+ clearTimeout(timer);
213
+ rl.close();
214
+ stream.destroy();
215
+ }
216
+ }
217
+
218
+ module.exports = { id, label, available, files, readLines };