@cruxy/cli 1.11.0 → 1.11.2
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 +12 -0
- package/dist/agent/context.js +6 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +4 -0
- package/dist/components/input.js +18 -1
- package/dist/components/keys.js +66 -3
- package/dist/config/schema.js +8 -1
- package/dist/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -0
- package/dist/render/context-view.js +12 -3
- package/dist/render/index.js +2 -1
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +66 -0
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/resume.js +22 -3
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/tools/file/apply-patch.js +49 -23
- package/dist/tools/file/edit-file.js +15 -1
- package/dist/tools/file/snapshot.js +63 -0
- package/dist/tools/file/write-file.js +26 -5
- package/dist/tui/app.js +30 -7
- package/dist/tui/approval-overlay.js +4 -1
- package/dist/tui/layout.js +25 -1
- package/dist/tui/panels.js +44 -6
- package/dist/tui/renderer.js +187 -14
- package/dist/tui/supports.js +15 -0
- package/dist/utils/logger.js +52 -6
- package/dist/utils/process-owner.js +107 -0
- package/package.json +3 -2
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
|
|
5
|
+
import { extract as tarExtract } from "tar";
|
|
6
|
+
/**
|
|
7
|
+
* Cruxy-owned download + extraction of the local embedding model.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists (#306): fastembed's own `retrieveModel` extracts the model
|
|
10
|
+
* tarball with `tar@6.2.1`, which carries a family of path-traversal /
|
|
11
|
+
* symlink-poisoning advisories, has no fixed 6.x, and cannot be overridden to
|
|
12
|
+
* tar 7 (fastembed does `import tar from "tar"`; tar 7 has no default export —
|
|
13
|
+
* the override was tested and breaks `cruxy index` at module link). fastembed
|
|
14
|
+
* 2.1.0 is its latest release and pins `tar ^6.2.0`.
|
|
15
|
+
*
|
|
16
|
+
* So cruxy provisions the model directory itself and hands fastembed an
|
|
17
|
+
* absolute path via `model: CUSTOM` + `modelAbsoluteDirPath`. In fastembed's
|
|
18
|
+
* `init`, `retrieveModel` sits on the other arm of a ternary on `CUSTOM`, so it
|
|
19
|
+
* is unreachable once we go through this module — tar 6 stays in the
|
|
20
|
+
* dependency tree (scanners will keep flagging it) but never runs.
|
|
21
|
+
*
|
|
22
|
+
* This code writes to every user's disk, so the archive is treated as hostile:
|
|
23
|
+
* - every entry is checked by an explicit allow-list (regular files and
|
|
24
|
+
* directories only; a single top-level directory named for the model; no
|
|
25
|
+
* `..`, absolute, drive-letter, backslash or NUL paths; bounded count and
|
|
26
|
+
* size) — never the library's defaults alone. A refused entry fails the
|
|
27
|
+
* whole extraction; it is never silently skipped.
|
|
28
|
+
* - download and extraction happen in a staging directory next to the final
|
|
29
|
+
* one; the final directory appears only via a rename after the required
|
|
30
|
+
* file set has been verified, so an interrupted run can never leave a
|
|
31
|
+
* directory that looks complete.
|
|
32
|
+
* - a completion marker records the verified file sizes; a cache hit requires
|
|
33
|
+
* the marker AND matching sizes, so a partial or tampered directory is
|
|
34
|
+
* redone rather than used.
|
|
35
|
+
*/
|
|
36
|
+
/** Model directory name inside the cache; also the archive's top-level dir. */
|
|
37
|
+
export const MODEL_NAME = "fast-bge-small-en-v1.5";
|
|
38
|
+
/** Where fastembed (and Qdrant's Python fastembed) fetch this archive from. */
|
|
39
|
+
export const MODEL_ARCHIVE_URL = `https://storage.googleapis.com/qdrant-fastembed/${MODEL_NAME}.tar.gz`;
|
|
40
|
+
/** The ONNX graph fastembed loads from the model directory. */
|
|
41
|
+
export const MODEL_ONNX_FILE = "model_optimized.onnx";
|
|
42
|
+
/**
|
|
43
|
+
* Files fastembed's `init` reads. The archive also ships `ort_config.json` and
|
|
44
|
+
* `vocab.txt`; they are extracted if present but not required.
|
|
45
|
+
*/
|
|
46
|
+
export const REQUIRED_FILES = [
|
|
47
|
+
"config.json",
|
|
48
|
+
MODEL_ONNX_FILE,
|
|
49
|
+
"special_tokens_map.json",
|
|
50
|
+
"tokenizer.json",
|
|
51
|
+
"tokenizer_config.json",
|
|
52
|
+
];
|
|
53
|
+
/** Written into the model dir after verification; absent ⇒ incomplete. */
|
|
54
|
+
export const MARKER_FILE = ".cruxy-model.json";
|
|
55
|
+
/** Compressed archive ceiling (the real one is ~77 MB). */
|
|
56
|
+
export const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
|
57
|
+
/** Sum of declared entry sizes ceiling (the real one is ~134 MB). */
|
|
58
|
+
export const MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024;
|
|
59
|
+
/** Entry-count ceiling (the real archive has 8). */
|
|
60
|
+
export const MAX_ENTRIES = 64;
|
|
61
|
+
/** Abort the download if no bytes arrive for this long. */
|
|
62
|
+
const IDLE_TIMEOUT_MS = 60_000;
|
|
63
|
+
/** Staging dirs older than this are leftovers of a dead run and are swept. */
|
|
64
|
+
const STALE_STAGING_MS = 6 * 60 * 60 * 1000;
|
|
65
|
+
/**
|
|
66
|
+
* Failure of the download / extraction / verification pipeline. `kind` lets
|
|
67
|
+
* the caller pick next steps: a `download` failure is the classic offline
|
|
68
|
+
* first run; `refused` means the archive contained an entry the allow-list
|
|
69
|
+
* rejected (retrying blindly is wrong); `incomplete` means the archive did not
|
|
70
|
+
* contain the required file set, or the extracted set failed verification.
|
|
71
|
+
*/
|
|
72
|
+
export class ModelCacheError extends Error {
|
|
73
|
+
kind;
|
|
74
|
+
constructor(kind, message, cause) {
|
|
75
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
76
|
+
this.name = "ModelCacheError";
|
|
77
|
+
this.kind = kind;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Ensure `<cacheDir>/<MODEL_NAME>` holds a verified, complete model and return
|
|
82
|
+
* that absolute path. Downloads and extracts only when the cache is missing or
|
|
83
|
+
* fails verification; never trusts a directory without its marker.
|
|
84
|
+
*/
|
|
85
|
+
export async function ensureModelDir(opts) {
|
|
86
|
+
const cacheDir = path.resolve(opts.cacheDir);
|
|
87
|
+
const modelDir = path.join(cacheDir, MODEL_NAME);
|
|
88
|
+
if (await isCompleteModelDir(modelDir))
|
|
89
|
+
return modelDir;
|
|
90
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
91
|
+
await sweepStaleStaging(cacheDir);
|
|
92
|
+
// Stage next to the destination so the final publish is a same-filesystem
|
|
93
|
+
// rename (atomic on POSIX; on Windows it either succeeds or throws).
|
|
94
|
+
const stagingDir = path.join(cacheDir, `.${MODEL_NAME}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
95
|
+
const archivePath = `${stagingDir}.tar.gz`;
|
|
96
|
+
try {
|
|
97
|
+
await fs.mkdir(stagingDir, { recursive: true });
|
|
98
|
+
await downloadArchive(opts.url ?? MODEL_ARCHIVE_URL, archivePath, opts.fetchImpl, opts.onProgress);
|
|
99
|
+
opts.onProgress?.({ phase: "extract" });
|
|
100
|
+
await extractArchive(archivePath, stagingDir);
|
|
101
|
+
opts.onProgress?.({ phase: "verify" });
|
|
102
|
+
const extracted = path.join(stagingDir, MODEL_NAME);
|
|
103
|
+
const files = await verifyModelDir(extracted);
|
|
104
|
+
await writeMarker(extracted, files);
|
|
105
|
+
await publish(extracted, modelDir);
|
|
106
|
+
return modelDir;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await fs.rm(archivePath, { force: true }).catch(() => { });
|
|
110
|
+
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => { });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* True only when the marker exists, parses, names this model, and every
|
|
115
|
+
* required file is a regular file whose size matches the marker. Anything
|
|
116
|
+
* else — a missing marker (a partial or legacy extraction), a size mismatch,
|
|
117
|
+
* a symlink where a file should be — is "incomplete" and gets redone.
|
|
118
|
+
*/
|
|
119
|
+
export async function isCompleteModelDir(modelDir) {
|
|
120
|
+
let marker;
|
|
121
|
+
try {
|
|
122
|
+
const raw = await fs.readFile(path.join(modelDir, MARKER_FILE), "utf8");
|
|
123
|
+
marker = JSON.parse(raw);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (!marker ||
|
|
129
|
+
marker.version !== 1 ||
|
|
130
|
+
marker.model !== MODEL_NAME ||
|
|
131
|
+
typeof marker.files !== "object" ||
|
|
132
|
+
marker.files === null) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
for (const name of REQUIRED_FILES) {
|
|
136
|
+
const expected = marker.files[name];
|
|
137
|
+
if (typeof expected !== "number" || expected <= 0)
|
|
138
|
+
return false;
|
|
139
|
+
const st = await fs.lstat(path.join(modelDir, name)).catch(() => null);
|
|
140
|
+
if (!st || !st.isFile() || st.size !== expected)
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// ── download ──────────────────────────────────────────────────────────────────
|
|
146
|
+
async function downloadArchive(url, dest, fetchImpl, onProgress) {
|
|
147
|
+
const doFetch = fetchImpl ?? undiciFetch;
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
let idle;
|
|
150
|
+
const armIdle = () => {
|
|
151
|
+
if (idle)
|
|
152
|
+
clearTimeout(idle);
|
|
153
|
+
idle = setTimeout(() => controller.abort(new Error(`no data for ${IDLE_TIMEOUT_MS}ms`)), IDLE_TIMEOUT_MS);
|
|
154
|
+
idle.unref?.();
|
|
155
|
+
};
|
|
156
|
+
let res;
|
|
157
|
+
try {
|
|
158
|
+
armIdle();
|
|
159
|
+
res = await doFetch(url, {
|
|
160
|
+
signal: controller.signal,
|
|
161
|
+
redirect: "follow",
|
|
162
|
+
// Honor HTTPS_PROXY / NO_PROXY for this fixed, public URL (the web tool
|
|
163
|
+
// deliberately does not; it has an SSRF guard to protect instead). The
|
|
164
|
+
// agent is built only when a proxy is configured: undici prints an
|
|
165
|
+
// "experimental" warning on construction, and non-proxy users should
|
|
166
|
+
// never see it.
|
|
167
|
+
...(fetchImpl || !proxyConfigured()
|
|
168
|
+
? {}
|
|
169
|
+
: { dispatcher: new EnvHttpProxyAgent() }),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
if (idle)
|
|
174
|
+
clearTimeout(idle);
|
|
175
|
+
throw new ModelCacheError("download", `downloading ${url} failed: ${messageOf(err)}`, err);
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
throw new ModelCacheError("download", `downloading ${url} failed: HTTP ${res.status}`);
|
|
180
|
+
}
|
|
181
|
+
const declared = Number(res.headers.get("content-length") ?? "");
|
|
182
|
+
if (Number.isFinite(declared) && declared > MAX_ARCHIVE_BYTES) {
|
|
183
|
+
throw new ModelCacheError("refused", `refused model archive: Content-Length ${declared} exceeds the ${MAX_ARCHIVE_BYTES}-byte ceiling`);
|
|
184
|
+
}
|
|
185
|
+
if (!res.body) {
|
|
186
|
+
throw new ModelCacheError("download", `downloading ${url}: empty body`);
|
|
187
|
+
}
|
|
188
|
+
const total = Number.isFinite(declared) && declared > 0 ? declared : undefined;
|
|
189
|
+
const fh = await fs.open(dest, "w");
|
|
190
|
+
let received = 0;
|
|
191
|
+
try {
|
|
192
|
+
for await (const chunk of res.body) {
|
|
193
|
+
armIdle();
|
|
194
|
+
received += chunk.byteLength;
|
|
195
|
+
if (received > MAX_ARCHIVE_BYTES) {
|
|
196
|
+
controller.abort();
|
|
197
|
+
throw new ModelCacheError("refused", `refused model archive: body exceeds the ${MAX_ARCHIVE_BYTES}-byte ceiling`);
|
|
198
|
+
}
|
|
199
|
+
await fh.write(chunk);
|
|
200
|
+
onProgress?.({ phase: "download", received, total });
|
|
201
|
+
}
|
|
202
|
+
if (total !== undefined && received !== total) {
|
|
203
|
+
throw new ModelCacheError("download", `downloading ${url}: connection closed after ${received} of ${total} bytes`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (err instanceof ModelCacheError)
|
|
208
|
+
throw err;
|
|
209
|
+
throw new ModelCacheError("download", `downloading ${url} failed: ${messageOf(err)}`, err);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await fh.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
if (idle)
|
|
217
|
+
clearTimeout(idle);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// ── extraction ────────────────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Decide whether one archive entry may be written. Returns a reason string to
|
|
223
|
+
* refuse, or null to allow. Deliberately an allow-list: anything not proven
|
|
224
|
+
* safe is refused. Runs BEFORE tar's own checks, and independently of them.
|
|
225
|
+
*/
|
|
226
|
+
export function refuseEntry(entryPath, type, linkpath) {
|
|
227
|
+
if (type !== "File" && type !== "Directory") {
|
|
228
|
+
return `entry type ${type} is not allowed (${entryPath})`;
|
|
229
|
+
}
|
|
230
|
+
if (linkpath)
|
|
231
|
+
return `entry carries a link target (${entryPath})`;
|
|
232
|
+
if (entryPath.length === 0)
|
|
233
|
+
return "empty entry path";
|
|
234
|
+
if (entryPath.includes("\0"))
|
|
235
|
+
return "entry path contains NUL";
|
|
236
|
+
if (entryPath.includes("\\"))
|
|
237
|
+
return `entry path contains a backslash (${entryPath})`;
|
|
238
|
+
if (entryPath.startsWith("/"))
|
|
239
|
+
return `absolute entry path (${entryPath})`;
|
|
240
|
+
if (/^[A-Za-z]:/.test(entryPath))
|
|
241
|
+
return `drive-letter entry path (${entryPath})`;
|
|
242
|
+
const parts = entryPath.split("/");
|
|
243
|
+
// A directory entry may end with "/"; nothing else may have an empty segment.
|
|
244
|
+
if (type === "Directory" &&
|
|
245
|
+
parts.length > 1 &&
|
|
246
|
+
parts[parts.length - 1] === "") {
|
|
247
|
+
parts.pop();
|
|
248
|
+
}
|
|
249
|
+
for (const seg of parts) {
|
|
250
|
+
if (seg === "" || seg === "." || seg === "..") {
|
|
251
|
+
return `entry path has an empty, "." or ".." segment (${entryPath})`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (parts[0] !== MODEL_NAME) {
|
|
255
|
+
return `entry is outside the ${MODEL_NAME}/ directory (${entryPath})`;
|
|
256
|
+
}
|
|
257
|
+
if (type === "Directory" && parts.length !== 1) {
|
|
258
|
+
return `nested directory not allowed (${entryPath})`;
|
|
259
|
+
}
|
|
260
|
+
if (type === "File" && parts.length !== 2) {
|
|
261
|
+
return `file must sit directly under ${MODEL_NAME}/ (${entryPath})`;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
async function extractArchive(archivePath, cwd) {
|
|
266
|
+
const refused = [];
|
|
267
|
+
let entries = 0;
|
|
268
|
+
let declaredBytes = 0;
|
|
269
|
+
// On the extract path tar always hands a ReadEntry (Stats is the create-side
|
|
270
|
+
// shape of the same option), but keep the narrowing explicit.
|
|
271
|
+
const filter = (entryPath, entry) => {
|
|
272
|
+
entries += 1;
|
|
273
|
+
if (entries > MAX_ENTRIES) {
|
|
274
|
+
refused.push(`more than ${MAX_ENTRIES} entries`);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
if (!("header" in entry)) {
|
|
278
|
+
refused.push(`unexpected non-archive entry (${entryPath})`);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
const reason = refuseEntry(entryPath, String(entry.type), entry.linkpath ? String(entry.linkpath) : undefined);
|
|
282
|
+
if (reason) {
|
|
283
|
+
refused.push(reason);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
declaredBytes += Number(entry.size ?? 0);
|
|
287
|
+
if (declaredBytes > MAX_EXTRACTED_BYTES) {
|
|
288
|
+
refused.push(`declared size exceeds the ${MAX_EXTRACTED_BYTES}-byte ceiling`);
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
await tarExtract({
|
|
295
|
+
file: archivePath,
|
|
296
|
+
cwd,
|
|
297
|
+
// tar's own guards stay on as a second layer: strict turns its warnings
|
|
298
|
+
// (e.g. its independent ".." detection) into errors; preservePaths=false
|
|
299
|
+
// keeps its absolute-path stripping; no ownership or mode replay.
|
|
300
|
+
strict: true,
|
|
301
|
+
preservePaths: false,
|
|
302
|
+
preserveOwner: false,
|
|
303
|
+
noChmod: true,
|
|
304
|
+
noMtime: true,
|
|
305
|
+
filter,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (refused.length > 0) {
|
|
310
|
+
throw new ModelCacheError("refused", `refused model archive: ${refused.join("; ")}`, err);
|
|
311
|
+
}
|
|
312
|
+
throw new ModelCacheError("incomplete", `extracting the model archive failed: ${messageOf(err)}`, err);
|
|
313
|
+
}
|
|
314
|
+
if (refused.length > 0) {
|
|
315
|
+
throw new ModelCacheError("refused", `refused model archive: ${refused.join("; ")}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// ── verification + publish ────────────────────────────────────────────────────
|
|
319
|
+
/**
|
|
320
|
+
* Every required file must be a regular, non-empty file; nothing in the dir
|
|
321
|
+
* may be a symlink (belt and braces over the entry filter). Returns the size
|
|
322
|
+
* manifest for the marker.
|
|
323
|
+
*/
|
|
324
|
+
async function verifyModelDir(dir) {
|
|
325
|
+
const st = await fs.lstat(dir).catch(() => null);
|
|
326
|
+
if (!st || !st.isDirectory()) {
|
|
327
|
+
throw new ModelCacheError("incomplete", `model archive did not contain a ${MODEL_NAME}/ directory`);
|
|
328
|
+
}
|
|
329
|
+
const files = {};
|
|
330
|
+
for (const name of await fs.readdir(dir)) {
|
|
331
|
+
const s = await fs.lstat(path.join(dir, name));
|
|
332
|
+
if (s.isSymbolicLink()) {
|
|
333
|
+
throw new ModelCacheError("refused", `symlink found after extraction: ${name}`);
|
|
334
|
+
}
|
|
335
|
+
if (s.isFile())
|
|
336
|
+
files[name] = s.size;
|
|
337
|
+
}
|
|
338
|
+
const missing = REQUIRED_FILES.filter((n) => !(files[n] > 0));
|
|
339
|
+
if (missing.length > 0) {
|
|
340
|
+
throw new ModelCacheError("incomplete", `model archive is missing or has empty required file(s): ${missing.join(", ")}`);
|
|
341
|
+
}
|
|
342
|
+
return files;
|
|
343
|
+
}
|
|
344
|
+
async function writeMarker(dir, files) {
|
|
345
|
+
const marker = {
|
|
346
|
+
version: 1,
|
|
347
|
+
model: MODEL_NAME,
|
|
348
|
+
files,
|
|
349
|
+
extractedAt: new Date().toISOString(),
|
|
350
|
+
};
|
|
351
|
+
await fs.writeFile(path.join(dir, MARKER_FILE), JSON.stringify(marker, null, 2));
|
|
352
|
+
}
|
|
353
|
+
/** Rename the verified staging dir into place; tolerate a concurrent winner. */
|
|
354
|
+
async function publish(from, to) {
|
|
355
|
+
try {
|
|
356
|
+
await fs.rename(from, to);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
// Another process may have published a complete model meanwhile — accept
|
|
361
|
+
// it. Otherwise whatever is there failed verification: replace it.
|
|
362
|
+
if (await isCompleteModelDir(to))
|
|
363
|
+
return;
|
|
364
|
+
await fs.rm(to, { recursive: true, force: true });
|
|
365
|
+
try {
|
|
366
|
+
await fs.rename(from, to);
|
|
367
|
+
}
|
|
368
|
+
catch (err2) {
|
|
369
|
+
throw new ModelCacheError("incomplete", `could not move the verified model into ${to}: ${messageOf(err2)}`, err2 ?? err);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function sweepStaleStaging(cacheDir) {
|
|
374
|
+
const prefix = `.${MODEL_NAME}.tmp-`;
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
let names;
|
|
377
|
+
try {
|
|
378
|
+
names = await fs.readdir(cacheDir);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
for (const name of names) {
|
|
384
|
+
if (!name.startsWith(prefix))
|
|
385
|
+
continue;
|
|
386
|
+
const p = path.join(cacheDir, name);
|
|
387
|
+
const st = await fs.lstat(p).catch(() => null);
|
|
388
|
+
if (st && now - st.mtimeMs > STALE_STAGING_MS) {
|
|
389
|
+
await fs.rm(p, { recursive: true, force: true }).catch(() => { });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function proxyConfigured() {
|
|
394
|
+
const env = process.env;
|
|
395
|
+
return Boolean(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy);
|
|
396
|
+
}
|
|
397
|
+
function messageOf(err) {
|
|
398
|
+
return err instanceof Error ? err.message : String(err);
|
|
399
|
+
}
|
|
@@ -26,7 +26,11 @@ import { fit } from "./layout.js";
|
|
|
26
26
|
*
|
|
27
27
|
* The reserve is shown as its own row rather than folded into a total. It is
|
|
28
28
|
* part of `used`, it is present in no message, and a breakdown that omitted it
|
|
29
|
-
* would leave several thousand tokens looking unexplained.
|
|
29
|
+
* would leave several thousand tokens looking unexplained. It is labelled an
|
|
30
|
+
* ALLOWANCE (cli#4): a fixed config value standing in for the system prompt and
|
|
31
|
+
* tool schemas, not a measurement of them — and the row under it names the two
|
|
32
|
+
* ways the figure is actually inaccurate, so nobody has to discover them by
|
|
33
|
+
* being compacted early.
|
|
30
34
|
*/
|
|
31
35
|
/** `~34k`, `~900` — the leading tilde is not decoration. See the module note. */
|
|
32
36
|
function approx(n) {
|
|
@@ -68,8 +72,13 @@ export function contextReportLines(report, t, width = Infinity) {
|
|
|
68
72
|
}
|
|
69
73
|
// Named separately because it is real, unavoidable, and in no message — the
|
|
70
74
|
// one part of the figure a user cannot shrink by pruning the conversation.
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
// And named as what it is: a fixed allowance, not a count. The two lines
|
|
76
|
+
// after it are the honest footnote — what the allowance does not track, and
|
|
77
|
+
// what the denominator is not.
|
|
78
|
+
lines.push(` ${"allowance".padEnd(13)} ${approx(report.reserveTokens).padStart(7)} ` +
|
|
79
|
+
t.muted(" system prompt + tool schemas (a fixed setting, not measured)"));
|
|
80
|
+
lines.push(t.muted(" the allowance does not grow with CRUXY.md, memory, LSP, web or MCP schemas,"));
|
|
81
|
+
lines.push(t.muted(" and the budget is context.maxTokens — a config heuristic, not a limit read from the served tier"));
|
|
73
82
|
// ── the biggest single messages ───────────────────────────────────────────
|
|
74
83
|
if (report.largest.length > 0) {
|
|
75
84
|
lines.push("");
|
package/dist/render/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { TtyRenderer } from "./tty-renderer.js";
|
|
|
8
8
|
import { TuiRenderer } from "../tui/renderer.js";
|
|
9
9
|
import { GitStatusCache } from "../tui/git-status.js";
|
|
10
10
|
import { ToolVersions } from "../tui/tool-versions.js";
|
|
11
|
-
import { supportsTui, usesAltScreen } from "../tui/supports.js";
|
|
11
|
+
import { supportsTui, usesAltScreen, usesMouse } from "../tui/supports.js";
|
|
12
12
|
export { detectCapabilities, detectReducedMotion, resolveColumns, resolveRows, DEFAULT_COLUMNS, DEFAULT_ROWS, } from "./capabilities.js";
|
|
13
13
|
export { attachResize, processResizeSignal, } from "./resize.js";
|
|
14
14
|
export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
|
|
@@ -58,6 +58,7 @@ export function createRenderer(out = process.stdout, err = process.stderr, env =
|
|
|
58
58
|
// for a direct construction and wrong here: an injected env exists
|
|
59
59
|
// precisely so a caller can describe a terminal that is not this one.
|
|
60
60
|
altScreen: usesAltScreen(caps, env),
|
|
61
|
+
mouse: usesMouse(caps, env),
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
return caps.cursor
|
package/dist/session/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export { PROJECTS_DIR_NAME, RESERVED_SUBDIRS, SESSION_FILE_EXT, projectDir, projectKey, projectsDir, reservedDir, sessionFile, } from "./paths.js";
|
|
16
16
|
export { SessionLog } from "./log.js";
|
|
17
|
+
export { claimSession, describeHolder, ownerFile, readOwner, releaseSession, removeOwnerFile, sessionHeldBy, } from "./owner.js";
|
|
17
18
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
18
19
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
19
20
|
export { redactMessages } from "./redact.js";
|
package/dist/session/log.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { APP_VERSION } from "../constants.js";
|
|
4
4
|
import { formatBytes } from "../utils/disk.js";
|
|
5
5
|
import { sessionFile } from "./paths.js";
|
|
6
|
+
import { claimSession, describeHolder, releaseSession, sessionHeldBy, } from "./owner.js";
|
|
6
7
|
import { pruneSessions } from "./prune.js";
|
|
7
8
|
import { SESSION_FILE_VERSION, } from "./types.js";
|
|
8
9
|
/**
|
|
@@ -20,6 +21,8 @@ export class SessionLog {
|
|
|
20
21
|
currentRunId;
|
|
21
22
|
/** Set once a write fails: the log goes inert rather than warning per turn. */
|
|
22
23
|
broken = false;
|
|
24
|
+
/** Whether this process has stamped itself as the file's owner (P1). */
|
|
25
|
+
claimed = false;
|
|
23
26
|
/**
|
|
24
27
|
* A NEW session's `meta` line, held until the session records something.
|
|
25
28
|
* Null on a reopen (the file already has its meta) and null again the moment
|
|
@@ -77,6 +80,14 @@ export class SessionLog {
|
|
|
77
80
|
const log = new SessionLog(file, opts);
|
|
78
81
|
log.pruneOnce(opts);
|
|
79
82
|
if (hasContent(file)) {
|
|
83
|
+
// OWNERSHIP (P1). A reopen is the moment a second process would start
|
|
84
|
+
// interleaving its turns into this file, so the stamp is taken here,
|
|
85
|
+
// eagerly, like the `resumed` event below. `loadResume` has already
|
|
86
|
+
// refused a file another live cruxy holds; this is the claim that makes
|
|
87
|
+
// the NEXT resume see us. A fresh session claims at its first flush
|
|
88
|
+
// instead — see {@link write} — for the same reason `meta` is buffered:
|
|
89
|
+
// a conversation with nothing in it should leave nothing on disk.
|
|
90
|
+
log.claim();
|
|
80
91
|
log.write({
|
|
81
92
|
kind: "resumed",
|
|
82
93
|
at: new Date().toISOString(),
|
|
@@ -232,9 +243,46 @@ export class SessionLog {
|
|
|
232
243
|
this.pendingMeta = null;
|
|
233
244
|
if (!this.writeLine(meta))
|
|
234
245
|
return false;
|
|
246
|
+
this.claim(); // the file now exists — so can a second `--resume` of it
|
|
235
247
|
}
|
|
236
248
|
return this.writeLine(event);
|
|
237
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Stamp this process as the file's owner (P1 — see `owner.ts`). Idempotent.
|
|
252
|
+
* The stamp is released on process exit; a crash leaves it behind, and that
|
|
253
|
+
* is fine — a stamp is pid + start-time, so the next reader sees a dead
|
|
254
|
+
* owner and ignores it. Never fatal: an unwritable stamp is the pre-P1
|
|
255
|
+
* behaviour (no ownership), and a session must still run without one.
|
|
256
|
+
*/
|
|
257
|
+
claim() {
|
|
258
|
+
if (this.claimed)
|
|
259
|
+
return;
|
|
260
|
+
// Never overwrite a LIVE owner's stamp. `loadResume` refuses that file
|
|
261
|
+
// before we get here, so this is the guard for any other writer that
|
|
262
|
+
// opens a log — and for the race two resumes in the same instant would
|
|
263
|
+
// be. Recording continues (the format tolerates it); ownership does not
|
|
264
|
+
// move, so the third process to come along still sees the real holder.
|
|
265
|
+
const holder = sessionHeldBy(this.file);
|
|
266
|
+
if (holder) {
|
|
267
|
+
this.logger?.warn(`session is open in another cruxy (${describeHolder(holder)}) — this process is not taking it over`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
this.claimed = claimSession(this.file);
|
|
271
|
+
if (this.claimed)
|
|
272
|
+
releaseOnExit(this.file);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Give the file up: remove our stamp. The exit hook does this for the
|
|
276
|
+
* normal case; this is for a caller that ends a session while the process
|
|
277
|
+
* lives on (tests, and any future in-process session switch).
|
|
278
|
+
*/
|
|
279
|
+
close() {
|
|
280
|
+
if (!this.claimed)
|
|
281
|
+
return;
|
|
282
|
+
this.claimed = false;
|
|
283
|
+
releaseSession(this.file);
|
|
284
|
+
claimedFiles.delete(this.file);
|
|
285
|
+
}
|
|
238
286
|
/**
|
|
239
287
|
* Append one event as a single line. Returns whether it landed. The first
|
|
240
288
|
* failure warns and latches `broken`, so a persistent problem (a full disk)
|
|
@@ -255,6 +303,24 @@ export class SessionLog {
|
|
|
255
303
|
}
|
|
256
304
|
}
|
|
257
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Every session file this process has stamped, released together on exit.
|
|
308
|
+
* ONE listener for the process rather than one per log: a test opens dozens
|
|
309
|
+
* of logs, and `process.on` warns past ten listeners.
|
|
310
|
+
*/
|
|
311
|
+
const claimedFiles = new Set();
|
|
312
|
+
let exitHookInstalled = false;
|
|
313
|
+
function releaseOnExit(file) {
|
|
314
|
+
claimedFiles.add(file);
|
|
315
|
+
if (exitHookInstalled)
|
|
316
|
+
return;
|
|
317
|
+
exitHookInstalled = true;
|
|
318
|
+
process.on("exit", () => {
|
|
319
|
+
for (const f of claimedFiles)
|
|
320
|
+
releaseSession(f);
|
|
321
|
+
claimedFiles.clear();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
258
324
|
/**
|
|
259
325
|
* Whether `file` is an existing log with content — i.e. this is a reopen.
|
|
260
326
|
*
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { describeOwner, selfStamp, } from "../utils/process-owner.js";
|
|
5
|
+
import { SESSION_FILE_EXT } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* Session ownership across processes (P1).
|
|
8
|
+
*
|
|
9
|
+
* A session log is one append-only file, and `SessionLog` was built so that
|
|
10
|
+
* concurrent appenders interleave whole lines rather than tearing them. That
|
|
11
|
+
* keeps the file PARSEABLE under two writers; it does not keep it MEANINGFUL.
|
|
12
|
+
* Two processes that `--resume` the same id each replay the same history,
|
|
13
|
+
* each append their own turns, and the result is one transcript with two
|
|
14
|
+
* conversations shuffled into it — replayable, and wrong.
|
|
15
|
+
*
|
|
16
|
+
* The fix is ownership, and the minimum that prevents the observed failure is
|
|
17
|
+
* a stamp, not a lock:
|
|
18
|
+
*
|
|
19
|
+
* - `<id>.owner.json` beside the log holds the {@link ProcessStamp} of the
|
|
20
|
+
* cruxy that has it open. It is written when the session first lands on
|
|
21
|
+
* disk (a new session) or on reopen (a resume), and removed on exit.
|
|
22
|
+
* - `--resume` reads it BEFORE replaying. A stamp whose process is still
|
|
23
|
+
* running is a refusal; a stamp whose process is gone — or whose pid has
|
|
24
|
+
* been recycled onto something else — is ignored and overwritten.
|
|
25
|
+
*
|
|
26
|
+
* WHY A STAMP AND NOT A LOCK. A lock file that must be deleted to be released
|
|
27
|
+
* outlives every crash, and a lock that outlives a crash is worse than no
|
|
28
|
+
* lock: the next `--resume` is refused with nothing to refuse it for, and the
|
|
29
|
+
* user learns to `rm` it — at which point it stops meaning anything. A pid +
|
|
30
|
+
* start-time stamp is self-invalidating: liveness is decided by asking the OS,
|
|
31
|
+
* never by whether cleanup ran. See `utils/process-owner.ts` for why the pid
|
|
32
|
+
* marker refused for JOB logs is category-correct for sessions.
|
|
33
|
+
*
|
|
34
|
+
* WHY NOT A PROJECT-LEVEL LOCK. Several cruxy processes in one project is the
|
|
35
|
+
* multi-agent case, not a misuse of it. What must not happen is two of them
|
|
36
|
+
* on ONE session; the stamp is scoped to exactly that.
|
|
37
|
+
*/
|
|
38
|
+
const OwnerSchema = z.object({
|
|
39
|
+
pid: z.number().int().positive(),
|
|
40
|
+
token: z.string().min(1),
|
|
41
|
+
startedAt: z.string(),
|
|
42
|
+
/** When the stamp was written — for messages. */
|
|
43
|
+
claimedAt: z.string(),
|
|
44
|
+
});
|
|
45
|
+
/** `<id>.owner.json` next to `<id>.jsonl`. */
|
|
46
|
+
export function ownerFile(sessionFile) {
|
|
47
|
+
const dir = path.dirname(sessionFile);
|
|
48
|
+
const id = path.basename(sessionFile, SESSION_FILE_EXT);
|
|
49
|
+
return path.join(dir, `${id}.owner.json`);
|
|
50
|
+
}
|
|
51
|
+
/** The recorded owner, or null when there is none (absent, unreadable, malformed). */
|
|
52
|
+
export function readOwner(sessionFile) {
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = readFileSync(ownerFile(sessionFile), "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const parsed = OwnerSchema.safeParse(JSON.parse(raw));
|
|
62
|
+
return parsed.success ? parsed.data : null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Who has the session open right now, if anyone other than this process.
|
|
70
|
+
* A stale stamp is not a holder; neither is our own.
|
|
71
|
+
*/
|
|
72
|
+
export function sessionHeldBy(sessionFile) {
|
|
73
|
+
const owner = readOwner(sessionFile);
|
|
74
|
+
if (!owner)
|
|
75
|
+
return null;
|
|
76
|
+
return describeOwner(owner) === "live" ? owner : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Record this process as the session's owner. Temp-then-rename so a reader
|
|
80
|
+
* never sees a half-written stamp; `0600` like everything else in `~/.cruxy`.
|
|
81
|
+
* Never throws: a stamp that cannot be written degrades to the pre-P1
|
|
82
|
+
* behaviour (no ownership), and the session must still run.
|
|
83
|
+
*/
|
|
84
|
+
export function claimSession(sessionFile) {
|
|
85
|
+
const file = ownerFile(sessionFile);
|
|
86
|
+
const me = selfStamp();
|
|
87
|
+
const record = { ...me, claimedAt: new Date().toISOString() };
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
90
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
91
|
+
writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
92
|
+
renameSync(tmp, file);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Remove our stamp — only ours; a later owner's is left alone. Never throws. */
|
|
100
|
+
export function releaseSession(sessionFile) {
|
|
101
|
+
const owner = readOwner(sessionFile);
|
|
102
|
+
if (!owner || describeOwner(owner) !== "self")
|
|
103
|
+
return;
|
|
104
|
+
try {
|
|
105
|
+
unlinkSync(ownerFile(sessionFile));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// already gone, or unwritable — nothing to do either way
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Drop a session's stamp unconditionally — for deleting the session itself. */
|
|
112
|
+
export function removeOwnerFile(sessionFile) {
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(ownerFile(sessionFile));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// no stamp to remove
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** One line for a refusal or a picker row: which process, since when. */
|
|
121
|
+
export function describeHolder(stamp) {
|
|
122
|
+
return `pid ${stamp.pid}, started ${stamp.startedAt}`;
|
|
123
|
+
}
|