@cruxy/cli 1.8.0 → 1.9.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.
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { modeFromFlags } from "../agent/mode.js";
3
3
  import { redactMessages } from "./redact.js";
4
- import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
4
+ import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, } from "./types.js";
5
5
  /**
6
6
  * Replay: fold an append-only event log back into the state a session needs to
7
7
  * resume (P2).
@@ -11,8 +11,7 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
11
11
  * the array the model last saw, including the synthetic compaction summaries.
12
12
  * Nothing is re-derived or re-summarised on the way back in.
13
13
  *
14
- * TOLERANCE IS THE POINT. A line that will not parse is SKIPPED and counted,
15
- * never fatal:
14
+ * TOLERANCE IS THE POINT. A line that will not parse is SKIPPED, never fatal:
16
15
  * - a torn final line from a crash mid-append would otherwise cost the whole
17
16
  * conversation (this is the failure temp-then-rename buys off at write time;
18
17
  * an append-only log buys it off here instead);
@@ -20,8 +19,25 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
20
19
  * heard of is skipped rather than rejected, which together with the
21
20
  * `.passthrough()` schemas is what keeps two CLI versions able to share one
22
21
  * home directory.
23
- * `SessionState.skipped` carries the count so a caller can say so out loud
24
- * rather than presenting a partial history as complete.
22
+ *
23
+ * THE TWO ARE COUNTED APART (#172 item 2), because they are not the same fact
24
+ * and the caller says something different about each. A damaged line means
25
+ * content was LOST — "the restored history may be incomplete" is warranted. An
26
+ * unknown kind means content is not visible HERE while the file is perfectly
27
+ * intact.
28
+ *
29
+ * Conflating them was survivable only while unknown kinds were rare. The
30
+ * `resumed` event is written on EVERY reopen, so an older build sharing a home
31
+ * directory would have announced possible history loss on every single resume —
32
+ * a forward-compatibility mechanism producing a corruption warning as routine
33
+ * output. `SessionState.skipped` now counts only damage;
34
+ * `SessionState.unknownEvents` counts the rest.
35
+ *
36
+ * This fixes readers from this build forward. An ALREADY-SHIPPED cruxy has the
37
+ * old reader and will still say "unreadable lines" when it meets a `resumed`
38
+ * event; nothing here can reach it. That is the cost of the split landing with
39
+ * the event rather than before it, and it is bounded — it misreports, it does
40
+ * not lose anything.
25
41
  */
26
42
  /**
27
43
  * The one cast in this module, isolated and explained.
@@ -36,20 +52,37 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
36
52
  function asMessages(validated) {
37
53
  return validated;
38
54
  }
39
- /** Parse one line, or `null` when it is unusable (torn, or an unknown kind). */
55
+ /**
56
+ * Classify one line.
57
+ *
58
+ * The distinction that matters is inside the failure case. A line that is valid
59
+ * JSON, is an object, and names a `kind` this build has never heard of is a
60
+ * NEWER cruxy's event — the file is intact and the forward-compatibility rule is
61
+ * working as designed. Anything else that fails is damage.
62
+ *
63
+ * A known kind with a payload that does not validate counts as DAMAGE, not as
64
+ * unknown: this build understands that kind, so failing to parse it means the
65
+ * line is wrong rather than merely new.
66
+ */
40
67
  function parseLine(line) {
41
68
  const trimmed = line.trim();
42
69
  if (trimmed === "")
43
- return null;
70
+ return { outcome: "blank" };
44
71
  let raw;
45
72
  try {
46
73
  raw = JSON.parse(trimmed);
47
74
  }
48
75
  catch {
49
- return null;
76
+ return { outcome: "damaged" }; // torn mid-write
50
77
  }
51
78
  const parsed = SessionEventSchema.safeParse(raw);
52
- return parsed.success ? parsed.data : null;
79
+ if (parsed.success)
80
+ return { outcome: "event", event: parsed.data };
81
+ const kind = raw?.kind;
82
+ if (typeof kind === "string" && !KNOWN_EVENT_KINDS.has(kind)) {
83
+ return { outcome: "unknown" };
84
+ }
85
+ return { outcome: "damaged" };
53
86
  }
54
87
  /**
55
88
  * Fold events into {@link SessionState}. Exported separately from file reading
@@ -60,7 +93,7 @@ function parseLine(line) {
60
93
  * which directory the history refers to, and resuming a conversation whose
61
94
  * origin is unknown is precisely what ruling 4 forbids.
62
95
  */
