@cruxy/cli 1.11.3 → 1.11.5

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.
@@ -359,15 +359,16 @@ export async function executeRun(promptParts, opts) {
359
359
  }) ?? undefined;
360
360
  // The TUI sidebar lists the same tree the `--resume` picker reads (P2).
361
361
  //
362
- // A FRESH SESSION IS NOT IN ITS OWN SIDEBAR UNTIL TURN 1 (#257). This used to
363
- // be populated after the log was opened precisely so the running session
364
- // listed itself; the meta line is buffered now, so there is nothing on disk
365
- // to list until something is said. That is the more honest reading the
366
- // sidebar and the `--resume` picker show the same tree, and a session with no
367
- // conversation in it is not one the picker would offer either. The active id
368
- // is still passed, so the row marks itself the moment it appears.
362
+ // A PROBE, NOT A LIST (cli#300). A fresh session is not on disk until its
363
+ // first turn the meta line is buffered (#257) — so a list read here could
364
+ // never contain the session the user is in, and with no later caller the
365
+ // sidebar stayed exactly as stale as that first read for the whole run. The
366
+ // renderer re-runs this probe after every turn, off its paint path, so the
367
+ // running session appears the moment it lands and the tree keeps following
368
+ // what this and any other cruxy write. The active id marks the row when it
369
+ // shows; the sidebar and the picker still read the same ten.
369
370
  if (renderer instanceof TuiRenderer) {
370
- renderer.setSessions(listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
371
+ renderer.attachSessions(() => listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
371
372
  }
372
373
  // ONE key reader for the whole TUI session (P5): the input loop and the
373
374
  // approval prompt's modal both lease it, so the two can never be live on
@@ -1247,6 +1247,45 @@ export function memoryInvalid(detail) {
1247
1247
  ],
1248
1248
  });
1249
1249
  }
1250
+ /**
1251
+ * A memory write was refused because the scope's existing store could not be
1252
+ * loaded, so the write would have replaced everything it held with the one
1253
+ * new note and reported success (cli#327). The same shape as
1254
+ * {@link credentialsUnprotected}: nothing is written, the file is named, and
1255
+ * the next step depends on WHICH state the file is in —
1256
+ *
1257
+ * - `unreadable`: the bytes could not be read at all (permissions, a
1258
+ * directory at the path). Fix the path; the content is presumably intact.
1259
+ * - `invalid`: the bytes were read but are not a memory document — a
1260
+ * half-written file (a crash or a concurrent writer mid-write) or a hand
1261
+ * edit that broke the JSON or the shape. Repair or clear it.
1262
+ *
1263
+ * An ABSENT file is not this error: it is the one state where starting empty
1264
+ * is correct, and conflating it with these two is what caused the wipe.
1265
+ */
1266
+ export function memoryStoreUnloadable(scope, state, path, detail, underlying) {
1267
+ const title = state === "unreadable"
1268
+ ? `refusing to write memory over a store that could not be read: ${path}`
1269
+ : `refusing to write memory over a store that is not a valid memory file: ${path}`;
1270
+ const nextSteps = state === "unreadable"
1271
+ ? [
1272
+ "nothing was written — the existing notes are presumably intact, but they could not be read",
1273
+ `make ${path} a regular file readable and writable by you, then retry`,
1274
+ ]
1275
+ : [
1276
+ "nothing was written — saving would have replaced every note the file holds",
1277
+ `inspect ${path}: a half-written or hand-edited file; restore it from a backup or your editor, then retry`,
1278
+ `or start the scope empty with \`cruxy memory clear --scope ${scope}\` (this discards whatever the file held)`,
1279
+ ];
1280
+ return new CruxyError({
1281
+ code: ErrorCode.MemoryStoreUnloadable,
1282
+ title,
1283
+ cause: detail,
1284
+ nextSteps,
1285
+ underlying,
1286
+ meta: { path, scope, state },
1287
+ });
1288
+ }
1250
1289
  // ── usage telemetry (exit 2) — C.22 ───────────────────────────────────────────
1251
1290
  /**
1252
1291
  * The local usage store (`~/.cruxy/usage/runs.json`) is corrupt or unreadable
@@ -172,6 +172,11 @@ export const ErrorCode = {
172
172
  /** A memory write was refused because the content matched a secret shape —
173
173
  * secrets are never persisted (defense in depth over C.17). */
174
174
  MemorySecret: "CRUXY_E_MEMORY_SECRET",
