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.
- package/README.md +334 -46
- package/SECURITY.md +29 -22
- package/package.json +1 -1
- package/src/cli.js +249 -17
- package/src/integrity.js +689 -0
- package/src/patterns.js +78 -5
- package/src/report.js +188 -8
- package/src/rotation.js +834 -0
- package/src/sources/agent-configs.js +308 -0
- package/src/sources/aider.js +361 -0
- package/src/sources/amazon-q.js +199 -0
- package/src/sources/antigravity-cli.js +155 -0
- package/src/sources/cline.js +208 -0
- package/src/sources/codebuff.js +295 -0
- package/src/sources/codex-cli.js +258 -0
- package/src/sources/cody.js +325 -0
- package/src/sources/continue.js +408 -0
- package/src/sources/copilot-chat.js +272 -0
- package/src/sources/copilot-cli.js +300 -0
- package/src/sources/crush.js +364 -0
- package/src/sources/cursor.js +374 -0
- package/src/sources/devin-cli.js +241 -0
- package/src/sources/factory-droid.js +153 -0
- package/src/sources/fx.js +136 -0
- package/src/sources/gemini-cli.js +242 -0
- package/src/sources/goose.js +366 -0
- package/src/sources/grok-cli.js +267 -0
- package/src/sources/hermes.js +282 -0
- package/src/sources/index.js +172 -8
- package/src/sources/jetbrains-ai-assistant.js +343 -0
- package/src/sources/jetbrains-junie.js +292 -0
- package/src/sources/kilo-code.js +430 -0
- package/src/sources/kimi-code.js +147 -0
- package/src/sources/kiro-cli.js +393 -0
- package/src/sources/kiro-ide.js +230 -0
- package/src/sources/llm.js +328 -0
- package/src/sources/mentat.js +143 -0
- package/src/sources/open-interpreter.js +224 -0
- package/src/sources/openclaw.js +218 -0
- package/src/sources/opencode.js +379 -0
- package/src/sources/openhands.js +181 -0
- package/src/sources/pearai.js +151 -0
- package/src/sources/pi-agent.js +130 -0
- package/src/sources/project-artifacts.js +355 -0
- package/src/sources/qodo-gen.js +189 -0
- package/src/sources/qwen-code.js +244 -0
- package/src/sources/roo-code.js +239 -0
- package/src/sources/trae.js +294 -0
- package/src/sources/void.js +273 -0
- package/src/sources/warp.js +395 -0
- package/src/sources/windsurf.js +256 -0
- package/src/sources/zed.js +374 -0
|
@@ -0,0 +1,308 @@
|
|
|
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
|
+
* Agent CONFIG and STATE files — the first source in this project that is
|
|
10
|
+
* not a transcript store. Configs earned their own source because they are
|
|
11
|
+
* the best-MEASURED plaintext secret sink in the 2026 evidence base:
|
|
12
|
+
* GitGuardian counted 24,008 secrets inside MCP config files on public
|
|
13
|
+
* GitHub (2,117 still valid); Lakera found live credentials inside
|
|
14
|
+
* `.claude/settings.local.json` files shipped in ~30 npm packages because
|
|
15
|
+
* Claude Code's approved-command cache accumulates tokens and no packaging
|
|
16
|
+
* tool ignores `.claude/` by default; and the year's supply-chain campaigns
|
|
17
|
+
* (Mini Shai-Hulud, Miasma, ChainDrop) both PLANT persistence in and STEAL
|
|
18
|
+
* from exactly these files. Published stealer target lists (JFrog's
|
|
19
|
+
* Bitwarden-CLI-hijack write-up, StepSecurity's Nx Console analysis, the
|
|
20
|
+
* keyv/Shai-Hulud reports) name several of the paths below verbatim.
|
|
21
|
+
*
|
|
22
|
+
* SCOPE — home-level only, and that limitation is real, not rhetorical:
|
|
23
|
+
* project-level configs (`.mcp.json`, `.claude/settings.json`,
|
|
24
|
+
* `.cursor/rules/`, `.vscode/tasks.json`, per-repo CLAUDE.md/AGENTS.md —
|
|
25
|
+
* the files Miasma actually planted in cloned repos) live inside arbitrary
|
|
26
|
+
* repositories this tool has no way to enumerate from a home directory.
|
|
27
|
+
* A clean report from this source therefore says nothing about any
|
|
28
|
+
* project's own config files. v1 ships the home-level set because those
|
|
29
|
+
* paths are fixed and verifiable; the project-level gap is stated here
|
|
30
|
+
* rather than papered over.
|
|
31
|
+
*
|
|
32
|
+
* PER-PATH VERIFICATION (per CONTRIBUTING.md's no-guessed-paths rule —
|
|
33
|
+
* "real install" below means the populated machine this source was built
|
|
34
|
+
* on, checked read-only; "digest" means the 2026-09-02 research digest's
|
|
35
|
+
* stealer target lists and campaign write-ups, which name exact paths):
|
|
36
|
+
*
|
|
37
|
+
* - `~/.claude.json` — real install (present, live content) + named
|
|
38
|
+
* verbatim in JFrog's Bitwarden-CLI-hijack target list + Claude Code's
|
|
39
|
+
* own MCP docs (user-scoped MCP servers, env blocks included, are
|
|
40
|
+
* stored here).
|
|
41
|
+
* - `~/.claude.json.backup` — real install (present; Claude Code's own
|
|
42
|
+
* rewrite backup of the file above — same content, same secrets, and a
|
|
43
|
+
* scanner that reads the original but not its sibling copy would
|
|
44
|
+
* under-report).
|
|
45
|
+
* - `~/.claude/settings.json` — real install + the file Mini Shai-Hulud
|
|
46
|
+
* and Miasma planted `SessionStart` hooks into + StepSecurity's Nx
|
|
47
|
+
* Console write-up names it as a harvest target.
|
|
48
|
+
* - `~/.claude/settings.local.json` — real install + Lakera's ~30
|
|
49
|
+
* leaking npm packages are this exact filename. This file is the
|
|
50
|
+
* reason a config source exists at all: it is NOT supposed to hold
|
|
51
|
+
* secrets, and measurably does.
|
|
52
|
+
* - `~/.claude/mcp.json` — the one deliberate exception to the rule that
|
|
53
|
+
* a path must be vendor-documented or locally present: it is NEITHER
|
|
54
|
+
* (Claude Code stores user-scope MCP config inside `~/.claude.json`,
|
|
55
|
+
* and it does not exist on the real install verified against). It is
|
|
56
|
+
* included anyway because published stealer target lists hunt this
|
|
57
|
+
* exact name (JFrog's Bitwarden-CLI list: `~/.claude.json`,
|
|
58
|
+
* `.claude/mcp.json`, `~/.kiro/settings/mcp.json`) — where the file
|
|
59
|
+
* does exist (hand-written, third-party tooling, older forks), it is
|
|
60
|
+
* precisely what an attacker grabs, and when absent it yields nothing
|
|
61
|
+
* and costs one lstat.
|
|
62
|
+
* - `~/.claude/CLAUDE.md` — Claude Code's own memory docs (user memory
|
|
63
|
+
* file) + the TrapDoor campaign hid zero-width-Unicode exfiltration
|
|
64
|
+
* instructions in CLAUDE.md files + the digest's stealer roadmap names
|
|
65
|
+
* "memory files (MEMORY.md/CLAUDE.md)". Absent on the real install
|
|
66
|
+
* (the `~/.claude` root is present); scanned when it exists because
|
|
67
|
+
* memory files are where users paste the things they want remembered.
|
|
68
|
+
* - Claude Desktop `claude_desktop_config.json` — macOS
|
|
69
|
+
* `~/Library/Application Support/Claude/`: real install (present) +
|
|
70
|
+
* the official MCP docs (modelcontextprotocol.io, "Connect to local
|
|
71
|
+
* MCP servers") document it per-OS. Windows `%APPDATA%\Claude\`: same
|
|
72
|
+
* official MCP docs + multiple independent setup guides agree. Linux
|
|
73
|
+
* is deliberately NOT covered: there is no official Linux build, and
|
|
74
|
+
* the unofficial ports disagree with each other on the config location
|
|
75
|
+
* (`~/.config/Claude/` vs `~/.config/claude-desktop/`) — either pick
|
|
76
|
+
* would be a guessed path.
|
|
77
|
+
* - `~/.cursor/mcp.json` — Cursor's official MCP docs (the global,
|
|
78
|
+
* all-projects config; distinct from the per-profile storage
|
|
79
|
+
* cursor.js reads) + independent Snyk/liblab/TrueFoundry guides + the
|
|
80
|
+
* digest's ~/.cursor deep-dive. Not installed on the build machine.
|
|
81
|
+
* - `~/.gemini/settings.json` — Gemini CLI's official settings docs
|
|
82
|
+
* (user settings file) + Miasma planted `.gemini/settings.json` (the
|
|
83
|
+
* digest names the filename verbatim). Root resolution honors
|
|
84
|
+
* GEMINI_CLI_HOME exactly as gemini-cli.js does — that override was
|
|
85
|
+
* verified from the project's own source during that adapter's
|
|
86
|
+
* research, not guessed here.
|
|
87
|
+
* - `~/.codex/config.toml` — OpenAI's official docs (CODEX_HOME "sets
|
|
88
|
+
* the root directory for Codex state, including config...", and the
|
|
89
|
+
* Codex MCP docs document `mcp_servers` sections in config.toml with
|
|
90
|
+
* `env` tables — the documented way to hand an MCP server an API key)
|
|
91
|
+
* + multiple independent setup guides showing exactly that. Root
|
|
92
|
+
* resolution honors CODEX_HOME exactly as codex-cli.js does.
|
|
93
|
+
* - `~/.kiro/settings/mcp.json` — Kiro's official MCP configuration docs
|
|
94
|
+
* (global config; their own security page recommends `chmod 600` on
|
|
95
|
+
* it, a vendor admission it holds secrets) + named verbatim in JFrog's
|
|
96
|
+
* Bitwarden-CLI target list.
|
|
97
|
+
*
|
|
98
|
+
* DELIBERATELY NOT READ, and why:
|
|
99
|
+
* - `~/.claude/projects/**` — claude-code.js's territory. Overlapping it
|
|
100
|
+
* would double-report every finding. (Named side effect: a
|
|
101
|
+
* `projects/<slug>/memory/MEMORY.md` is covered by NEITHER source
|
|
102
|
+
* today — a real gap that belongs to the transcript source's scope
|
|
103
|
+
* discussion, recorded here so it isn't mistaken for covered.)
|
|
104
|
+
* - `~/.claude/history.jsonl`, paste-cache, file-history, session-env —
|
|
105
|
+
* transcript-adjacent conversation state, not configuration; adding
|
|
106
|
+
* them belongs in a transcript source where dedup against session
|
|
107
|
+
* files can be reasoned about.
|
|
108
|
+
* - `~/.codex/auth.json`, `~/.gemini/oauth_creds.json`, `~/.gemini/.env`,
|
|
109
|
+
* `~/.claude/.credentials.json` — credential VAULTS: files whose whole
|
|
110
|
+
* documented job is holding the user's own keys/tokens, following the
|
|
111
|
+
* precedent opencode.js set for its auth.json. Flagging those re-reports
|
|
112
|
+
* what the user put there on purpose. The line drawn: a file that holds
|
|
113
|
+
* secrets BY DESIGN is excluded; a file that accumulates secrets by
|
|
114
|
+
* accident (settings.local.json's approved-command cache — Lakera's
|
|
115
|
+
* finding) is exactly what this source is for.
|
|
116
|
+
* - `~/.gemini/GEMINI.md`, `~/.codex/AGENTS.md` — vendor-documented
|
|
117
|
+
* memory files, but the research digest never names either exact
|
|
118
|
+
* home-level path, leaving them one source short of this project's
|
|
119
|
+
* verification bar. Add-with-citation candidates, not omissions by
|
|
120
|
+
* oversight.
|
|
121
|
+
* - Windsurf/OpenClaw/other "equivalents" — the digest gestures at them
|
|
122
|
+
* without naming an exact home-level path; no path, no scan.
|
|
123
|
+
*
|
|
124
|
+
* If any of these tools is installed on your machine, the most useful
|
|
125
|
+
* thing you can do is run `residoo scan` and confirm the per-source file
|
|
126
|
+
* counts match what you know is on disk, then report back either way —
|
|
127
|
+
* see CONTRIBUTING.md.
|
|
128
|
+
*/
|
|
129
|
+
function claudeDesktopConfig() {
|
|
130
|
+
const home = os.homedir();
|
|
131
|
+
if (process.platform === "darwin") {
|
|
132
|
+
return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
133
|
+
}
|
|
134
|
+
if (process.platform === "win32") {
|
|
135
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
136
|
+
return path.join(appData, "Claude", "claude_desktop_config.json");
|
|
137
|
+
}
|
|
138
|
+
return null; // Linux: no official build, unofficial ports disagree — see header
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function geminiDir() {
|
|
142
|
+
// GEMINI_CLI_HOME is the tool's own documented override (the CLI creates
|
|
143
|
+
// a `.gemini` folder INSIDE it) — same resolution gemini-cli.js verified
|
|
144
|
+
// from the project's source, duplicated per the one-file-per-source rule.
|
|
145
|
+
if (process.env.GEMINI_CLI_HOME) return path.join(process.env.GEMINI_CLI_HOME, ".gemini");
|
|
146
|
+
return path.join(os.homedir(), ".gemini");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function codexHome() {
|
|
150
|
+
// CODEX_HOME per official docs covers "config", not just sessions —
|
|
151
|
+
// same resolution codex-cli.js uses.
|
|
152
|
+
if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
|
|
153
|
+
return path.join(os.homedir(), ".codex");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const HOME = os.homedir();
|
|
157
|
+
const CLAUDE_DIR = path.join(HOME, ".claude");
|
|
158
|
+
const DESKTOP_CONFIG = claudeDesktopConfig();
|
|
159
|
+
const GEMINI_DIR = geminiDir();
|
|
160
|
+
const CODEX_HOME = codexHome();
|
|
161
|
+
const CURSOR_DIR = path.join(HOME, ".cursor");
|
|
162
|
+
const KIRO_DIR = path.join(HOME, ".kiro");
|
|
163
|
+
|
|
164
|
+
// Every candidate is a single fixed file path (see header for what verified
|
|
165
|
+
// each). Absence is normal and yields nothing — most machines have a few of
|
|
166
|
+
// these tools at most; only a path that LOOKS present but can't be resolved
|
|
167
|
+
// is reported broken.
|
|
168
|
+
const CANDIDATES = [
|
|
169
|
+
path.join(HOME, ".claude.json"),
|
|
170
|
+
path.join(HOME, ".claude.json.backup"),
|
|
171
|
+
path.join(CLAUDE_DIR, "settings.json"),
|
|
172
|
+
path.join(CLAUDE_DIR, "settings.local.json"),
|
|
173
|
+
path.join(CLAUDE_DIR, "mcp.json"),
|
|
174
|
+
path.join(CLAUDE_DIR, "CLAUDE.md"),
|
|
175
|
+
...(DESKTOP_CONFIG ? [DESKTOP_CONFIG] : []),
|
|
176
|
+
path.join(CURSOR_DIR, "mcp.json"),
|
|
177
|
+
path.join(GEMINI_DIR, "settings.json"),
|
|
178
|
+
path.join(CODEX_HOME, "config.toml"),
|
|
179
|
+
path.join(KIRO_DIR, "settings", "mcp.json"),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
// Configs are KB-scale in every real observation this source's research
|
|
183
|
+
// produced (the largest, a live ~/.claude.json accumulating per-project
|
|
184
|
+
// state, was tens of KB; community bloat reports for that file reach tens
|
|
185
|
+
// of MB). 64MB is a corrupted-or-pathological-file backstop, not a bound
|
|
186
|
+
// derived from a real file — same caveat cursor.js states for MAX_DB_BYTES.
|
|
187
|
+
// A file over it is surfaced as "too-large", never silently skipped.
|
|
188
|
+
const MAX_BYTES = 64 * 1024 * 1024;
|
|
189
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
190
|
+
|
|
191
|
+
function id() { return "agent-configs"; }
|
|
192
|
+
function label() { return "Agent config files"; }
|
|
193
|
+
|
|
194
|
+
function dirExists(p) {
|
|
195
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
196
|
+
}
|
|
197
|
+
function fileExists(p) {
|
|
198
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Available when any of the config ROOTS exists — not just when a candidate
|
|
203
|
+
* file does. A machine with an empty `~/.cursor` should still show this
|
|
204
|
+
* source as checked (finding nothing is a result), while a machine with
|
|
205
|
+
* none of these tools shouldn't list it at all.
|
|
206
|
+
*/
|
|
207
|
+
function available() {
|
|
208
|
+
return (
|
|
209
|
+
fileExists(path.join(HOME, ".claude.json")) ||
|
|
210
|
+
dirExists(CLAUDE_DIR) ||
|
|
211
|
+
(DESKTOP_CONFIG !== null && dirExists(path.dirname(DESKTOP_CONFIG))) ||
|
|
212
|
+
dirExists(CURSOR_DIR) ||
|
|
213
|
+
dirExists(GEMINI_DIR) ||
|
|
214
|
+
dirExists(CODEX_HOME) ||
|
|
215
|
+
dirExists(KIRO_DIR)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve one fixed candidate path into zero or one files() entries — the
|
|
221
|
+
* same lstat-then-follow shape as cursor.js's statIfPresent, duplicated per
|
|
222
|
+
* the one-file-per-source convention. These paths are constructed, not
|
|
223
|
+
* discovered by a directory listing, so there is no Dirent to reuse:
|
|
224
|
+
* absence yields nothing (normal — see CANDIDATES), a dangling symlink
|
|
225
|
+
* yields broken (a dotfiles manager symlinking `~/.claude/settings.json`
|
|
226
|
+
* at a moved target is the realistic case, and silently skipping it is the
|
|
227
|
+
* exact bug claude-code.js's files() docstring exists to prevent), and
|
|
228
|
+
* something that is neither file nor symlink at the path is out of scope.
|
|
229
|
+
*/
|
|
230
|
+
function* statIfPresent(p) {
|
|
231
|
+
let lst;
|
|
232
|
+
try { lst = fs.lstatSync(p); }
|
|
233
|
+
catch (err) {
|
|
234
|
+
// ENOENT/ENOTDIR is the normal not-installed case and yields nothing.
|
|
235
|
+
// Any other lstat failure (EACCES on `~/.claude` itself, ELOOP) means a
|
|
236
|
+
// candidate may exist but can't be examined — that's a broken entry,
|
|
237
|
+
// not absence: available() can still say true for the root, and a
|
|
238
|
+
// silently empty files() would be the exact silent-exclusion bug the
|
|
239
|
+
// yield-broken convention exists to prevent.
|
|
240
|
+
if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return;
|
|
241
|
+
yield { file: p, broken: true };
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (lst.isSymbolicLink()) {
|
|
246
|
+
try {
|
|
247
|
+
const st = fs.statSync(p); // follow the link
|
|
248
|
+
if (!st.isFile()) { yield { file: p, broken: true }; return; }
|
|
249
|
+
yield { file: p, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
|
|
250
|
+
} catch {
|
|
251
|
+
yield { file: p, broken: true }; // dangling symlink
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!lst.isFile()) return;
|
|
257
|
+
yield { file: p, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Yield { file, mtimeMs, sizeBytes, broken } for every candidate present. */
|
|
261
|
+
function* files() {
|
|
262
|
+
for (const p of CANDIDATES) yield* statIfPresent(p);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Read one config file as raw text lines — the same streaming reader as
|
|
267
|
+
* claude-code.js's readLines (see that docstring for the timeout rationale:
|
|
268
|
+
* a symlink retargeted between stat and open can block open() forever, and
|
|
269
|
+
* destroying the stream is the only way out). Configs are JSON, TOML, or
|
|
270
|
+
* Markdown rather than JSONL, which changes nothing for the caller: scan.js
|
|
271
|
+
* matches raw text lines, and a token inside a pretty-printed `"env"` block
|
|
272
|
+
* or a TOML `env` table sits on its own line just like a JSONL record does.
|
|
273
|
+
* A single-line minified JSON config arrives as one long line — still
|
|
274
|
+
* within the streaming reader's per-line limits at this source's size cap.
|
|
275
|
+
*
|
|
276
|
+
* Same status contract as every source: "complete", "partial" (some lines
|
|
277
|
+
* were read before a failure — scanned, and flagged), "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 lines = [];
|
|
286
|
+
let bytesRead = 0;
|
|
287
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
288
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
289
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
for await (const line of rl) {
|
|
293
|
+
lines.push(line);
|
|
294
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
|
|
295
|
+
}
|
|
296
|
+
return { lines, status: "complete", bytesRead };
|
|
297
|
+
} catch {
|
|
298
|
+
// Lines read before the failure are real content and may hold a real
|
|
299
|
+
// secret — an honest "partial" beats a silent false negative.
|
|
300
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
301
|
+
} finally {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
rl.close();
|
|
304
|
+
stream.destroy();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = { id, label, available, files, readLines };
|
|
@@ -0,0 +1,361 @@
|
|
|
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
|
+
* Aider (github.com/Aider-AI/aider) session transcripts.
|
|
10
|
+
*
|
|
11
|
+
* VERIFICATION STATUS (read this before trusting anything below): the file
|
|
12
|
+
* names, on-disk format, and location logic below are corroborated across
|
|
13
|
+
* FOUR independent, current sources —
|
|
14
|
+
*
|
|
15
|
+
* 1. Aider's own official docs (aider.chat/docs/config/options.html),
|
|
16
|
+
* which document `--chat-history-file` (default `.aider.chat.history.md`)
|
|
17
|
+
* and `--input-history-file` (default `.aider.input.history`).
|
|
18
|
+
* 2. Aider's own current GitHub source (Aider-AI/aider, `aider/args.py`,
|
|
19
|
+
* main branch — fetched directly, not from a cache or a summary):
|
|
20
|
+
* default_input_history_file = os.path.join(git_root, ".aider.input.history")
|
|
21
|
+
* if git_root else ".aider.input.history"
|
|
22
|
+
* default_chat_history_file = os.path.join(git_root, ".aider.chat.history.md")
|
|
23
|
+
* if git_root else ".aider.chat.history.md"
|
|
24
|
+
* i.e. these are NOT under one fixed root the way Claude Code's or
|
|
25
|
+
* Cursor's storage is — they land at the root of whatever git repo the
|
|
26
|
+
* user ran `aider` inside (or the bare CWD if that wasn't a git repo).
|
|
27
|
+
* A real user independently ran into exactly this scattering, filing
|
|
28
|
+
* Aider-AI/aider#2684 ("history files accumulate ... outside git
|
|
29
|
+
* repos"), which corroborates the CWD/git-root behaviour from the
|
|
30
|
+
* outside, not just from reading the source.
|
|
31
|
+
* 3. A REAL, live `.aider.chat.history.md` from an actual aider user, who
|
|
32
|
+
* committed it to their own public repo (github.com/dfeldman/
|
|
33
|
+
* operation-conundrum.github.io, file `aider-chat-history.md`, 2628
|
|
34
|
+
* lines, dated 2023-05-26 in-content). Fetched and inspected directly.
|
|
35
|
+
* It matches the documented format exactly: sessions delimited by
|
|
36
|
+
* `# aider chat started at <timestamp>`, each user message as one or
|
|
37
|
+
* more `#### `-prefixed markdown lines, tool/system notices as `> `
|
|
38
|
+
* blockquote lines, assistant replies as plain markdown including
|
|
39
|
+
* fenced code blocks and aider's own `<<<<<<< ORIGINAL / ======= /
|
|
40
|
+
* >>>>>>> UPDATED` search-replace diff blocks.
|
|
41
|
+
* 4. `.aider.input.history` is not aider's own format at all — aider hands
|
|
42
|
+
* it straight to `prompt_toolkit.history.FileHistory`, a dependency of
|
|
43
|
+
* aider's. Fetched that library's own current source
|
|
44
|
+
* (python-prompt-toolkit, `src/prompt_toolkit/history.py`,
|
|
45
|
+
* `FileHistory.store_string`) directly: every stored input is appended
|
|
46
|
+
* as `\n# <datetime>\n` followed by that input's lines, each prefixed
|
|
47
|
+
* with a literal `+`. So this file is not free-form text so much as a
|
|
48
|
+
* well-known third-party library's fixed serialization — verified
|
|
49
|
+
* against that library's own code, not guessed.
|
|
50
|
+
*
|
|
51
|
+
* What none of the above is: a real Aider install on the machine this
|
|
52
|
+
* source was built on. Checked directly and thoroughly — `which aider`,
|
|
53
|
+
* `pip3 show aider-chat` / `python3 -m pip show aider-chat`, `brew list
|
|
54
|
+
* aider`, `pipx list`, common config locations (`~/.config`, `~/Library/
|
|
55
|
+
* Application Support`, `~/Library/Caches`, `~/Library/Preferences`), and a
|
|
56
|
+
* filesystem-wide `find`/`mdfind` for `.aider*` and `*aider-chat-history*`.
|
|
57
|
+
* All came back empty: aider is not installed here, and there is no real
|
|
58
|
+
* session history on this machine to genuinely verify the schema against.
|
|
59
|
+
* Per CONTRIBUTING.md's rule 3, this ships anyway because of the four
|
|
60
|
+
* corroborating sources above, but should be treated the same way cursor.js
|
|
61
|
+
* asks to be treated: real, but UNVERIFIED against a live install. If you
|
|
62
|
+
* have Aider installed, running `residoo scan` and checking the results
|
|
63
|
+
* against what you know is really in your `.aider.chat.history.md` /
|
|
64
|
+
* `.aider.input.history` files is the single most useful way to firm this
|
|
65
|
+
* up — please report back either way.
|
|
66
|
+
*
|
|
67
|
+
* WHERE THIS SOURCE LOOKS — the fundamentally different problem vs.
|
|
68
|
+
* claude-code.js / cursor.js:
|
|
69
|
+
*
|
|
70
|
+
* Both of those tools keep everything under one fixed, well-known directory
|
|
71
|
+
* this machine can enumerate directly (~/.claude/projects,
|
|
72
|
+
* .../Cursor/User). Aider, by its own design (see source citation #2
|
|
73
|
+
* above), has no such thing — its two history files can be sitting at the
|
|
74
|
+
* root of literally any git repository, or any bare directory, the user has
|
|
75
|
+
* ever run `aider` from. There is no manifest anywhere that lists which
|
|
76
|
+
* directories those were: `~/.aider/installs.json` (see AIDER_HOME below)
|
|
77
|
+
* only ever records `(version, python-executable)` pairs for "what's new"
|
|
78
|
+
* notes, and `~/.aider/analytics.json` records anonymized event counters,
|
|
79
|
+
* neither ever a path. Confirmed directly against aider's own
|
|
80
|
+
* `is_first_run_of_new_version()` in `main.py` and `Analytics` in
|
|
81
|
+
* `analytics.py`.
|
|
82
|
+
*
|
|
83
|
+
* So `files()` below does a bounded, best-effort walk of the user's home
|
|
84
|
+
* directory looking for the two exact filenames above (plus, opportunistically,
|
|
85
|
+
* the one non-default file named below) at any depth. This is an honest,
|
|
86
|
+
* named engineering tradeoff, not a guess about WHERE aider's format lives
|
|
87
|
+
* (that part is verified, see above) — it is a search-breadth compromise for
|
|
88
|
+
* a location that is, by the tool's own design, unbounded. The walk is
|
|
89
|
+
* gated behind available() (see below) so a user who has never touched
|
|
90
|
+
* aider pays nothing for it, and it is bounded (MAX_DEPTH, MAX_DIRS_VISITED)
|
|
91
|
+
* so a user who has pays a bounded, not unlimited, cost. Both bounds are
|
|
92
|
+
* generous enough to cover realistic project layouts but this is explicitly
|
|
93
|
+
* NOT an exhaustive filesystem search — a `.aider.chat.history.md` sitting
|
|
94
|
+
* deeper than MAX_DEPTH below $HOME, or reachable only through a symlinked
|
|
95
|
+
* directory (deliberately not followed — see walk()'s docstring), will be
|
|
96
|
+
* missed. That is a real, named limitation, the same spirit as
|
|
97
|
+
* claude-code.js's admitted peak-memory gap and cursor.js's admitted
|
|
98
|
+
* untested-on-a-real-install gap — not a silent one.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
const HOME = os.homedir();
|
|
102
|
+
|
|
103
|
+
// Presence-only signal, NOT the location transcripts live in (see module
|
|
104
|
+
// docstring's "WHERE THIS SOURCE LOOKS" section). Aider writes into this
|
|
105
|
+
// directory on essentially every normal run — install-tracking
|
|
106
|
+
// (installs.json), opt-in anonymous analytics (analytics.json), and OAuth
|
|
107
|
+
// provider tokens (oauth-keys.env) — confirmed directly against
|
|
108
|
+
// `Path.home() / ".aider"` in aider's own main.py/analytics.py. Its mere
|
|
109
|
+
// existence is a reliable, cheap, fixed-path way to answer "has aider ever
|
|
110
|
+
// actually run on this machine" without doing the expensive home-directory
|
|
111
|
+
// walk files() needs for the transcripts themselves.
|
|
112
|
+
const AIDER_HOME = path.join(HOME, ".aider");
|
|
113
|
+
|
|
114
|
+
const CHAT_HISTORY_NAME = ".aider.chat.history.md";
|
|
115
|
+
const INPUT_HISTORY_NAME = ".aider.input.history";
|
|
116
|
+
// --llm-history-file has NO default (default=None in args.py — confirmed
|
|
117
|
+
// directly) — it only exists if a user explicitly opted in. It is included
|
|
118
|
+
// here purely opportunistically, using the exact filename aider's own
|
|
119
|
+
// --help text uses as its example ("for example, .aider.llm.history"): if a
|
|
120
|
+
// file with this exact name happens to exist alongside the other two, scan
|
|
121
|
+
// it too, since aider's own LLM history log is plausibly full of pasted
|
|
122
|
+
// code/secrets. This is NOT a verified default location the way the other
|
|
123
|
+
// two are — it is a zero-cost opportunistic check with no default to be
|
|
124
|
+
// wrong about, and its absence should never be read as "surely not opted
|
|
125
|
+
// in," just "not opted in under the example name."
|
|
126
|
+
const LLM_HISTORY_NAME = ".aider.llm.history";
|
|
127
|
+
|
|
128
|
+
const CANDIDATE_NAMES = new Set([CHAT_HISTORY_NAME, INPUT_HISTORY_NAME, LLM_HISTORY_NAME]);
|
|
129
|
+
|
|
130
|
+
function id() { return "aider"; }
|
|
131
|
+
function label() { return "Aider"; }
|
|
132
|
+
|
|
133
|
+
function available() {
|
|
134
|
+
try { return fs.statSync(AIDER_HOME).isDirectory(); } catch { return false; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Bounds for the home-directory walk in files() — see the module docstring
|
|
138
|
+
// for why this walk exists at all. Not calibrated against any real, large
|
|
139
|
+
// aider user's directory tree (no real install on this machine — see
|
|
140
|
+
// module docstring); chosen as a generous-but-bounded backstop the same way
|
|
141
|
+
// cursor.js's MAX_DB_BYTES is, not a measured real-world ceiling the way
|
|
142
|
+
// claude-code.js's MAX_BYTES is.
|
|
143
|
+
const MAX_DEPTH = 8; // levels below $HOME a candidate file can be found at
|
|
144
|
+
const MAX_DIRS_VISITED = 50_000; // circuit breaker on total directories read
|
|
145
|
+
|
|
146
|
+
// Directory names never worth descending into, at any depth: version
|
|
147
|
+
// control internals, dependency/build output, and language/tool caches.
|
|
148
|
+
// This is a performance optimization only, not a correctness boundary —
|
|
149
|
+
// MAX_DIRS_VISITED is what actually bounds worst-case cost; skipping these
|
|
150
|
+
// just spends that budget on directories far more likely to matter. None of
|
|
151
|
+
// aider's own history files are ever written inside any of these (they live
|
|
152
|
+
// at a git root or a bare CWD — never inside .git/, node_modules/, etc.),
|
|
153
|
+
// so skipping them cannot hide a real match.
|
|
154
|
+
const ALWAYS_SKIP_DIR_NAMES = new Set([
|
|
155
|
+
"node_modules", ".git", ".hg", ".svn", "vendor",
|
|
156
|
+
".venv", "venv", "__pycache__", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
157
|
+
".next", ".nuxt", "dist", "build", "target", ".gradle", ".m2",
|
|
158
|
+
".cargo", ".rustup", ".npm", ".yarn", ".pnpm-store", ".cache",
|
|
159
|
+
".docker", ".orbstack", ".Trash", ".Trashes",
|
|
160
|
+
".Spotlight-V100", ".fseventsd", ".DocumentRevisions-V100", ".TemporaryItems",
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
// Skipped ONLY as direct children of $HOME itself (depth 0), never at any
|
|
164
|
+
// deeper level — unlike the names above, these are ordinary, meaningful
|
|
165
|
+
// words a real project directory could legitimately be named (e.g. a repo
|
|
166
|
+
// literally called "build" or "Library"); they are only reliably "OS/user
|
|
167
|
+
// furniture, not a project" when sitting directly under the home directory.
|
|
168
|
+
const HOME_TOP_LEVEL_SKIP_DIR_NAMES = new Set([
|
|
169
|
+
"Library", "Applications", "Pictures", "Movies", "Music", "Public", "Desktop",
|
|
170
|
+
"AppData", // Windows counterpart to the above; harmless to check cross-platform
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Same defensive symlink-following as claude-code.js's
|
|
175
|
+
* isFileFollowingSymlink — duplicated locally rather than imported, same
|
|
176
|
+
* reasoning cursor.js states: each source here is meant to be a small,
|
|
177
|
+
* self-contained file a reviewer can audit on its own.
|
|
178
|
+
*
|
|
179
|
+
* Used only for the three known candidate filenames themselves (a single,
|
|
180
|
+
* named entry) — see walk()'s docstring for why open-ended directory
|
|
181
|
+
* recursion below deliberately does NOT get the same symlink-following
|
|
182
|
+
* treatment.
|
|
183
|
+
*/
|
|
184
|
+
function isFileFollowingSymlink(fullPath, dirent) {
|
|
185
|
+
if (dirent.isFile()) return true;
|
|
186
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
187
|
+
try { return fs.statSync(fullPath).isFile(); } catch { return false; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve one candidate-named directory entry (`.aider.chat.history.md`,
|
|
192
|
+
* `.aider.input.history`, or `.aider.llm.history`) into zero or one files()
|
|
193
|
+
* entries, following a symlink with that exact name the same way
|
|
194
|
+
* claude-code.js follows a `*.jsonl`-named symlink. `broken: true` is
|
|
195
|
+
* reserved for a symlink with one of these exact names that fails to
|
|
196
|
+
* resolve — genuinely "this looked like an aider history file and wasn't
|
|
197
|
+
* readable," not the general "most directories we visit aren't
|
|
198
|
+
* aider-related at all" case walk() itself silently passes over (see its
|
|
199
|
+
* docstring).
|
|
200
|
+
*/
|
|
201
|
+
function* candidateEntry(fullPath, dirent) {
|
|
202
|
+
if (!isFileFollowingSymlink(fullPath, dirent)) {
|
|
203
|
+
if (dirent.isSymbolicLink()) yield { file: fullPath, broken: true };
|
|
204
|
+
return; // e.g. a directory that happens to be named exactly this — out of scope, not broken
|
|
205
|
+
}
|
|
206
|
+
let stat;
|
|
207
|
+
try { stat = fs.statSync(fullPath); }
|
|
208
|
+
catch { yield { file: fullPath, broken: true }; return; }
|
|
209
|
+
yield { file: fullPath, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Recursively walk `dir` (`depth` levels below $HOME) looking for the
|
|
214
|
+
* candidate filenames above, subject to `budget` (a shared { remaining }
|
|
215
|
+
* counter across the whole walk — see MAX_DIRS_VISITED).
|
|
216
|
+
*
|
|
217
|
+
* Two deliberate departures from claude-code.js/cursor.js's walking style,
|
|
218
|
+
* both because this walk is open-ended (an unbounded, unknown directory
|
|
219
|
+
* tree) rather than a listing of one specific, known, expected location:
|
|
220
|
+
*
|
|
221
|
+
* 1. Directory symlinks are NOT followed during recursion (only
|
|
222
|
+
* `dirent.isDirectory()`, lstat semantics). Following them here — unlike
|
|
223
|
+
* following a single, specific, known symlink such as
|
|
224
|
+
* ~/.claude/projects/<slug> — risks an infinite cycle (a symlinked
|
|
225
|
+
* directory pointing back at one of its own ancestors), which an
|
|
226
|
+
* open-ended walk has no other guard against. MAX_DIRS_VISITED still
|
|
227
|
+
* bounds worst case even if this reasoning has a gap, but not following
|
|
228
|
+
* directory symlinks is the primary defense.
|
|
229
|
+
* 2. A directory that fails to list (fs.readdirSync throws — permission
|
|
230
|
+
* denied, deleted mid-walk, etc.) is silently skipped, NOT reported via
|
|
231
|
+
* `broken: true`. claude-code.js reports that for a project directory
|
|
232
|
+
* under ~/.claude/projects because every entry there is a known,
|
|
233
|
+
* expected Claude Code project folder — a read failure is anomalous and
|
|
234
|
+
* worth surfacing. Here, the overwhelming majority of directories this
|
|
235
|
+
* function visits have nothing to do with aider at all (this is a
|
|
236
|
+
* speculative, exploratory walk of $HOME) — treating every
|
|
237
|
+
* permission-denied OS directory encountered along the way as a
|
|
238
|
+
* reportable "broken" entry would flood the report with noise carrying
|
|
239
|
+
* no actionable signal. `broken` stays reserved for the specific, named
|
|
240
|
+
* candidate files themselves (see candidateEntry above), exactly
|
|
241
|
+
* mirroring how claude-code.js/cursor.js already treat "some unrelated
|
|
242
|
+
* stray entry" as silently out of scope while treating a failure on a
|
|
243
|
+
* specifically-expected entry as reportable.
|
|
244
|
+
*/
|
|
245
|
+
function* walk(dir, depth, budget) {
|
|
246
|
+
if (budget.remaining <= 0) return;
|
|
247
|
+
budget.remaining--;
|
|
248
|
+
|
|
249
|
+
let entries;
|
|
250
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
251
|
+
catch { return; }
|
|
252
|
+
|
|
253
|
+
const atHomeLevel = depth === 0;
|
|
254
|
+
for (const e of entries) {
|
|
255
|
+
const full = path.join(dir, e.name);
|
|
256
|
+
|
|
257
|
+
if (CANDIDATE_NAMES.has(e.name)) {
|
|
258
|
+
yield* candidateEntry(full, e);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (depth >= MAX_DEPTH) continue;
|
|
263
|
+
if (!e.isDirectory()) continue; // no symlink-following in open-ended recursion — see docstring above
|
|
264
|
+
if (ALWAYS_SKIP_DIR_NAMES.has(e.name)) continue;
|
|
265
|
+
if (atHomeLevel && HOME_TOP_LEVEL_SKIP_DIR_NAMES.has(e.name)) continue;
|
|
266
|
+
|
|
267
|
+
yield* walk(full, depth + 1, budget);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every aider history file
|
|
273
|
+
* found under $HOME. See the module docstring's "WHERE THIS SOURCE LOOKS"
|
|
274
|
+
* section for what this walk is and is not guaranteed to cover.
|
|
275
|
+
*/
|
|
276
|
+
function* files() {
|
|
277
|
+
// available() is the cheap gate on the (unrelated) ~/.aider directory —
|
|
278
|
+
// see that directory's own comment above for why checking it here, unlike
|
|
279
|
+
// in claude-code.js/cursor.js, is necessary rather than redundant: this
|
|
280
|
+
// function's walk root ($HOME) is not the same directory available()
|
|
281
|
+
// checks, so without this line every residoo user — aider or not — would
|
|
282
|
+
// pay for a full home-directory walk on every scan.
|
|
283
|
+
if (!available()) return;
|
|
284
|
+
|
|
285
|
+
const budget = { remaining: MAX_DIRS_VISITED };
|
|
286
|
+
yield* walk(HOME, 0, budget);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Bounds for readLines() — same rationale and same numbers as
|
|
290
|
+
// claude-code.js, but NOT calibrated against a real large aider file the
|
|
291
|
+
// way claude-code.js's MAX_BYTES was (no real install — see module
|
|
292
|
+
// docstring). Markdown chat transcripts and prompt_toolkit's input-history
|
|
293
|
+
// format are both far more compact than JSONL tool-call payloads (no
|
|
294
|
+
// embedded base64, no repeated schema keys), so multi-gigabyte real files
|
|
295
|
+
// are less likely here than for claude-code.js — but with nothing real to
|
|
296
|
+
// measure, this stays a generous backstop rather than a measured ceiling.
|
|
297
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
|
|
298
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read one aider history file as an array of raw text lines.
|
|
302
|
+
*
|
|
303
|
+
* Both known formats are already meaningfully line-oriented, so no
|
|
304
|
+
* reformatting is needed before pattern matching:
|
|
305
|
+
* - .aider.chat.history.md is Markdown — every line (a `#### ` prompt
|
|
306
|
+
* line, a `> ` tool-notice line, a fenced-code-block line, plain
|
|
307
|
+
* assistant prose) is exactly one scanned line, same as any other text
|
|
308
|
+
* file this codebase reads.
|
|
309
|
+
* - .aider.input.history is prompt_toolkit's FileHistory serialization —
|
|
310
|
+
* each stored input's lines are written back out one per file line,
|
|
311
|
+
* each prefixed with a literal `+` (plus interleaved `# <datetime>`
|
|
312
|
+
* comment lines) — see source citation #4 in the module docstring. The
|
|
313
|
+
* leading `+` is left in place rather than stripped: every pattern in
|
|
314
|
+
* src/patterns.js matches on `\b` word boundaries, never a `^`
|
|
315
|
+
* line-start anchor (checked directly against patterns.js), so a
|
|
316
|
+
* secret on a `+`-prefixed line is matched exactly as it would be
|
|
317
|
+
* without the prefix. Stripping it would be extra code with no
|
|
318
|
+
* detection benefit.
|
|
319
|
+
*
|
|
320
|
+
* Implementation (streaming via readline/promises, MAX_BYTES cap,
|
|
321
|
+
* READ_TIMEOUT_MS watchdog, partial-read lines kept rather than discarded)
|
|
322
|
+
* is deliberately identical in shape to claude-code.js's readLines() — see
|
|
323
|
+
* that file's docstring for the full reasoning on each of those choices,
|
|
324
|
+
* all of which apply here unchanged (this is plain line-delimited UTF-8
|
|
325
|
+
* text on disk either way, not a database or JSON blob needing cursor.js's
|
|
326
|
+
* different approach). Duplicated rather than imported, per this project's
|
|
327
|
+
* one-small-self-contained-file-per-source convention (see cursor.js's own
|
|
328
|
+
* docstring for the same point).
|
|
329
|
+
*/
|
|
330
|
+
async function readLines(file) {
|
|
331
|
+
let stat;
|
|
332
|
+
try { stat = fs.statSync(file); }
|
|
333
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
334
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
335
|
+
|
|
336
|
+
const lines = [];
|
|
337
|
+
let bytesRead = 0;
|
|
338
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
339
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
340
|
+
|
|
341
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
for await (const line of rl) {
|
|
345
|
+
lines.push(line);
|
|
346
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
|
|
347
|
+
}
|
|
348
|
+
return { lines, status: "complete", bytesRead };
|
|
349
|
+
} catch {
|
|
350
|
+
// Whatever WAS read before the failure is real content and may contain
|
|
351
|
+
// a real secret — discarding it because the file didn't finish cleanly
|
|
352
|
+
// would be a silent false negative, same reasoning as claude-code.js.
|
|
353
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
354
|
+
} finally {
|
|
355
|
+
clearTimeout(timer);
|
|
356
|
+
rl.close();
|
|
357
|
+
stream.destroy();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = { id, label, available, files, readLines };
|