63
- export function foldEvents(events, skipped = 0) {
96
+ export function foldEvents(events, counts = {}) {
64
97
  const metaEvent = events.find((e) => e.kind === "meta");
65
98
  if (!metaEvent) {
66
99
  throw new Error("session log has no readable meta line");
@@ -76,6 +109,7 @@ export function foldEvents(events, skipped = 0) {
76
109
  let planMode = false;
77
110
  let mode = null;
78
111
  let redactions = 0;
112
+ const resumes = [];
79
113
  const usage = { input_tokens: 0, output_tokens: 0 };
80
114
  for (const event of events) {
81
115
  switch (event.kind) {
@@ -116,6 +150,12 @@ export function foldEvents(events, skipped = 0) {
116
150
  messages = redactMessages(messages).messages;
117
151
  redactions++;
118
152
  break;
153
+ case "resumed":
154
+ // Recorded, never folded into anything the model sees. A reopen is a
155
+ // fact ABOUT the conversation, not a turn in it — the whole reason it
156
+ // could be added without touching `meta` or the message array.
157
+ resumes.push({ at: event.at, cwd: event.cwd });
158
+ break;
119
159
  case "meta":
120
160
  break;
121
161
  }
@@ -128,7 +168,9 @@ export function foldEvents(events, skipped = 0) {
128
168
  // Auto-approve reads false there, which is right: it was not a thing that
129
169
  // could be on, so nothing is being inferred.
130
170
  mode: mode ?? modeFromFlags(planMode, false),
131
- skipped,
171
+ skipped: counts.skipped ?? 0,
172
+ unknownEvents: counts.unknownEvents ?? 0,
173
+ resumes,
132
174
  redactions,
133
175
  };
134
176
  }
@@ -137,21 +179,29 @@ export function readEvents(file) {
137
179
  const raw = readFileSync(file, "utf8");
138
180
  const events = [];
139
181
  let skipped = 0;
182
+ let unknownEvents = 0;
140
183
  for (const line of raw.split("\n")) {
141
- if (line.trim() === "")
142
- continue;
143
- const event = parseLine(line);
144
- if (event)
145
- events.push(event);
146
- else
147
- skipped++;
184
+ const parsed = parseLine(line);
185
+ switch (parsed.outcome) {
186
+ case "event":
187
+ events.push(parsed.event);
188
+ break;
189
+ case "unknown":
190
+ unknownEvents++;
191
+ break;
192
+ case "damaged":
193
+ skipped++;
194
+ break;
195
+ case "blank":
196
+ break;
197
+ }
148
198
  }
149
- return { events, skipped };
199
+ return { events, skipped, unknownEvents };
150
200
  }
151
201
  /** Read a session file and fold it into resumable state. */
152
202
  export function replaySession(file) {
153
- const { events, skipped } = readEvents(file);
154
- return foldEvents(events, skipped);
203
+ const { events, skipped, unknownEvents } = readEvents(file);
204
+ return foldEvents(events, { skipped, unknownEvents });
155
205
  }
156
206
  /**
157
207
  * Read ONLY the meta line — enough to list a session without folding its whole
@@ -1,6 +1,6 @@
1
1
  import { selectList } from "../components/index.js";
2
2
  import { usageError } from "../errors/index.js";
3
- import { findSession, isAmbiguous, listSessions } from "./list.js";
3
+ import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
4
4
  import { replaySession } from "./replay.js";
5
5
  /** How many sessions the bare-`--resume` picker offers. */
6
6
  export const PICKER_LIMIT = 10;
@@ -47,6 +47,28 @@ export function cwdMismatchWarning(state, cwd) {
47
47
  return (`this session was recorded in ${state.meta.cwd}, but you are in ${cwd} — ` +
48
48
  `its history refers to files and paths from the original directory`);
49
49
  }
50
+ /**
51
+ * Directories this session has run in BEFORE, other than the one we are
52
+ * resuming into now (#172 item 2). Returns a warning to print, or null.
53
+ *
54
+ * Distinct from {@link cwdMismatchWarning}, which compares where the
55
+ * conversation BEGAN against where it is being resumed. This compares where it
56
+ * has since RUN. The two disagree in exactly the case that motivated the
57
+ * `resumed` event: begin in `/a`, resume in `/b`, then resume in `/a` again —
58
+ * `meta.cwd` matches, so the mismatch check is silent, and yet the history now
59
+ * contains a whole stretch of work done against a different tree.
60
+ *
61
+ * Nothing could say this before, because nothing recorded it. Sessions written
62
+ * before the event simply have no `resumes` and are silent here — absence is
63
+ * "not recorded", never "did not happen".
64
+ */
65
+ export function priorDirectoriesWarning(state, cwd) {
66
+ const others = [...new Set(state.resumes.map((r) => r.cwd))].filter((dir) => dir !== cwd && dir !== state.meta.cwd);
67
+ if (others.length === 0)
68
+ return null;
69
+ return (`this session has also run in ${others.join(", ")} — ` +
70
+ `part of its history refers to files and paths from ${others.length === 1 ? "that directory" : "those directories"}`);
71
+ }
50
72
  /**
51
73
  * Load one session and collect its warnings. Throws a usage error when the
52
74
  * file cannot be replayed at all (no meta line) — an unreadable session is
@@ -67,10 +89,23 @@ export function loadResume(session, cwd) {
67
89
  const mismatch = cwdMismatchWarning(state, cwd);
68
90
  if (mismatch)
69
91
  warnings.push(mismatch);
92
+ const elsewhere = priorDirectoriesWarning(state, cwd);
93
+ if (elsewhere)
94
+ warnings.push(elsewhere);
70
95
  if (state.skipped > 0) {
71
96
  warnings.push(`${state.skipped} unreadable line${state.skipped === 1 ? "" : "s"} in the session log were skipped — ` +
72
97
  `the restored history may be incomplete`);
73
98
  }
99
+ if (state.unknownEvents > 0) {
100
+ // Deliberately NOT the sentence above. The file is intact; this build is
101
+ // simply older than whatever wrote those lines, and saying "unreadable"
102
+ // about a healthy log would send the user looking for damage that is not
103
+ // there. What IS true is that some of what the session recorded cannot be
104
+ // shown here.
105
+ warnings.push(`${state.unknownEvents} event${state.unknownEvents === 1 ? "" : "s"} in this session ` +
106
+ `${state.unknownEvents === 1 ? "was" : "were"} written by a newer cruxy and ${state.unknownEvents === 1 ? "is" : "are"} not shown — ` +
107
+ `the conversation itself is complete; upgrade to see the rest`);
108
+ }
74
109
  if (state.redactions > 0) {
75
110
  // Said on resume because the alternative is a user finding `[redacted …]`
76
111
  // in a transcript and not knowing whether cruxy did it or the model wrote
@@ -82,27 +117,54 @@ export function loadResume(session, cwd) {
82
117
  return { session, state, warnings };
83
118
  }
84
119
  /**
85
- * Resolve `--resume <id>`. Fails loud on an unknown or ambiguous id rather than
86
- * silently starting a new session — the user named something specific.
120
+ * VALIDATE `--resume <id>` which session does this name? without loading it.
121
+ *
122
+ * Split from the loading half for two reasons, one of them ordering:
123
+ *
124
+ * 1. `executeRun` has to answer "is this a real id?" BEFORE the non-TTY guard,
125
+ * so `cruxy --resume no-such-id < /dev/null` complains about the id rather
126
+ * than about the terminal. It must not have to pay a full `replaySession`
127
+ * to find that out — a VALID id in that same position is still going to be
128
+ * told it needs a terminal, and reading a whole conversation only to throw
129
+ * it away is exactly the cost this split avoids.
130
+ * 2. Both questions — ambiguous? which one? — are now asked of ONE index. This
131
+ * used to build the full listing up to three times (`isAmbiguous`, then
132
+ * `findSession`, then a third time to name the collisions), each one a
133
+ * complete read and parse of every session file in the project.
134
+ *
135
+ * Fails loud on an unknown or ambiguous id rather than silently starting a new
136
+ * session — the user named something specific.
137
+ *
138
+ * The one file that matched IS summarized, so the caller still gets the title
139
+ * and turn count the resume line prints. That is one read, not N.
87
140
  */
88
- export function resumeById(cwd, id) {
89
- if (isAmbiguous(cwd, id)) {
90
- const matches = listSessions(cwd)
91
- .filter((s) => s.sessionId.startsWith(id))
92
- .map((s) => shortId(s.sessionId));
141
+ export function resolveSessionId(cwd, id) {
142
+ const matches = matchSessionRefs(listSessionRefs(cwd), id);
143
+ if (matches.length > 1) {
144
+ const names = matches.map((m) => shortId(m.sessionId)).join(", ");
93
145
  throw usageError(`\`${id}\` matches more than one session`, [
94
- `did you mean one of: ${matches.join(", ")}?`,
146
+ `did you mean one of: ${names}?`,
95
147
  "run `cruxy --resume` to pick from a list",
96
148
  ]);
97
149
  }
98
- const found = findSession(cwd, id);
99
- if (!found) {
150
+ const summary = matches.length === 1 ? summarizeSession(matches[0].file) : null;
151
+ if (!summary) {
100
152
  throw usageError(`no session \`${id}\` in this project`, [
101
153
  "run `cruxy --resume` to pick from recent sessions",
102
154
  "sessions are per-directory; check you are in the right one",
103
155
  ]);
104
156
  }
105
- return loadResume(found, cwd);
157
+ return summary;
158
+ }
159
+ /**
160
+ * Resolve `--resume <id>` all the way to restorable state: validate, then load.
161
+ *
162
+ * `executeRun` calls the two halves separately, so validation can precede the
163
+ * non-TTY guard; this composition is what everything else (and the tests) use
164
+ * where there is no ordering constraint to respect.
165
+ */
166
+ export function resumeById(cwd, id) {
167
+ return loadResume(resolveSessionId(cwd, id), cwd);
106
168
  }
107
169
  /**
108
170
  * Bare `--resume`: pick from the most recent sessions, or start a new one.
@@ -248,6 +248,41 @@ export const RedactEventSchema = z
248
248
  count: z.number().int().nonnegative().default(0),
249
249
  })
250
250
  .passthrough();
251
+ /**
252
+ * A session was REOPENED (#172 item 2) — `--resume` found this log and
253
+ * continued it.
254
+ *
255
+ * `meta` is written once and never again, on purpose: it records where and when
256
+ * the conversation BEGAN, and a second copy written from wherever it was
257
+ * resumed would make "the session's directory" ambiguous. That ruling stands.
258
+ * Its consequence was that a resume left no trace at all — a session started in
259
+ * one directory and continued in another looked, from the file, as though it
260
+ * had only ever run in the first.
261
+ *
262
+ * This event closes that without touching `meta`'s authority. `meta.cwd` is
263
+ * still where the conversation began; `resumed.cwd` is somewhere it has since
264
+ * run. A reader can tell the two apart because they are different kinds.
265
+ *
266
+ * SCOPE, stated because the obvious next step is deliberately NOT taken here:
267
+ * this makes a cross-directory resume AUDITABLE, not DISCOVERABLE. The file
268
+ * still lives under `projectKey(meta.cwd)`, and `listSessions` for the other
269
+ * directory does one `readdir` of its own project dir and will never see it.
270
+ * Surfacing it there needs a pointer written into the second directory, or a
271
+ * scan across every project — a separate decision with a real cost, not a
272
+ * side effect of recording the fact.
273
+ */
274
+ export const ResumedEventSchema = z
275
+ .object({
276
+ kind: z.literal("resumed"),
277
+ at: z.string(),
278
+ /** The primary root the session was resumed INTO. */
279
+ cwd: z.string(),
280
+ /** Roots declared on the resuming run — multi-root can differ per run. */
281
+ roots: z.array(RootRefSchema).default([]),
282
+ /** The build that reopened it; a session can outlive several. */
283
+ cliVersion: z.string().optional(),
284
+ })
285
+ .passthrough();
251
286
  /** Every event, discriminated on `kind`. */
252
287
  export const SessionEventSchema = z.discriminatedUnion("kind", [
253
288
  SessionMetaSchema,
@@ -258,4 +293,14 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
258
293
  SessionModeEventSchema,
259
294
  UsageEventSchema,
260
295
  RedactEventSchema,
296
+ ResumedEventSchema,
261
297
  ]);
298
+ /**
299
+ * Every `kind` this build understands, derived from the union itself so the two
300
+ * cannot drift.
301
+ *
302
+ * This exists so the reader can tell "a line I do not understand" from "a line
303
+ * that is damaged" — see `replay.ts`. Those are different facts about a file and
304
+ * they used to be counted as one.
305
+ */
306
+ export const KNOWN_EVENT_KINDS = new Set(SessionEventSchema.options.map((option) => option.shape.kind.value));
@@ -241,6 +241,15 @@ export function limitsPanelLines(theme, state, now = Date.now()) {
241
241
  switch (state.reason) {
242
242
  case "unauthenticated":
243
243
  return [theme.muted("not signed in"), theme.muted("run cruxy login")];
244
+ case "expired":
245
+ // The same remedy as "not signed in", but a different fact — and the
246
+ // fact is the point. "Sign-in expired" tells someone their setup was
247
+ // right and simply aged out; "not signed in" invites them to go looking
248
+ // for what they configured wrong.
249
+ return [
250
+ theme.warning("sign-in expired"),
251
+ theme.muted("run cruxy login"),
252
+ ];
244
253
  case "unsupported":
245
254
  // The gateway answered — it simply has no limits to report. Sending this
246
255
  // user to debug their network would be the wrong errand entirely.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "undici": "^6.21.0",
37
37
  "zod": "^3.23.8",
38
38
  "zod-to-json-schema": "^3.23.5",
39
- "@cruxy/sdk": "0.6.0"
39
+ "@cruxy/sdk": "0.7.0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"