175
+ /** A memory write was refused because the scope's existing store could not
176
+ * be loaded — unreadable, or readable but not a valid memory document (a
177
+ * torn or hand-broken file). Writing would replace everything it held with
178
+ * the one new note and report success (cli#327). Nothing is written. */
179
+ MemoryStoreUnloadable: "CRUXY_E_MEMORY_STORE_UNLOADABLE",
175
180
  // usage telemetry (exit 2) — C.22
176
181
  /** The local usage store is corrupt/unreadable — the read is SKIPPED and this
177
182
  * is surfaced; never fatal to a run (usage display is best-effort). */
@@ -388,6 +393,7 @@ const EXIT_CODES = {
388
393
  [ErrorCode.MemoryInvalid]: 14,
389
394
  [ErrorCode.MemoryUntrusted]: 14,
390
395
  [ErrorCode.MemorySecret]: 14,
396
+ [ErrorCode.MemoryStoreUnloadable]: 14,
391
397
  // Usage telemetry (C.22). A corrupt store is a usage/data problem the user can
392
398
  // fix (delete the file); it shares the usage exit code and is never fatal to a
393
399
  // run — the aggregation just skips it.
@@ -58,7 +58,11 @@ export const rememberTool = {
58
58
  // Fail loud to the model: a refused secret / invalid note is reported with
59
59
  // its stable code so the model knows it was NOT stored (never a silent no-op).
60
60
  if (CruxyError.is(err)) {
61
- return { ok: false, error: `${err.code}: ${err.title}` };
61
+ // The next steps ride along so the model can tell the user what to do
62
+ // (cli#327: "refusing to write over a store that could not be read"
63
+ // is only actionable with the file and the fix named).
64
+ const steps = err.nextSteps.length > 0 ? ` — ${err.nextSteps.join("; ")}` : "";
65
+ return { ok: false, error: `${err.code}: ${err.title}${steps}` };
62
66
  }
63
67
  return { ok: false, error: err.message };
64
68
  }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
- import { memoryInvalid, memorySecretRefused } from "../errors/index.js";
3
+ import { memoryInvalid, memorySecretRefused, memoryStoreUnloadable, } from "../errors/index.js";
4
4
  import { buildRecallBlock } from "./recall.js";
5
5
  import { containsSecret } from "./secrets.js";
6
6
  import { defaultMemorySources, loadScope, saveScope, } from "./store.js";
@@ -78,7 +78,7 @@ export class MemoryService {
78
78
  createdAt: this.now(),
79
79
  };
80
80
  const file = this.sources[scope];
81
- const existing = loadScope(file, scope).entries;
81
+ const existing = this.loadForWrite(file, scope).entries;
82
82
  if (scope === "project") {
83
83
  // Snapshot trust BEFORE the write — this is the launder guard.
84
84
  const wasTrustedOrEmpty = existing.length === 0 || this.projectTrusted(existing);
@@ -113,7 +113,7 @@ export class MemoryService {
113
113
  forget(id) {
114
114
  for (const scope of ["user", "project"]) {
115
115
  const file = this.sources[scope];
116
- const entries = loadScope(file, scope).entries;
116
+ const entries = this.loadForWrite(file, scope).entries;
117
117
  const kept = entries.filter((e) => e.id !== id);
118
118
  if (kept.length === entries.length)
119
119
  continue;
@@ -131,6 +131,10 @@ export class MemoryService {
131
131
  let removed = 0;
132
132
  for (const s of scopes) {
133
133
  const file = this.sources[s];
134
+ // Deliberately NOT `loadForWrite`: clearing is the documented recovery
135
+ // for a store that cannot be loaded (cli#327). Emptying it is the
136
+ // user's explicit intent here, so an unloadable file counts as 0
137
+ // removed and is replaced with an empty document.
134
138
  const entries = loadScope(file, s).entries;
135
139
  removed += entries.length;
136
140
  saveScope(file, [], s);
@@ -150,10 +154,29 @@ export class MemoryService {
150
154
  * become recallable.
151
155
  */
152
156
  trustProject() {
153
- const entries = loadScope(this.sources.project, "project").entries;
157
+ // Trust is a fingerprint over the entry SET. Recording it for the empty
158
+ // set an unloadable file reads as would trust nothing and, once the file
159
+ // is repaired, mismatch its real content — so this refuses like a write.
160
+ const entries = this.loadForWrite(this.sources.project, "project").entries;
154
161
  this.recordProjectTrust(entries);
155
162
  return entries.length;
156
163
  }
164
+ /**
165
+ * Load a scope for a path that will WRITE the whole set back (cli#327).
166
+ * `absent` and `loaded` are fine; `unreadable` and `invalid` are refused
167
+ * with `CRUXY_E_MEMORY_STORE_UNLOADABLE`, because the "existing" set is
168
+ * empty only because the load failed, and saving it back would replace every
169
+ * note the file holds with whatever this call adds — while reporting success.
170
+ * A read-only path (`recall`, `status`) keeps using `loadScope` directly and
171
+ * surfaces the same condition as an error row instead.
172
+ */
173
+ loadForWrite(file, scope) {
174
+ const load = loadScope(file, scope);
175
+ if (load.file.kind === "unreadable" || load.file.kind === "invalid") {
176
+ throw memoryStoreUnloadable(scope, load.file.kind, file, load.file.message, load.file.underlying);
177
+ }
178
+ return load;
179
+ }
157
180
  recordProjectTrust(entries) {
158
181
  this.trust.record({
159
182
  root: this.cwd,
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globalDir } from "../config/paths.js";
4
4
  import { GLOBAL_DIR_NAME, MEMORY_DIR_NAME, MEMORY_FILE_NAME, } from "../constants.js";
@@ -13,46 +13,60 @@ export function defaultMemorySources(cwd) {
13
13
  };
14
14
  }
15
15
  /**
16
- * Read and validate one scope's file. A missing file yields an empty result
17
- * (not an error). Each entry is (1) schema-validated and (2) scanned for secret
18
- * content; failures are excluded and collected. The returned `entries` all carry
19
- * the requested `scope` (the on-disk `scope` field is normalized to it, so a
20
- * mislabeled entry can't cross scopes).
16
+ * Read and validate one scope's file. Each entry is (1) schema-validated and
17
+ * (2) scanned for secret content; failures are excluded and collected. The
18
+ * returned `entries` all carry the requested `scope` (the on-disk `scope` field
19
+ * is normalized to it, so a mislabeled entry can't cross scopes).
20
+ *
21
+ * THE FILE-LEVEL STATE IS REPORTED SEPARATELY (`file`, cli#327), because three
22
+ * different situations used to collapse into "empty, carry on":
23
+ *
24
+ * - a MISSING file (ENOENT) is `absent` — the one state where starting from
25
+ * an empty set is right;
26
+ * - any OTHER read failure (EACCES, EISDIR, ...) is `unreadable` — something
27
+ * is there and we could not see it;
28
+ * - bytes that are not a memory document (not JSON, or the wrong shape) are
29
+ * `invalid` — something was there and it is damaged, or half-written.
30
+ *
31
+ * Readers (recall, `cruxy memory list`) still get `entries: []` plus an error
32
+ * row for the last two, so nothing that only READS changes behaviour. Writers
33
+ * must branch on `file.kind`: see {@link saveScope}'s contract and
34
+ * `MemoryService.remember`.
21
35
  */
22
36
  export function loadScope(file, scope) {
23
37
  let raw;
24
38
  try {
25
39
  raw = readFileSync(file, "utf8");
26
40
  }
27
- catch {
28
- // Missing / unreadable → nothing recalled from this scope (not an error).
29
- return { entries: [], errors: [] };
41
+ catch (err) {
42
+ if (err.code === "ENOENT") {
43
+ // Missing nothing recalled from this scope, and a write may create it.
44
+ return { entries: [], errors: [], file: { kind: "absent" } };
45
+ }
46
+ // Present but unreadable. NOT absent: a writer that treated it as absent
47
+ // would replace whatever is there.
48
+ const message = `unreadable: ${err.message}`;
49
+ return {
50
+ entries: [],
51
+ errors: [{ scope, reason: "unreadable", id: "(file)", message }],
52
+ file: { kind: "unreadable", message, underlying: err },
53
+ };
30
54
  }
31
55
  let parsedFile;
32
56
  try {
33
57
  parsedFile = JSON.parse(raw);
34
58
  }
35
- catch {
36
- return {
37
- entries: [],
38
- errors: [
39
- { scope, reason: "invalid", id: "(file)", message: "not valid JSON" },
40
- ],
41
- };
59
+ catch (err) {
60
+ // Read fine, but not JSON: a half-written file (a crash or another
61
+ // writer mid-`writeFileSync`) or a hand edit. Either way it HELD something.
62
+ const message = raw.length === 0
63
+ ? "not valid JSON (the file is empty a truncated or half-written store)"
64
+ : "not valid JSON (a half-written or hand-edited store)";
65
+ return invalidFile(scope, message, err);
42
66
  }
43
67
  const fileResult = MemoryFileSchema.safeParse(parsedFile);
44
68
  if (!fileResult.success) {
45
- return {
46
- entries: [],
47
- errors: [
48
- {
49
- scope,
50
- reason: "invalid",
51
- id: "(file)",
52
- message: `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`,
53
- },
54
- ],
55
- };
69
+ return invalidFile(scope, `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`);
56
70
  }
57
71
  const entries = [];
58
72
  const errors = [];
@@ -86,15 +100,72 @@ export function loadScope(file, scope) {
86
100
  // move an entry across scopes).
87
101
  entries.push({ ...parsed.data, scope });
88
102
  });
89
- return { entries, errors };
103
+ return { entries, errors, file: { kind: "loaded" } };
104
+ }
105
+ function invalidFile(scope, message, underlying) {
106
+ const file = { kind: "invalid", message, underlying };
107
+ return {
108
+ entries: [],
109
+ errors: [{ scope, reason: "invalid", id: "(file)", message }],
110
+ file,
111
+ };
90
112
  }
91
113
  /**
92
- * Persist a scope's entries, overwriting the file. Creates the memory dir if
93
- * needed. The user scope is written `0600` (it is personal, cross-project data);
94
- * the project scope inherits normal repo permissions (it may be committed).
114
+ * Persist a scope's entries, replacing the file. Creates the memory dir if
115
+ * needed. The user scope is written `0600` (it is personal, cross-project data).
116
+ * The project scope KEEPS THE MODE THE FILE ALREADY HAD, and takes the process
117
+ * umask only when creating it: a replace through rename is a new inode, and
118
+ * letting it land at whatever umask says would be a permission change the user
119
+ * never asked for, arriving through a write path — the same reason `api.env`'s
120
+ * mode has to survive a `sed`.
121
+ *
122
+ * WRITTEN TO A TEMP FILE AND RENAMED INTO PLACE (cli#327), the `credentials.ts`
123
+ * pattern. A plain `writeFileSync` on an existing path truncates first and
124
+ * fills in after, so anything reading in between — recall in a second cruxy,
125
+ * `cruxy memory list`, or another writer's load — saw an empty or half-written
126
+ * file. `rename(2)` swaps the whole document in one step: a reader sees the
127
+ * old bytes or the new ones, never a prefix. It does NOT serialize two writers
128
+ * (that is #304, the read-modify-write race); it only guarantees that what
129
+ * either of them reads is a complete document.
130
+ *
131
+ * CONTRACT FOR CALLERS: this overwrites whatever is at `file`. It must only be
132
+ * called with a set derived from a load whose `file.kind` was `loaded` or
133
+ * `absent` — never `unreadable` or `invalid`, where the caller's "existing"
134
+ * set is empty only because the load failed. `MemoryService` enforces that;
135
+ * `clear` is the deliberate exception (it is the documented recovery).
95
136
  */
96
137
  export function saveScope(file, entries, scope) {
97
138
  mkdirSync(path.dirname(file), { recursive: true });
98
139
  const body = JSON.stringify({ version: MEMORY_FILE_VERSION, entries }, null, 2);
99
- writeFileSync(file, body, scope === "user" ? { mode: 0o600 } : undefined);
140
+ // The mode the replacement must carry: explicit 0600 for the user scope;
141
+ // for the project scope, whatever the existing file has (absent → umask).
142
+ const mode = scope === "user" ? 0o600 : existingMode(file);
143
+ const tmp = `${file}.tmp-${process.pid}`;
144
+ try {
145
+ writeFileSync(tmp, body, mode !== undefined ? { mode } : undefined);
146
+ // `writeFileSync`'s mode is masked by the umask and applies only on create;
147
+ // chmod pins the exact bits on the temp BEFORE it becomes the file.
148
+ if (mode !== undefined)
149
+ chmodSync(tmp, mode);
150
+ renameSync(tmp, file); // atomic replace; the mode moves with the inode
151
+ }
152
+ catch (err) {
153
+ try {
154
+ rmSync(tmp, { force: true });
155
+ }
156
+ catch {
157
+ // The temp may already be gone; the target file is untouched regardless.
158
+ }
159
+ throw err;
160
+ }
161
+ }
162
+ /** The permission bits of `file`, or `undefined` when there is nothing there
163
+ * to preserve (the create case takes the umask, as before). */
164
+ function existingMode(file) {
165
+ try {
166
+ return statSync(file).mode & 0o777;
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
100
171
  }
@@ -219,14 +219,21 @@ export class TuiRenderer {
219
219
  /**
220
220
  * Saved sessions shown in the sidebar (P2), and which one is running.
221
221
  *
222
- * Populated only by {@link setSessions}, never at construction. The running
223
- * session has to be listed, and its meta line does not exist until the
224
- * session log is open which happens after the renderer is built. A
225
- * constructor argument could therefore only ever carry the list from BEFORE
226
- * this run, i.e. a sidebar missing the very session the user is in.
222
+ * Populated by {@link setSessions} or by the probe {@link attachSessions}
223
+ * installs, never at construction: the list is read from disk, the renderer
224
+ * is built before the session log exists, and a constructor argument could
225
+ * only ever carry the tree from BEFORE this run.
226
+ *
227
+ * THE LIST IS RE-READ AFTER EVERY TURN (cli#300). It used to be read once at
228
+ * startup, and since a fresh session's meta line is buffered until its first
229
+ * turn (#257), that one read could never include the session the user was
230
+ * in — the row the active id exists to mark. Re-probing on `endTurn`, the
231
+ * same trigger the git cache and the views use, is what lets it appear.
227
232
  */
228
233
  sessions = [];
229
234
  activeSessionId;
235
+ /** Re-reads the session tree; installed by {@link attachSessions}. */
236
+ sessionsProbe;
230
237
  constructor(caps, out, opts = {}) {
231
238
  this.caps = caps;
232
239
  this.out = out;
@@ -573,9 +580,9 @@ export class TuiRenderer {
573
580
  }
574
581
  // ── app-owned surfaces ────────────────────────────────────────────────────
575
582
  /**
576
- * Replace the sidebar's session list (P2). Called once the session log is
577
- * open, so the running session is itself listed; a live refresh later (P3)
578
- * is the same call.
583
+ * Replace the sidebar's session list (P2) with one the caller already holds.
584
+ * The primitive {@link attachSessions} and {@link refreshSessions} both land
585
+ * on; a caller with no probe to install (tests, a static list) uses it alone.
579
586
  */
580
587
  setSessions(sessions, activeSessionId) {
581
588
  if (this.closed)
@@ -584,6 +591,46 @@ export class TuiRenderer {
584
591
  this.activeSessionId = activeSessionId;
585
592
  this.schedulePaint();
586
593
  }
594
+ /**
595
+ * Install the probe that reads the session tree, and read it once now
596
+ * (cli#300). From here on {@link endTurn} re-runs it, so the list follows the
597
+ * tree as this and other cruxys write to it — and so the running session
598
+ * shows up the moment its first turn puts it on disk.
599
+ *
600
+ * THE PROBE NEVER RUNS ON THE PAINT PATH. It reads one meta line per file
601
+ * for at most ten files, which is cheap, and cheap is not the discipline:
602
+ * `viewModel` reads `this.sessions` and nothing else, exactly as it reads
603
+ * the git cache's last answer rather than running `git status`. A paint
604
+ * that touched the disk would do so on every streaming frame.
605
+ */
606
+ attachSessions(probe, activeSessionId) {
607
+ if (this.closed)
608
+ return;
609
+ this.sessionsProbe = probe;
610
+ this.activeSessionId = activeSessionId;
611
+ this.refreshSessions();
612
+ }
613
+ /**
614
+ * Re-read the session tree through the installed probe, off the paint path,
615
+ * and repaint if it answered. A probe that throws leaves the last list
616
+ * standing: the sidebar is a status surface, and a transient read error must
617
+ * never blank it or take the shell down. No probe → nothing to do, and the
618
+ * list set by {@link setSessions} stays as it was.
619
+ */
620
+ refreshSessions() {
621
+ const probe = this.sessionsProbe;
622
+ if (probe === undefined || this.closed)
623
+ return;
624
+ let sessions;
625
+ try {
626
+ sessions = probe();
627
+ }
628
+ catch {
629
+ return;
630
+ }
631
+ this.sessions = sessions;
632
+ this.schedulePaint();
633
+ }
587
634
  /** Set the input line's rendered text (prompt + buffer + caret). */
588
635
  setInput(line) {
589
636
  if (this.closed)
@@ -865,6 +912,10 @@ export class TuiRenderer {
865
912
  this.refreshGit();
866
913
  this.refreshLimits();
867
914
  this.refreshViews();
915
+ // AFTER the turn's final paint, like the three above: the frame that
916
+ // closes the turn is composed from state already in hand, and the tree
917
+ // read lands in the next scheduled paint rather than delaying this one.
918
+ this.refreshSessions();
868
919
  }
869
920
  /**
870
921
  * REPAINT the headroom panel once the turn's reading lands (P9) — no longer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.11.3",
3
+ "version": "1.11.5",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {