@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.
@@ -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
- lines.push(` ${"reserve".padEnd(13)} ${approx(report.reserveTokens).padStart(7)} ` +
72
- t.muted(" system prompt + tool schemas"));
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("");
@@ -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
@@ -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";
@@ -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
+ }