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.
- package/README.md +225 -46
- package/SECURITY.md +29 -22
- package/package.json +1 -1
- package/src/cli.js +82 -16
- package/src/integrity.js +669 -0
- package/src/patterns.js +78 -5
- package/src/report.js +74 -7
- 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/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,408 @@
|
|
|
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
|
+
* Continue (continue.dev) — the open-source AI coding extension for VS Code
|
|
10
|
+
* and JetBrains. Both IDE front-ends embed the same IDE-agnostic "core"
|
|
11
|
+
* (github.com/continuedev/continue, package `core/`), and it is that core —
|
|
12
|
+
* not either IDE integration — which owns local storage, so the path below
|
|
13
|
+
* applies the same way regardless of which IDE the user runs Continue in.
|
|
14
|
+
*
|
|
15
|
+
* VERIFICATION STATUS (read this before trusting anything below): this is
|
|
16
|
+
* NOT checked against a real Continue install — no `~/.continue` directory,
|
|
17
|
+
* no VS Code install, and no Continue JetBrains plugin trace exist on the
|
|
18
|
+
* machine this adapter was built on (checked: direct path stat, `mdfind`,
|
|
19
|
+
* `~/.vscode/extensions`, `~/Library/Application Support/JetBrains/*`).
|
|
20
|
+
* Ships anyway per CONTRIBUTING.md rule 3, on the strength of reading the
|
|
21
|
+
* project's own real, current source directly — about as strong as
|
|
22
|
+
* corroboration gets short of a live install — cross-checked against two
|
|
23
|
+
* more independent descriptions that agree with it:
|
|
24
|
+
*
|
|
25
|
+
* 1. THE PROJECT'S OWN SOURCE CODE, read directly from
|
|
26
|
+
* github.com/continuedev/continue (`main` branch):
|
|
27
|
+
* - `core/util/paths.ts` — `getContinueGlobalPath()` resolves to
|
|
28
|
+
* `path.join(os.homedir(), ".continue")` (or the `CONTINUE_GLOBAL_DIR`
|
|
29
|
+
* env var, not honored by this adapter — see below), with NO
|
|
30
|
+
* per-OS branching, so this is the path on macOS, Linux, AND
|
|
31
|
+
* Windows alike (unlike Cursor/Trae's VS Code-inherited per-OS
|
|
32
|
+
* `Application Support` / `%APPDATA%` split — Continue's core is a
|
|
33
|
+
* separate Node process the IDE spawns, not a VS Code storage
|
|
34
|
+
* consumer). `getSessionsFolderPath()` /
|
|
35
|
+
* `getSessionFilePath(sessionId)` / `getSessionsListPath()` give
|
|
36
|
+
* `sessions/`, `sessions/<sessionId>.json`, and
|
|
37
|
+
* `sessions/sessions.json` off that root.
|
|
38
|
+
* - `core/util/history.ts` (`HistoryManager`) — `save()` writes one
|
|
39
|
+
* full `Session` object per `sessions/<uuid>.json` (pretty-printed
|
|
40
|
+
* `JSON.stringify(orderedSession, undefined, 2)`) and appends/
|
|
41
|
+
* updates a lightweight `BaseSessionMetadata` entry in the
|
|
42
|
+
* `sessions/sessions.json` array.
|
|
43
|
+
* - `core/index.d.ts` — `Session.history` is a `ChatHistoryItem[]`;
|
|
44
|
+
* each item's `message` carries `role` ("user" | "assistant" |
|
|
45
|
+
* "thinking" | "system" | "tool") and `content` (plain string OR
|
|
46
|
+
* an array of `{type:"text",text}` / `{type:"imageUrl",imageUrl}`
|
|
47
|
+
* parts), plus optional `toolCalls`, `contextItems` (full file
|
|
48
|
+
* contents pulled into context, `{content, name, uri, ...}`), and
|
|
49
|
+
* `promptLogs` (raw `{prompt, completion}` sent to/from the model)
|
|
50
|
+
* — i.e. real transcript content, not just metadata.
|
|
51
|
+
* - `core/data/log.ts` (`DataLogger.logLocalData`) — confirms local
|
|
52
|
+
* "dev data" logging to `dev_data/<schemaVersion>/<eventName>.jsonl`
|
|
53
|
+
* (via `getDevDataFilePath()` in paths.ts) runs UNCONDITIONALLY
|
|
54
|
+
* ("Local logs (always on for all levels)", literal comment in the
|
|
55
|
+
* source) at `DEFAULT_DEV_DATA_LEVEL = "all"` — i.e. on by
|
|
56
|
+
* default, not an opt-in telemetry path.
|
|
57
|
+
* - `packages/config-yaml/src/schemas/data/chatInteraction/v0.2.0.ts`
|
|
58
|
+
* — confirms the "all"-level `chatInteraction` event schema (the
|
|
59
|
+
* one written locally by default) includes `prompt` and
|
|
60
|
+
* `completion` as full fields, only omitted at the opt-in stricter
|
|
61
|
+
* "noCode" level used for some *remote* destinations.
|
|
62
|
+
* 2. Official docs (docs.continue.dev/development-data, via search
|
|
63
|
+
* excerpt — the live page itself is a JS-rendered redirect stub
|
|
64
|
+
* WebFetch could not follow, so this is the search engine's cached
|
|
65
|
+
* text of it, not a fetch of the rendered page): "By default, this
|
|
66
|
+
* development data is saved to .continue/dev_data on your local
|
|
67
|
+
* machine," independently confirming point 1's "always on" reading of
|
|
68
|
+
* the source.
|
|
69
|
+
* 3. DeepWiki's auto-generated `continuedev/continue` wiki page
|
|
70
|
+
* "8.7 History and Session Persistence" (deepwiki.com) — independently
|
|
71
|
+
* describes the same `sessions/sessions.json` +
|
|
72
|
+
* `sessions/<uuid>.json` split, matching the source exactly.
|
|
73
|
+
*
|
|
74
|
+
* NOT covered by this adapter, named so a future PR knows they were seen
|
|
75
|
+
* and deliberately left out rather than missed: `logs/core.log` and
|
|
76
|
+
* `logs/prompt.log` (`getLogsDirPath()`/`getPromptLogsPath()` in paths.ts)
|
|
77
|
+
* — free-text debug logs, not confirmed to hold full prompt/completion
|
|
78
|
+
* content the way `dev_data` is; `dev_data/**\/*.sqlite`
|
|
79
|
+
* (`getDevDataSqlitePath()`) — a separate SQLite mirror of the same dev-data
|
|
80
|
+
* events, redundant with the JSONL files this adapter already reads; and
|
|
81
|
+
* the `CONTINUE_GLOBAL_DIR` environment-variable override — honoring it
|
|
82
|
+
* would mean trusting an env var to redirect what gets scanned, which
|
|
83
|
+
* didn't seem like the right default to add silently; a relocated
|
|
84
|
+
* `~/.continue` installed via that var will simply read as "not installed"
|
|
85
|
+
* to `available()` below.
|
|
86
|
+
*/
|
|
87
|
+
const ROOT = path.join(os.homedir(), ".continue");
|
|
88
|
+
const SESSIONS_DIR = path.join(ROOT, "sessions");
|
|
89
|
+
const SESSIONS_INDEX_FILE = path.join(SESSIONS_DIR, "sessions.json");
|
|
90
|
+
const DEV_DATA_DIR = path.join(ROOT, "dev_data");
|
|
91
|
+
|
|
92
|
+
function id() { return "continue"; }
|
|
93
|
+
function label() { return "Continue"; }
|
|
94
|
+
|
|
95
|
+
function available() {
|
|
96
|
+
// Mirrors cursor.js's choice, not claude-code.js's: like Cursor, this
|
|
97
|
+
// source reads from multiple sibling locations under one root
|
|
98
|
+
// (sessions/, dev_data/) rather than one specific content directory, so
|
|
99
|
+
// the umbrella root is what "installed" means here. It is not proof any
|
|
100
|
+
// session/event content actually exists yet — same caveat cursor.js's
|
|
101
|
+
// available() carries for the same reason.
|
|
102
|
+
try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Same defensive symlink-following pattern as claude-code.js's
|
|
107
|
+
* isDirFollowingSymlink/isFileFollowingSymlink — see that file's docstring
|
|
108
|
+
* for the full reasoning. Duplicated here rather than imported, matching
|
|
109
|
+
* cursor.js's stated rationale: each source is meant to be a small,
|
|
110
|
+
* self-contained file a reviewer can audit on its own.
|
|
111
|
+
*/
|
|
112
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
113
|
+
if (checkFn(dirent)) return true;
|
|
114
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
115
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
116
|
+
}
|
|
117
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
118
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve one FIXED, known directory path (not discovered via a parent
|
|
122
|
+
* readdir, so there is no Dirent to reuse isDirFollowingSymlink against —
|
|
123
|
+
* same situation cursor.js's statIfPresent is in for GLOBAL_STORAGE_DB)
|
|
124
|
+
* into "ok" | "absent" | "broken". "absent" (path simply doesn't exist, or
|
|
125
|
+
* something unexpected — not a directory, not a symlink — sits there) is
|
|
126
|
+
* deliberately NOT reported broken, same convention as everywhere else in
|
|
127
|
+
* this project: broken is reserved for a path that looked like it should
|
|
128
|
+
* resolve to a real directory and didn't (chiefly a dangling symlink).
|
|
129
|
+
*/
|
|
130
|
+
function resolveDirState(dirPath) {
|
|
131
|
+
let lst;
|
|
132
|
+
try { lst = fs.lstatSync(dirPath); } catch { return "absent"; }
|
|
133
|
+
if (lst.isDirectory()) return "ok";
|
|
134
|
+
if (lst.isSymbolicLink()) {
|
|
135
|
+
try { return fs.statSync(dirPath).isDirectory() ? "ok" : "broken"; }
|
|
136
|
+
catch { return "broken"; }
|
|
137
|
+
}
|
|
138
|
+
return "absent";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function* walkJsonSessionFiles() {
|
|
142
|
+
const state = resolveDirState(SESSIONS_DIR);
|
|
143
|
+
if (state === "broken") { yield { file: SESSIONS_DIR, broken: true }; return; }
|
|
144
|
+
if (state !== "ok") return; // no sessions/ yet — normal, not broken
|
|
145
|
+
|
|
146
|
+
let entries;
|
|
147
|
+
try { entries = fs.readdirSync(SESSIONS_DIR, { withFileTypes: true }); }
|
|
148
|
+
catch { yield { file: SESSIONS_DIR, broken: true }; return; }
|
|
149
|
+
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
// Covers both sessions.json (the index) and <uuid>.json (per-session
|
|
152
|
+
// history) — readLines() tells them apart by filename, see below.
|
|
153
|
+
if (!e.name.endsWith(".json")) continue;
|
|
154
|
+
const file = path.join(SESSIONS_DIR, e.name);
|
|
155
|
+
if (!e.isFile()) {
|
|
156
|
+
const resolved = isFileFollowingSymlink(file, e);
|
|
157
|
+
if (!resolved) {
|
|
158
|
+
if (e.isSymbolicLink()) yield { file, broken: true };
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
let stat;
|
|
163
|
+
try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
|
|
164
|
+
yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Yield one files()-shaped entry for a single `.jsonl` candidate, handling
|
|
170
|
+
* the symlink-following/broken-reporting the same way every other file
|
|
171
|
+
* entry in this project does. `dirent` is the Dirent from whichever
|
|
172
|
+
* readdirSync produced this entry.
|
|
173
|
+
*/
|
|
174
|
+
function* yieldJsonlEntry(file, dirent) {
|
|
175
|
+
if (!dirent.isFile()) {
|
|
176
|
+
const resolved = isFileFollowingSymlink(file, dirent);
|
|
177
|
+
if (!resolved) {
|
|
178
|
+
if (dirent.isSymbolicLink()) yield { file, broken: true };
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
let stat;
|
|
183
|
+
try { stat = fs.statSync(file); } catch { yield { file, broken: true }; return; }
|
|
184
|
+
yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* dev_data/ holds Continue's local interaction-event log — one `.jsonl`
|
|
189
|
+
* file per event type, written on by default (see the header docstring,
|
|
190
|
+
* point 1's `core/data/log.ts` note). The real, documented layout nests
|
|
191
|
+
* these one level down by schema version — `dev_data/<version>/<event>.jsonl`
|
|
192
|
+
* (`getDevDataFilePath()` in paths.ts) — but that version string
|
|
193
|
+
* ("0.2.0" today) is exactly the kind of value likely to drift across
|
|
194
|
+
* Continue releases the way cursor.js's own docstring describes already
|
|
195
|
+
* happening to Cursor's key names; deliberately NOT hard-coded here.
|
|
196
|
+
* Instead this walks two levels: any `.jsonl` sitting directly in dev_data/
|
|
197
|
+
* (in case a future/older layout is flatter), and one directory down inside
|
|
198
|
+
* whatever subdirectories dev_data/ actually contains right now, whatever
|
|
199
|
+
* they're named.
|
|
200
|
+
*/
|
|
201
|
+
function* walkDevDataFiles() {
|
|
202
|
+
const state = resolveDirState(DEV_DATA_DIR);
|
|
203
|
+
if (state === "broken") { yield { file: DEV_DATA_DIR, broken: true }; return; }
|
|
204
|
+
if (state !== "ok") return; // no dev_data/ yet — normal, not broken
|
|
205
|
+
|
|
206
|
+
let topEntries;
|
|
207
|
+
try { topEntries = fs.readdirSync(DEV_DATA_DIR, { withFileTypes: true }); }
|
|
208
|
+
catch { yield { file: DEV_DATA_DIR, broken: true }; return; }
|
|
209
|
+
|
|
210
|
+
for (const e of topEntries) {
|
|
211
|
+
const p = path.join(DEV_DATA_DIR, e.name);
|
|
212
|
+
|
|
213
|
+
if (e.name.endsWith(".jsonl")) {
|
|
214
|
+
yield* yieldJsonlEntry(p, e);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!isDirFollowingSymlink(p, e)) {
|
|
219
|
+
if (e.isSymbolicLink()) yield { file: p, broken: true };
|
|
220
|
+
continue; // some other stray entry — out of scope, not broken
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let innerEntries;
|
|
224
|
+
try { innerEntries = fs.readdirSync(p, { withFileTypes: true }); }
|
|
225
|
+
catch { yield { file: p, broken: true }; continue; }
|
|
226
|
+
|
|
227
|
+
for (const ie of innerEntries) {
|
|
228
|
+
if (!ie.name.endsWith(".jsonl")) continue;
|
|
229
|
+
yield* yieldJsonlEntry(path.join(p, ie.name), ie);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every candidate file this
|
|
236
|
+
* source knows about: session history JSON (sessions/) and default-on
|
|
237
|
+
* interaction-event JSONL (dev_data/). See CONTRIBUTING.md and the header
|
|
238
|
+
* docstring above for what's deliberately excluded.
|
|
239
|
+
*/
|
|
240
|
+
function* files() {
|
|
241
|
+
yield* walkJsonSessionFiles();
|
|
242
|
+
yield* walkDevDataFiles();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// dev_data/*.jsonl is a genuinely line-delimited, append-only log (grows
|
|
246
|
+
// with every autocomplete/chat/tool-use event Continue records), so it gets
|
|
247
|
+
// the same bounds claude-code.js uses for its own append-only transcripts —
|
|
248
|
+
// by analogy, not because a real multi-GB dev_data file was observed; no
|
|
249
|
+
// install existed to observe one against (see header docstring).
|
|
250
|
+
const MAX_JSONL_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
|
|
251
|
+
// sessions/*.json is one whole JSON document per file (a single session's
|
|
252
|
+
// history, or the sessions.json index) — not append-only in the same way,
|
|
253
|
+
// and has no real large example to size this against either. Generous
|
|
254
|
+
// backstop against a corrupted/pathological file, same admitted status as
|
|
255
|
+
// cursor.js's own MAX_DB_BYTES.
|
|
256
|
+
const MAX_JSON_DOC_BYTES = 512 * 1024 * 1024;
|
|
257
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read a genuinely line-delimited `.jsonl` file (dev_data/) exactly the way
|
|
261
|
+
* claude-code.js reads its JSONL transcripts — see that file's docstring
|
|
262
|
+
* for the full reasoning (streaming over readFileSync+split because a real
|
|
263
|
+
* 818MB transcript overflowed V8's single-string limit; the destroy-on-timeout
|
|
264
|
+
* timer because nothing in Node's stream/readline stack times out on its
|
|
265
|
+
* own). Reused verbatim here rather than imported, per this project's
|
|
266
|
+
* one-small-self-contained-file-per-source convention.
|
|
267
|
+
*/
|
|
268
|
+
async function readJsonlFile(file) {
|
|
269
|
+
let stat;
|
|
270
|
+
try { stat = fs.statSync(file); }
|
|
271
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
272
|
+
if (stat.size > MAX_JSONL_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
273
|
+
|
|
274
|
+
const lines = [];
|
|
275
|
+
let bytesRead = 0;
|
|
276
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
277
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
278
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
for await (const line of rl) {
|
|
282
|
+
lines.push(line);
|
|
283
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1;
|
|
284
|
+
}
|
|
285
|
+
return { lines, status: "complete", bytesRead };
|
|
286
|
+
} catch {
|
|
287
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
288
|
+
} finally {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
rl.close();
|
|
291
|
+
stream.destroy();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Turn a parsed sessions.json index (expected: BaseSessionMetadata[]) into
|
|
297
|
+
* one scanned "line" per entry. Falls back to a single line for the whole
|
|
298
|
+
* parsed value if it isn't the expected array shape (e.g. an older/newer
|
|
299
|
+
* index format) — still scanned, just not decomposed per-entry.
|
|
300
|
+
*/
|
|
301
|
+
function linesFromSessionsIndex(parsed) {
|
|
302
|
+
if (Array.isArray(parsed)) return parsed.map((entry) => JSON.stringify(entry));
|
|
303
|
+
return [JSON.stringify(parsed)];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Turn a parsed individual session document (expected: Session, per
|
|
308
|
+
* core/index.d.ts — sessionId/title/workspaceDirectory/history[]/...) into
|
|
309
|
+
* one line per ChatHistoryItem in `history`, plus one line for everything
|
|
310
|
+
* else in the document (title, workspaceDirectory, mode, chatModelTitle,
|
|
311
|
+
* usage) so a secret sitting somewhere outside `history` — implausible, but
|
|
312
|
+
* this project's whole point is not assuming — still gets scanned. Falls
|
|
313
|
+
* back to a single line for the whole parsed value if `history` isn't an
|
|
314
|
+
* array (older/corrupted format).
|
|
315
|
+
*/
|
|
316
|
+
function linesFromSessionDocument(parsed) {
|
|
317
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.history)) {
|
|
318
|
+
return [JSON.stringify(parsed)];
|
|
319
|
+
}
|
|
320
|
+
const { history, ...rest } = parsed;
|
|
321
|
+
const lines = [JSON.stringify(rest)];
|
|
322
|
+
for (const item of history) lines.push(JSON.stringify(item));
|
|
323
|
+
return lines;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Read one whole JSON document file (sessions/sessions.json or
|
|
328
|
+
* sessions/<uuid>.json) and flatten it into scanned "lines" — see
|
|
329
|
+
* linesFromSessionsIndex/linesFromSessionDocument above for how, chosen by
|
|
330
|
+
* filename the same way files() constructs these two kinds of paths.
|
|
331
|
+
*
|
|
332
|
+
* Unlike readJsonlFile above, this can't match patterns against the file as
|
|
333
|
+
* it streams in — the file is one JSON document, not one record per line,
|
|
334
|
+
* so it must be fully assembled before JSON.parse can run at all. It still
|
|
335
|
+
* streams the raw bytes in (rather than fs.readFileSync) so the same
|
|
336
|
+
* destroy-on-timeout protection applies to a file whose read might hang
|
|
337
|
+
* (e.g. a symlink retargeted onto a FIFO with no writer, same scenario
|
|
338
|
+
* claude-code.js's docstring describes) — the read is bounded even though
|
|
339
|
+
* the eventual JSON.parse of the fully-assembled text is not.
|
|
340
|
+
*
|
|
341
|
+
* If the assembled text fails to parse as JSON (corrupted file, or a
|
|
342
|
+
* genuinely different/older format than core/index.d.ts describes), the
|
|
343
|
+
* raw text is scanned as a single line rather than discarded — every byte
|
|
344
|
+
* that was actually read off disk is still scanned, just not decomposed
|
|
345
|
+
* into per-message lines. Status is "complete" in that case: reading the
|
|
346
|
+
* file did succeed start to finish, it just isn't the JSON shape assumed.
|
|
347
|
+
*/
|
|
348
|
+
async function readJsonDocumentFile(file) {
|
|
349
|
+
let stat;
|
|
350
|
+
try { stat = fs.statSync(file); }
|
|
351
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
352
|
+
if (stat.size > MAX_JSON_DOC_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
353
|
+
|
|
354
|
+
let text = "";
|
|
355
|
+
let tooLarge = false;
|
|
356
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
357
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
358
|
+
|
|
359
|
+
// Resolves exactly once, from whichever of 'end'/'error' fires first —
|
|
360
|
+
// 'error' also covers the timeout/size-cap destroy() calls above, since
|
|
361
|
+
// destroying a stream mid-read emits 'error', not a silent 'close'.
|
|
362
|
+
const readCleanly = await new Promise((resolve) => {
|
|
363
|
+
stream.on("data", (chunk) => {
|
|
364
|
+
text += chunk;
|
|
365
|
+
if (!tooLarge && Buffer.byteLength(text, "utf-8") > MAX_JSON_DOC_BYTES) {
|
|
366
|
+
tooLarge = true;
|
|
367
|
+
stream.destroy();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
stream.once("end", () => resolve(true));
|
|
371
|
+
stream.once("error", () => resolve(false));
|
|
372
|
+
});
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
|
|
375
|
+
if (tooLarge) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
376
|
+
|
|
377
|
+
const bytesRead = Buffer.byteLength(text, "utf-8");
|
|
378
|
+
if (!readCleanly && bytesRead === 0) return { lines: [], status: "failed", bytesRead: 0 };
|
|
379
|
+
|
|
380
|
+
const isIndex = path.basename(file) === "sessions.json" && path.dirname(file) === SESSIONS_DIR;
|
|
381
|
+
// A read that didn't finish cleanly (timed out, or errored partway) still
|
|
382
|
+
// handed us real bytes off disk — scan them rather than discard them,
|
|
383
|
+
// same principle as claude-code.js's own partial-read handling, just
|
|
384
|
+
// applied to a whole-document read instead of a line-by-line one.
|
|
385
|
+
const status = readCleanly ? "complete" : "partial";
|
|
386
|
+
|
|
387
|
+
if (text.length === 0) return { lines: [], status, bytesRead };
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const parsed = JSON.parse(text);
|
|
391
|
+
const lines = isIndex ? linesFromSessionsIndex(parsed) : linesFromSessionDocument(parsed);
|
|
392
|
+
return { lines, status, bytesRead };
|
|
393
|
+
} catch {
|
|
394
|
+
// Not valid JSON — corrupted, truncated by a timeout/error partway
|
|
395
|
+
// through, or a genuinely different format than core/index.d.ts
|
|
396
|
+
// describes. Whatever text WAS read is real content and may contain a
|
|
397
|
+
// real secret; scan it as one raw line rather than discarding it just
|
|
398
|
+
// because it didn't parse.
|
|
399
|
+
return { lines: [text], status, bytesRead };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function readLines(file) {
|
|
404
|
+
if (file.endsWith(".jsonl")) return readJsonlFile(file);
|
|
405
|
+
return readJsonDocumentFile(file);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
module.exports = { id, label, available, files, readLines };
|
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
* GitHub Copilot Chat — the VS Code extension/panel (github.copilot-chat),
|
|
10
|
+
* NOT the standalone `copilot`/`gh copilot` CLI (see copilot-cli.js for that,
|
|
11
|
+
* a genuinely different product with a genuinely different storage format).
|
|
12
|
+
*
|
|
13
|
+
* VERIFICATION STATUS (read this before trusting anything below):
|
|
14
|
+
* multi-source-corroborated-but-UNVERIFIED against a real install. VS Code
|
|
15
|
+
* itself is not installed on the machine this adapter was built on (checked:
|
|
16
|
+
* no /Applications/*Code*.app, no ~/Library/Application Support/Code, no
|
|
17
|
+
* ~/Library/Application Support/Code - Insiders, no `code`/`code-insiders` on
|
|
18
|
+
* PATH). What IS unusually strong here, short of a real install: the storage
|
|
19
|
+
* mechanism and exact on-disk filenames below were read directly out of VS
|
|
20
|
+
* Code's own current shipped source (microsoft/vscode, fetched verbatim via
|
|
21
|
+
* `gh api repos/microsoft/vscode/contents/...` on 2026-09-02), not inferred
|
|
22
|
+
* from a blog post — see the file-by-file citations inline below. Treat
|
|
23
|
+
* findings from this source with the same caution as windsurf.js/void.js
|
|
24
|
+
* until someone with VS Code + Copilot Chat installed confirms it against
|
|
25
|
+
* real data (see CONTRIBUTING.md).
|
|
26
|
+
*
|
|
27
|
+
* WHERE THIS LIVES AND WHY (VS Code core chat storage, not Copilot-specific):
|
|
28
|
+
* Chat session persistence is implemented in VS Code CORE
|
|
29
|
+
* (`src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts`), shared
|
|
30
|
+
* by every chat participant an installed extension might register — it is
|
|
31
|
+
* not a Copilot-owned table or folder the way Cursor's `cursorDiskKV` is
|
|
32
|
+
* Cursor-owned (see cursor.js). Copilot Chat is simply the default,
|
|
33
|
+
* overwhelmingly dominant participant that actually writes real content
|
|
34
|
+
* there for the vast majority of installs, which is exactly what this
|
|
35
|
+
* cluster's brief means by "may store history in VS Code's workspaceStorage."
|
|
36
|
+
*
|
|
37
|
+
* Read directly from `chatSessionStore.ts` (constructor and
|
|
38
|
+
* handleWorkspaceTransition(), microsoft/vscode@780ea331b2861816fe6bb8215d81
|
|
39
|
+
* 2933c81df83b, the file's own most recent commit as of this research,
|
|
40
|
+
* 2026-08-06, "Harden chat import storage paths"):
|
|
41
|
+
* - Normal case (a folder/workspace is open): sessions live under
|
|
42
|
+
* <workspaceStorageHome>/<workspaceId>/chatSessions/
|
|
43
|
+
* where <workspaceId> is VS Code's own per-workspace hash directory name
|
|
44
|
+
* (workspaceStorage/<hash>/) — the same directory family cursor.js and
|
|
45
|
+
* void.js already walk in this project for their own state.vscdb files.
|
|
46
|
+
* - Empty window (no folder open): sessions live under the DEFAULT
|
|
47
|
+
* profile's globalStorage instead:
|
|
48
|
+
* <globalStorageHome>/emptyWindowChatSessions/
|
|
49
|
+
* - Legacy fallback, empty-window sessions from before this path existed:
|
|
50
|
+
* <workspaceStorageHome>/no-workspace/chatSessions/
|
|
51
|
+
* (read as a fallback only when a session isn't found at the current
|
|
52
|
+
* location; this adapter scans it unconditionally rather than trying to
|
|
53
|
+
* replicate that fallback logic, since "no-workspace" is simply one more
|
|
54
|
+
* literal-named entry under workspaceStorage/ that the generic walk
|
|
55
|
+
* below already visits like any other hash directory).
|
|
56
|
+
* - Sessions carried across a workspace-identity change (e.g. "Save
|
|
57
|
+
* Workspace As"):
|
|
58
|
+
* <globalStorageHome>/transferredChatSessions/
|
|
59
|
+
*
|
|
60
|
+
* Filenames, read directly from `getChatSessionStorageResource()` in
|
|
61
|
+
* `chatUri.ts` (same commit) and its two call sites in `chatSessionStore.ts`:
|
|
62
|
+
* each session is `<sessionId>.json` (a "flat" full-snapshot write) and/or,
|
|
63
|
+
* when `chat.useLogSessionStorage` is enabled (VS Code's own default is
|
|
64
|
+
* true), `<sessionId>.jsonl` (an append-only operation log — VS Code prefers
|
|
65
|
+
* reading this one when both exist: see `readSessionFromLocation()`, which
|
|
66
|
+
* tries the `.jsonl` location first and falls back to `.json`). Real user
|
|
67
|
+
* reports independently corroborate this exact "workspaceStorage/<hash>/
|
|
68
|
+
* chatSessions/*.json(.jsonl)" shape and the JSONL-vs-JSON split before this
|
|
69
|
+
* adapter's own source-reading confirmed it directly:
|
|
70
|
+
* - microsoft/vscode issue #285059 ("chat sessions remain in old
|
|
71
|
+
* workspaceStorage hash") and #291897 ("missing chatSessions JSON in new
|
|
72
|
+
* workspaceStorage") — real user bug reports naming the exact directory.
|
|
73
|
+
* - microsoft/vscode issue #308730 ("malformed chatSessions JSONL") — a
|
|
74
|
+
* real user hitting a parse error in the `.jsonl` form specifically.
|
|
75
|
+
* - dev.to/5a9awneh's "VS Code is silently losing your Copilot chat
|
|
76
|
+
* history" write-up, independently describing the same
|
|
77
|
+
* `workspaceStorage/<hash>/chatSessions/` JSONL layout.
|
|
78
|
+
* Base "User" directory per OS (official VS Code docs,
|
|
79
|
+
* code.visualstudio.com/docs/getstarted/settings — "%APPDATA%\Code\User" on
|
|
80
|
+
* Windows, "$HOME/Library/Application Support/Code/User" on macOS; Linux
|
|
81
|
+
* follows the same XDG-config convention cursor.js documents): this adapter
|
|
82
|
+
* checks both standard VS Code ("Code") and VS Code Insiders ("Code -
|
|
83
|
+
* Insiders"), the same narrower-than-exhaustive scope cline.js already
|
|
84
|
+
* documents and justifies for itself in this project — VSCodium, other VS
|
|
85
|
+
* Code forks, and the separate ~/.vscode-server tree used by remote-SSH
|
|
86
|
+
* sessions are deliberately NOT covered here.
|
|
87
|
+
*
|
|
88
|
+
* Explicitly out of scope, named rather than silently skipped:
|
|
89
|
+
* - Non-default VS Code profiles. A custom profile's globalStorage lives
|
|
90
|
+
* under `User/profiles/<profileId>/...` instead of `User/globalStorage`
|
|
91
|
+
* directly (per `IUserDataProfilesService.defaultProfile` vs. other
|
|
92
|
+
* profiles in the source read above) — only the default profile is
|
|
93
|
+
* walked here.
|
|
94
|
+
* - The session INDEX/title list. `chatSessionStore.ts` also persists a
|
|
95
|
+
* lightweight per-session index (session id -> `title`, via storage key
|
|
96
|
+
* `chat.ChatSessionStore.index`) through VS Code's generic
|
|
97
|
+
* IStorageService, which resolves to rows in the shared, per-profile
|
|
98
|
+
* `globalStorage/state.vscdb` `ItemTable` — the same file cursor.js and
|
|
99
|
+
* void.js already parse for their own products. That index is METADATA
|
|
100
|
+
* ONLY (an auto-generated title string per session, per
|
|
101
|
+
* `IChatSessionEntryMetadata` in chatSessionStore.ts) — actual message
|
|
102
|
+
* content, tool-call arguments, and anything pasted into the chat live
|
|
103
|
+
* exclusively in the chatSessions/*.json(.jsonl) files this adapter does
|
|
104
|
+
* read. Skipping the title index is the same call cline.js already makes
|
|
105
|
+
* for its own state.vscdb-backed task-title list, for the same reason.
|
|
106
|
+
*/
|
|
107
|
+
function vscodeUserDirs() {
|
|
108
|
+
const home = os.homedir();
|
|
109
|
+
const variants = ["Code", "Code - Insiders"];
|
|
110
|
+
if (process.platform === "darwin") {
|
|
111
|
+
return variants.map((v) => path.join(home, "Library", "Application Support", v, "User"));
|
|
112
|
+
}
|
|
113
|
+
if (process.platform === "win32") {
|
|
114
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
115
|
+
return variants.map((v) => path.join(appData, v, "User"));
|
|
116
|
+
}
|
|
117
|
+
// Linux and other XDG-following unix platforms.
|
|
118
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
119
|
+
return variants.map((v) => path.join(configHome, v, "User"));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Bounds for readLines() — same rationale and same values as claude-code.js
|
|
123
|
+
// and cline.js. Not backed by a real Copilot Chat transcript this tool was
|
|
124
|
+
// tested against (no VS Code install to test with) — see the
|
|
125
|
+
// verification-status note above.
|
|
126
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
|
|
127
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
128
|
+
|
|
129
|
+
function id() { return "copilot-chat"; }
|
|
130
|
+
function label() { return "GitHub Copilot Chat"; }
|
|
131
|
+
|
|
132
|
+
function available() {
|
|
133
|
+
return vscodeUserDirs().some((dir) => {
|
|
134
|
+
try { return fs.statSync(dir).isDirectory(); } catch { return false; }
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Same defensive symlink-following pattern as claude-code.js's
|
|
140
|
+
* isDirFollowingSymlink/isFileFollowingSymlink — see that file's docstring
|
|
141
|
+
* for the full reasoning. Duplicated rather than imported, matching this
|
|
142
|
+
* project's "small, self-contained file" convention (see cursor.js's own
|
|
143
|
+
* note on this).
|
|
144
|
+
*/
|
|
145
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
146
|
+
if (checkFn(dirent)) return true;
|
|
147
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
148
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
149
|
+
}
|
|
150
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
151
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every `*.json`/`*.jsonl`
|
|
155
|
+
* session file directly inside one chatSessions-shaped directory (flat, not
|
|
156
|
+
* recursive — chatSessionStore.ts writes sessions as immediate children of
|
|
157
|
+
* its storage root, confirmed via getChatSessionStorageResource()'s own
|
|
158
|
+
* dirname-equality check).
|
|
159
|
+
*
|
|
160
|
+
* A directory that simply doesn't exist yields nothing — normal, not broken:
|
|
161
|
+
* most workspaceStorage/<hash> entries are for workspaces that never opened
|
|
162
|
+
* the Chat view, and empty/transferred/legacy chat-session directories are
|
|
163
|
+
* only ever created on first use. Only an entry that looked like it should
|
|
164
|
+
* resolve and didn't (chiefly a dangling symlink) is reported broken, same
|
|
165
|
+
* convention as every other source in this project.
|
|
166
|
+
*/
|
|
167
|
+
function* walkSessionFilesDir(dir) {
|
|
168
|
+
let entries;
|
|
169
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
170
|
+
catch { return; }
|
|
171
|
+
|
|
172
|
+
for (const e of entries) {
|
|
173
|
+
if (!(e.name.endsWith(".json") || e.name.endsWith(".jsonl"))) continue;
|
|
174
|
+
const file = path.join(dir, e.name);
|
|
175
|
+
if (!isFileFollowingSymlink(file, e)) {
|
|
176
|
+
if (e.isSymbolicLink()) yield { file, broken: true };
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
let stat;
|
|
180
|
+
try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
|
|
181
|
+
yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Walk every entry directly under workspaceStorage/ (each one is either a
|
|
187
|
+
* per-workspace hash directory or the literal "no-workspace" legacy
|
|
188
|
+
* directory — both are just directories containing an optional
|
|
189
|
+
* chatSessions/ subdirectory, so no special-casing is needed) and yield
|
|
190
|
+
* whatever session files are inside each one's chatSessions/.
|
|
191
|
+
*/
|
|
192
|
+
function* walkWorkspaceStorage(workspaceStorageDir) {
|
|
193
|
+
let entries;
|
|
194
|
+
try { entries = fs.readdirSync(workspaceStorageDir, { withFileTypes: true }); }
|
|
195
|
+
catch { return; } // no workspaceStorage at all for this VS Code variant/profile — normal
|
|
196
|
+
|
|
197
|
+
for (const e of entries) {
|
|
198
|
+
const entryDir = path.join(workspaceStorageDir, e.name);
|
|
199
|
+
if (!isDirFollowingSymlink(entryDir, e)) {
|
|
200
|
+
if (e.isSymbolicLink()) yield { file: entryDir, broken: true };
|
|
201
|
+
continue; // a stray non-directory entry under workspaceStorage/ is out of scope, not broken
|
|
202
|
+
}
|
|
203
|
+
yield* walkSessionFilesDir(path.join(entryDir, "chatSessions"));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every Copilot/VS-Code-chat
|
|
209
|
+
* session file this adapter knows how to find, across every candidate VS
|
|
210
|
+
* Code User dir (standard + Insiders): every workspaceStorage/<hash-or-
|
|
211
|
+
* "no-workspace">/chatSessions/*.json(.jsonl), plus the default profile's
|
|
212
|
+
* globalStorage/emptyWindowChatSessions/ and globalStorage/
|
|
213
|
+
* transferredChatSessions/.
|
|
214
|
+
*/
|
|
215
|
+
function* files() {
|
|
216
|
+
for (const userDir of vscodeUserDirs()) {
|
|
217
|
+
yield* walkWorkspaceStorage(path.join(userDir, "workspaceStorage"));
|
|
218
|
+
|
|
219
|
+
const globalStorageDir = path.join(userDir, "globalStorage");
|
|
220
|
+
yield* walkSessionFilesDir(path.join(globalStorageDir, "emptyWindowChatSessions"));
|
|
221
|
+
yield* walkSessionFilesDir(path.join(globalStorageDir, "transferredChatSessions"));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read one chatSessions file as an array of raw text lines.
|
|
227
|
+
*
|
|
228
|
+
* Both known shapes are ordinary UTF-8 text — a `.jsonl` operation log is
|
|
229
|
+
* one JSON record per line by construction, and a `.json` flat snapshot is
|
|
230
|
+
* still real text (pretty-printed or not) rather than a binary format — so
|
|
231
|
+
* the same streamed readline/promises approach claude-code.js and cline.js
|
|
232
|
+
* use applies unchanged: no whole-file-as-one-string V8 length ceiling, and
|
|
233
|
+
* a partial read still returns whatever lines WERE read rather than
|
|
234
|
+
* discarding real content. A minified single-line `.json` file just becomes
|
|
235
|
+
* one long "line," bounded the same way by MAX_BYTES.
|
|
236
|
+
*
|
|
237
|
+
* Status vocabulary matches every other source in this project: "complete",
|
|
238
|
+
* "partial", "too-large", "failed".
|
|
239
|
+
*/
|
|
240
|
+
async function readLines(file) {
|
|
241
|
+
let stat;
|
|
242
|
+
try { stat = fs.statSync(file); }
|
|
243
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
244
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
245
|
+
|
|
246
|
+
const lines = [];
|
|
247
|
+
let bytesRead = 0;
|
|
248
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
249
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
250
|
+
|
|
251
|
+
// Same rationale as claude-code.js: no natural timeout exists anywhere in
|
|
252
|
+
// Node's stream/readline stack, and a retargeted symlink can make the
|
|
253
|
+
// underlying open() block forever with no event ever firing. Destroying
|
|
254
|
+
// the stream is what actually unblocks that.
|
|
255
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
for await (const line of rl) {
|
|
259
|
+
lines.push(line);
|
|
260
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
|
|
261
|
+
}
|
|
262
|
+
return { lines, status: "complete", bytesRead };
|
|
263
|
+
} catch {
|
|
264
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
265
|
+
} finally {
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
rl.close();
|
|
268
|
+
stream.destroy();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = { id, label, available, files, readLines };
|