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,379 @@
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
+ * OpenCode (https://opencode.ai — repo moved from github.com/sst/opencode to
10
+ * github.com/anomalyco/opencode after the SST team rebranded to Anomaly in
11
+ * 2026; same project, same maintainers, old sst/opencode Docker-image-style
12
+ * references now point at a stale location). Not to be confused with the
13
+ * unrelated, much smaller opencode-ai/opencode project or with GitHub
14
+ * Copilot's own internal "OpenCode" naming, if any — this adapter targets
15
+ * the 75+-provider terminal coding agent at opencode.ai specifically.
16
+ *
17
+ * VERIFICATION STATUS: NOT checked against a real OpenCode install — neither
18
+ * `opencode` nor any `~/.local/share/opencode` / `~/.config/opencode`
19
+ * directory exists on the machine this adapter was built on (checked:
20
+ * PATH, mdfind, common install locations). Ships anyway per CONTRIBUTING.md
21
+ * rule 3 on the strength of multiple independent, corroborating sources
22
+ * that agree with each other and, in two cases, are the project's own real
23
+ * source code and its own maintainers describing a real, live bug in it:
24
+ *
25
+ * - Official docs (opencode.ai/docs/troubleshooting/): data lives under
26
+ * `~/.local/share/opencode/` on macOS/Linux (this is the same on macOS
27
+ * as on Linux — OpenCode does NOT use `~/Library/Application Support`
28
+ * there, confirmed independently by several GitHub issues filed
29
+ * specifically complaining that it doesn't follow platform convention
30
+ * on macOS/Windows, e.g. #18633, #8235 — a real, if debated, choice, not
31
+ * an assumption made here) and an equivalent path under
32
+ * `%USERPROFILE%` on Windows.
33
+ * - The project's own real source, read directly: `session.sql.ts` at
34
+ * github.com/anomalyco/opencode confirms a Drizzle-ORM SQLite schema
35
+ * (`sqliteTable(...)`) for the `session` table, and multiple real
36
+ * GitHub issues on the same repo (#13202 "SQLite storage layer",
37
+ * #13654, #12889, #34445, #21941) all independently reference the same
38
+ * two-format reality this adapter is built around — see below.
39
+ * - GitHub issue #13654, describing a real, live bug: incremental
40
+ * upgraders can end up with the pre-SQLite storage format never
41
+ * migrated, leaving JSON session files "permanently orphaned" on disk;
42
+ * the issue explicitly quotes their real location as
43
+ * `~/.local/share/opencode/storage/session/*.json`. Issue #12889
44
+ * ("NotFoundError on startup: session JSON files not found in
45
+ * storage") independently corroborates the same `storage/` JSON layout
46
+ * from the other direction (a user hitting the gap this adapter reads
47
+ * around). A different official-docs excerpt additionally describes a
48
+ * project-scoped variant of the same idea — `<project-slug>/storage/`
49
+ * for a git repo, `global/storage/` otherwise — i.e. the exact nesting
50
+ * under `storage/` has moved across versions. Both shapes share one
51
+ * trait this adapter relies on instead of picking one to hardcode: the
52
+ * content-bearing files always sit under a directory literally named
53
+ * `storage`, at some depth under the OpenCode data root.
54
+ *
55
+ * Given that version drift (confirmed by the project's own migration code
56
+ * existing at all, and by the incremental-upgrade bug above meaning both
57
+ * formats really can coexist on one real machine), this source reads BOTH,
58
+ * the same call cursor.js makes for its own two-copy, moved-across-versions
59
+ * ambiguity:
60
+ * - `<DATA_DIR>/opencode.db` — current format, read via node:sqlite.
61
+ * - every `*.json` file under any directory literally named `storage`,
62
+ * anywhere under `<DATA_DIR>` — legacy/orphaned format.
63
+ *
64
+ * Deliberately NOT read: `<DATA_DIR>/auth.json` (OpenCode's own provider
65
+ * API key vault — the user's intentionally configured credentials, not a
66
+ * leaked one; scanning it would just re-report keys the user put there on
67
+ * purpose) or `<DATA_DIR>/opencode.json` / `log/` (config and application
68
+ * logs, not conversation content) — mirroring how claude-code.js and
69
+ * cursor.js each stay scoped to actual transcript data rather than a
70
+ * tool's entire state directory. Since none of these live under a
71
+ * `storage`-named directory, the walk below already excludes them
72
+ * structurally, not just by convention.
73
+ *
74
+ * The SQLite schema itself is read generically (see readDbFile() below)
75
+ * rather than hardcoding table/column names beyond `session` (the one
76
+ * table this research could confirm directly from source): the exact
77
+ * `message`/`part` table names and columns could not be confirmed against
78
+ * either a real install or the actual message.sql.ts/part.sql.ts source
79
+ * (GitHub's raw/blob endpoints were unreachable during this research), and
80
+ * cursor.js's own docstring already makes the case for why guessing at a
81
+ * key/column allowlist is the wrong tradeoff when the schema is confirmed
82
+ * to have moved before and code review can't check it against a real file.
83
+ * A generic per-table, per-column text scan can't go stale the way a
84
+ * specific column list can.
85
+ *
86
+ * If you have OpenCode installed, the most useful thing you can do is run
87
+ * `residoo scan` and confirm `sourcesScanned`/`filesScanned` look right for
88
+ * what you know is actually on disk under your OpenCode data directory,
89
+ * then report back either way — see CONTRIBUTING.md.
90
+ */
91
+ function opencodeDataDir() {
92
+ // XDG_DATA_HOME is honored for OpenCode's config dir per its own docs
93
+ // (opencode.ai/docs — XDG_CONFIG_HOME is explicitly documented there) and
94
+ // the project's data dir follows the same XDG-first convention (per the
95
+ // GitHub issues above discussing XDG_DATA_HOME for this exact directory).
96
+ // Same reasoning as cursor.js honoring XDG_CONFIG_HOME: the tool's own
97
+ // documented override, not a guess.
98
+ const home = os.homedir();
99
+ const dataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
100
+ return path.join(dataHome, "opencode");
101
+ }
102
+
103
+ const DATA_DIR = opencodeDataDir();
104
+ const DB_FILE = path.join(DATA_DIR, "opencode.db");
105
+
106
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same reasoning as claude-code.js/codex-cli.js
107
+ const READ_TIMEOUT_MS = 60_000;
108
+ const MAX_WALK_DEPTH = 12; // see codex-cli.js's identical constant for why this exists
109
+
110
+ // Same backstop-not-evidence caveat as cursor.js's MAX_DB_BYTES: no real
111
+ // opencode.db has been observed during this research to size this against.
112
+ const MAX_DB_BYTES = 512 * 1024 * 1024;
113
+ const BUSY_TIMEOUT_MS = 5_000;
114
+ const YIELD_EVERY_N_ROWS = 500;
115
+
116
+ function id() { return "opencode"; }
117
+ function label() { return "OpenCode"; }
118
+
119
+ function dataDirExists() {
120
+ try { return fs.statSync(DATA_DIR).isDirectory(); } catch { return false; }
121
+ }
122
+
123
+ /**
124
+ * Unlike cursor.js, availability here does NOT depend on node:sqlite being
125
+ * present: the legacy JSON-under-storage/ format (see module docstring) is
126
+ * plain text, readable with zero special modules, on any Node version this
127
+ * project supports. Gating the whole source on sqlite would silently drop
128
+ * real, scannable content for exactly the incremental-upgrade users GitHub
129
+ * issue #13654 describes — orphaned JSON sessions with no opencode.db ever
130
+ * written, or a Node runtime too old for node:sqlite. sqlite is required
131
+ * only for the opencode.db file specifically; see readDbFile()'s own
132
+ * "failed" return when it's unavailable, surfaced per-file rather than by
133
+ * hiding the whole source.
134
+ */
135
+ function available() {
136
+ return dataDirExists();
137
+ }
138
+
139
+ /**
140
+ * Lazily loaded exactly like cursor.js's getDatabaseSync() — see that
141
+ * file's docstring for the full reasoning (avoid the one-time
142
+ * ExperimentalWarning node:sqlite prints from being paid by every user on
143
+ * every invocation, since index.js requires every source unconditionally).
144
+ * Duplicated rather than shared, per this project's one-file-per-source
145
+ * convention.
146
+ */
147
+ let sqliteRequireAttempted = false;
148
+ let DatabaseSync = null;
149
+ function getDatabaseSync() {
150
+ if (!sqliteRequireAttempted) {
151
+ sqliteRequireAttempted = true;
152
+ try { ({ DatabaseSync } = require("node:sqlite")); }
153
+ catch { DatabaseSync = null; }
154
+ }
155
+ return DatabaseSync;
156
+ }
157
+
158
+ /** Same lstat-vs-stat symlink-following pattern duplicated across every source in this project. */
159
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
160
+ if (checkFn(dirent)) return true;
161
+ if (!dirent.isSymbolicLink()) return false;
162
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
163
+ }
164
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
165
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
166
+
167
+ /**
168
+ * Recursively walk `dir` looking for directories literally named `storage`;
169
+ * within each one found, recursively yield every `*.json` file underneath
170
+ * it (any depth) as a { file, mtimeMs, sizeBytes, broken } entry. A
171
+ * directory named anything else is only ever a waypoint down to a possible
172
+ * `storage` dir, never scanned itself — this is what keeps auth.json,
173
+ * opencode.json, and log/ out of scope structurally (see module docstring).
174
+ */
175
+ function* walkForStorageJson(dir, depth, insideStorage) {
176
+ if (depth > MAX_WALK_DEPTH) return;
177
+ let entries;
178
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
179
+ catch { return; }
180
+
181
+ for (const e of entries) {
182
+ const full = path.join(dir, e.name);
183
+
184
+ if (e.isDirectory()) {
185
+ yield* walkForStorageJson(full, depth + 1, insideStorage || e.name === "storage");
186
+ continue;
187
+ }
188
+ if (e.isFile()) {
189
+ if (insideStorage && e.name.endsWith(".json")) {
190
+ let stat;
191
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
192
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
193
+ }
194
+ continue; // a real file outside storage/, or not .json — out of scope, not broken
195
+ }
196
+ if (e.isSymbolicLink()) {
197
+ // Resolve once and treat as whichever kind it turns out to be — checked
198
+ // BEFORE any insideStorage gating, deliberately: a dangling symlink here
199
+ // might have been the very `storage` directory (or a path down to one)
200
+ // this walk exists to find, so it must be reported regardless of
201
+ // whether we've already stepped inside a `storage` dir by this point.
202
+ // An earlier version of this function checked `insideStorage` first,
203
+ // which let exactly this case (a dangling symlink encountered before
204
+ // entering storage/) fall through and vanish silently — the same class
205
+ // of bug claude-code.js's own docstring calls out and fixes.
206
+ if (isDirFollowingSymlink(full, e)) {
207
+ yield* walkForStorageJson(full, depth + 1, insideStorage || e.name === "storage");
208
+ continue;
209
+ }
210
+ if (isFileFollowingSymlink(full, e)) {
211
+ if (insideStorage && e.name.endsWith(".json")) {
212
+ try {
213
+ const stat = fs.statSync(full);
214
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
215
+ } catch { yield { file: full, broken: true }; }
216
+ }
217
+ continue;
218
+ }
219
+ yield { file: full, broken: true }; // dangling — resolves to neither a directory nor a file
220
+ continue;
221
+ }
222
+ // Neither directory, file, nor symlink (a device, socket, etc.) — genuinely out of scope.
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Yield { file, mtimeMs, sizeBytes, broken } for opencode.db (if present)
228
+ * and every legacy JSON session/message/part file under any `storage`
229
+ * directory anywhere below DATA_DIR. Purely a filesystem walk + stat, same
230
+ * division of labour as cursor.js's files() — never opens the database, so
231
+ * this works even without node:sqlite; only readLines() needs it.
232
+ */
233
+ function* files() {
234
+ let lst;
235
+ try { lst = fs.lstatSync(DB_FILE); }
236
+ catch { lst = null; }
237
+
238
+ if (lst) {
239
+ if (lst.isSymbolicLink()) {
240
+ try {
241
+ const st = fs.statSync(DB_FILE);
242
+ if (st.isFile()) yield { file: DB_FILE, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
243
+ else yield { file: DB_FILE, broken: true };
244
+ } catch {
245
+ yield { file: DB_FILE, broken: true };
246
+ }
247
+ } else if (lst.isFile()) {
248
+ yield { file: DB_FILE, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
249
+ }
250
+ }
251
+
252
+ yield* walkForStorageJson(DATA_DIR, 0, false);
253
+ }
254
+
255
+ /** Same string/Uint8Array decode as cursor.js's valueToText — see that file's docstring for why. */
256
+ function valueToText(value) {
257
+ if (typeof value === "string") return value;
258
+ if (value instanceof Uint8Array) return Buffer.from(value).toString("utf-8");
259
+ return null;
260
+ }
261
+
262
+ /**
263
+ * Read opencode.db generically: every user table in sqlite_master, every
264
+ * column of every row, each non-null string/blob value becoming one scanned
265
+ * line (see module docstring for why table/column names aren't hardcoded
266
+ * beyond knowing `session` exists). Row-by-row with a periodic yield-and-
267
+ * deadline-check exactly like cursor.js's readLines, for the same reason:
268
+ * node:sqlite's DatabaseSync is fully synchronous, so this is the only
269
+ * preemption point available without adding a dependency.
270
+ */
271
+ async function readDbFile(file) {
272
+ const DB = getDatabaseSync();
273
+ if (!DB) return { lines: [], status: "failed", bytesRead: 0 };
274
+
275
+ let stat;
276
+ try { stat = fs.statSync(file); }
277
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
278
+ if (stat.size > MAX_DB_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
279
+
280
+ let db;
281
+ try {
282
+ db = new DB(file, { readOnly: true });
283
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
284
+ } catch {
285
+ return { lines: [], status: "failed", bytesRead: 0 };
286
+ }
287
+
288
+ let tables;
289
+ try {
290
+ tables = db.prepare(
291
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_\\_drizzle%' ESCAPE '\\'"
292
+ ).all().map((r) => r.name);
293
+ } catch {
294
+ try { db.close(); } catch { /* best-effort */ }
295
+ return { lines: [], status: "failed", bytesRead: 0 };
296
+ }
297
+
298
+ const lines = [];
299
+ let bytesRead = 0;
300
+ const deadline = Date.now() + READ_TIMEOUT_MS;
301
+ let timedOut = false;
302
+ let sawError = false;
303
+
304
+ for (const table of tables) {
305
+ let rows;
306
+ try {
307
+ // Table names come from sqlite_master itself, not external input, but
308
+ // they're still quoted defensively rather than trusted to be
309
+ // bare-word-safe.
310
+ rows = db.prepare(`SELECT * FROM "${table.replace(/"/g, '""')}"`).iterate();
311
+ } catch {
312
+ continue; // this table vanished or is a view/virtual table that doesn't support this — move on
313
+ }
314
+
315
+ let n = 0;
316
+ try {
317
+ for (const row of rows) {
318
+ for (const key in row) {
319
+ const text = valueToText(row[key]);
320
+ if (text) { lines.push(text); bytesRead += Buffer.byteLength(text, "utf-8"); }
321
+ }
322
+ n++;
323
+ if (n % YIELD_EVERY_N_ROWS === 0) {
324
+ await new Promise((resolve) => setImmediate(resolve));
325
+ if (Date.now() > deadline) { timedOut = true; break; }
326
+ }
327
+ }
328
+ } catch {
329
+ sawError = true;
330
+ }
331
+ if (timedOut) break;
332
+ }
333
+
334
+ try { db.close(); } catch { /* best-effort */ }
335
+
336
+ if (sawError && lines.length === 0) return { lines: [], status: "failed", bytesRead };
337
+ if (timedOut || sawError) return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
338
+ return { lines, status: "complete", bytesRead };
339
+ }
340
+
341
+ /**
342
+ * Read one legacy JSON session/message/part file as raw text lines —
343
+ * identical streaming strategy to claude-code.js's readLines (a JSON file
344
+ * on disk is still line-delimited text; the pattern-matcher works on raw
345
+ * text regardless of what it parses as). Duplicated rather than shared,
346
+ * per this project's convention.
347
+ */
348
+ async function readJsonFile(file) {
349
+ let stat;
350
+ try { stat = fs.statSync(file); }
351
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
352
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
353
+
354
+ const lines = [];
355
+ let bytesRead = 0;
356
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
357
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
358
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
359
+
360
+ try {
361
+ for await (const line of rl) {
362
+ lines.push(line);
363
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
364
+ }
365
+ return { lines, status: "complete", bytesRead };
366
+ } catch {
367
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
368
+ } finally {
369
+ clearTimeout(timer);
370
+ rl.close();
371
+ stream.destroy();
372
+ }
373
+ }
374
+
375
+ async function readLines(file) {
376
+ return file === DB_FILE ? readDbFile(file) : readJsonFile(file);
377
+ }
378
+
379
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,181 @@
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
+ * OpenHands (github.com/OpenHands/OpenHands, 85,900+ stars) local conversation
10
+ * storage.
11
+ *
12
+ * VERIFICATION STATUS: corroborated by reading the actual, current source code
13
+ * of two real repositories in the OpenHands org (fetched directly from GitHub
14
+ * during this source's research, base64-decoded, not inferred from a blog
15
+ * post) — NOT verified against a real OpenHands install on the machine this
16
+ * source was built on (OpenHands was not installed there; see CONTRIBUTING.md).
17
+ *
18
+ * 1. github.com/OpenHands/OpenHands-CLI — `openhands_cli/locations.py`
19
+ * defines `get_persistence_dir()` as `$OPENHANDS_PERSISTENCE_DIR` or
20
+ * `~/.openhands`, and `get_conversations_dir()` as
21
+ * `<persistence_dir>/conversations`. `openhands_cli/conversations/
22
+ * store/local.py`'s `LocalFileStore` (the CLI's on-disk conversation
23
+ * store) confirms the concrete layout used to build ROOT below:
24
+ * `<conversations_dir>/<conversation_id>/events/event-*.json`, one JSON
25
+ * object per file, plus a `metadata.json`-style summary this source does
26
+ * NOT rely on (it derives everything from the event files themselves).
27
+ * NOTE: as of this research, OpenHands-CLI's own README marks the
28
+ * project "no longer actively maintained," pointing users at "Agent
29
+ * Canvas" (the browser-based control surface in the main OpenHands/
30
+ * OpenHands repo) instead.
31
+ * 2. github.com/OpenHands/software-agent-sdk (1,000+ stars, actively
32
+ * maintained — this is the SDK OpenHands-CLI itself is "Powered by," and
33
+ * the same SDK Agent Canvas's agent-server is built on) confirms the
34
+ * event-file naming convention still current today:
35
+ * `openhands-sdk/openhands/sdk/conversation/persistence_const.py`
36
+ * defines `EVENTS_DIR = "events"` and
37
+ * `EVENT_FILE_PATTERN = "event-{idx:05d}-{event_id}.json"`. A shipped
38
+ * example, `examples/01_standalone_sdk/55_persistent_memory.py`, also
39
+ * independently confirms `~/.openhands/` (home-anchored, not
40
+ * workspace-anchored) as the SDK's own convention for user-tier
41
+ * persistent state, matching the CLI's `get_persistence_dir()`.
42
+ *
43
+ * What this source does NOT claim: that `~/.openhands/conversations` is
44
+ * necessarily where the currently-recommended "Agent Canvas" product (which
45
+ * can also run inside Docker, where its FILE_STORE_PATH may point inside the
46
+ * container rather than the host) writes when run that way. What IS shared
47
+ * and confirmed across every variant researched is the `~/.openhands` home
48
+ * directory as OpenHands' persistence root, and the events/event-*.json shape
49
+ * for conversation storage. Scanning that root, if present on this machine,
50
+ * degrades gracefully (files() below simply finds nothing extra) rather than
51
+ * scanning the wrong place, if some other run mode used a different root
52
+ * entirely (e.g. purely inside an ephemeral Docker container never mounted to
53
+ * the host).
54
+ *
55
+ * Each event-*.json file is a single JSON object (see the pattern above), not
56
+ * JSONL — but scan.js only wants raw text lines, and a JSON file (minified or
57
+ * pretty-printed) is exactly that, so this source reuses claude-code.js's
58
+ * plain line-streaming readLines() unmodified in spirit.
59
+ */
60
+ const HOME = os.homedir();
61
+ const PERSISTENCE_DIR = path.join(HOME, ".openhands");
62
+ const CONVERSATIONS_DIR = path.join(PERSISTENCE_DIR, "conversations");
63
+
64
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same generous backstop as claude-code.js;
65
+ // real event files are tiny (one event each), this
66
+ // is a pathological-file guard, not a tuned limit.
67
+ const READ_TIMEOUT_MS = 60_000;
68
+
69
+ function id() { return "openhands"; }
70
+ function label() { return "OpenHands"; }
71
+
72
+ function available() {
73
+ try { return fs.statSync(PERSISTENCE_DIR).isDirectory(); } catch { return false; }
74
+ }
75
+
76
+ /**
77
+ * Same defensive symlink-following helpers as claude-code.js — see that
78
+ * file's docstring for the full reasoning. Duplicated rather than imported:
79
+ * every source in this project is meant to be a small, self-contained file a
80
+ * reviewer can audit on its own (see cursor.js's docstring for the same
81
+ * design note).
82
+ */
83
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
84
+ if (checkFn(dirent)) return true;
85
+ if (!dirent.isSymbolicLink()) return false;
86
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
87
+ }
88
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
89
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
90
+
91
+ /**
92
+ * Yield entries for every plain file directly inside `dir` whose name passes
93
+ * `matchFn`, following symlinks the same way claude-code.js's files() does,
94
+ * and reporting a symlink that resolves to neither a file nor a directory as
95
+ * `broken: true` rather than silently skipping it. Not recursive — every
96
+ * caller here already knows the exact directory it wants to list.
97
+ */
98
+ function* filesInDir(dir, matchFn) {
99
+ let entries;
100
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
101
+ catch { return; } // directory doesn't exist (e.g. no events/ yet) — normal, not broken
102
+ for (const e of entries) {
103
+ const full = path.join(dir, e.name);
104
+ if (isDirFollowingSymlink(full, e)) continue; // a directory where a file was expected — out of scope
105
+ const isFile = isFileFollowingSymlink(full, e);
106
+ if (!isFile) {
107
+ if (e.isSymbolicLink()) yield { file: full, broken: true };
108
+ continue;
109
+ }
110
+ if (!matchFn(e.name)) continue;
111
+ let stat;
112
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
113
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Yield { file, mtimeMs, sizeBytes, broken } for every conversation's
119
+ * metadata.json and event-*.json files under ~/.openhands/conversations.
120
+ *
121
+ * Structure (confirmed by source, see docstring above):
122
+ * conversations/<conversation_id>/metadata.json (optional, small)
123
+ * conversations/<conversation_id>/events/event-*.json (one per event)
124
+ */
125
+ function* files() {
126
+ let conversationDirs;
127
+ try { conversationDirs = fs.readdirSync(CONVERSATIONS_DIR, { withFileTypes: true }); }
128
+ catch { return; }
129
+
130
+ for (const conv of conversationDirs) {
131
+ const convDir = path.join(CONVERSATIONS_DIR, conv.name);
132
+ const isDir = isDirFollowingSymlink(convDir, conv);
133
+ if (!isDir) {
134
+ if (conv.isSymbolicLink()) yield { file: convDir, broken: true };
135
+ continue;
136
+ }
137
+
138
+ yield* filesInDir(convDir, (name) => name === "metadata.json");
139
+
140
+ const eventsDir = path.join(convDir, "events");
141
+ yield* filesInDir(eventsDir, (name) => name.startsWith("event-") && name.endsWith(".json"));
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Read one file (a single JSON object, metadata or event) as raw text lines.
147
+ * Identical streaming/timeout/partial-read discipline to claude-code.js's
148
+ * readLines() — see that file's docstring for the reasoning; not repeated
149
+ * here since these files are individually tiny and the 818MB failure mode
150
+ * that motivated streaming there doesn't apply, but there is no reason to be
151
+ * less careful about a hung read or a mid-read failure just because the
152
+ * common case is small.
153
+ */
154
+ async function readLines(file) {
155
+ let stat;
156
+ try { stat = fs.statSync(file); }
157
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
158
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
159
+
160
+ const lines = [];
161
+ let bytesRead = 0;
162
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
163
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
164
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
165
+
166
+ try {
167
+ for await (const line of rl) {
168
+ lines.push(line);
169
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
170
+ }
171
+ return { lines, status: "complete", bytesRead };
172
+ } catch {
173
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
174
+ } finally {
175
+ clearTimeout(timer);
176
+ rl.close();
177
+ stream.destroy();
178
+ }
179
+ }
180
+
181
+ module.exports = { id, label, available, files, readLines };