@cruxy/cli 1.7.1 → 1.8.1

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.
@@ -3,6 +3,7 @@ import { existsSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { COMMAND_CATALOG } from "./command-catalog.js";
5
5
  import { contextReport } from "../agent/context.js";
6
+ import { permissionsReport, revokedLine } from "../agent/permissions.js";
6
7
  import { buildSessionStatus } from "../agent/status.js";
7
8
  import { resolveCheckpointDiff } from "../checkpoint/diff.js";
8
9
  import { rollbackLatestRun } from "../checkpoint/run-rollback.js";
@@ -11,6 +12,7 @@ import { resolveSlash } from "../hooks/index.js";
11
12
  import { contextReportLines } from "../render/context-view.js";
12
13
  import { compactTokens } from "../render/units.js";
13
14
  import { renderUnifiedDiff } from "../render/index.js";
15
+ import { permissionsReportLines } from "../render/permissions-view.js";
14
16
  import { sessionStatusLines } from "../render/status-view.js";
15
17
  import { defaultExportName, exportMarkdown } from "../session/index.js";
16
18
  import { readDiskCapacitySync } from "../utils/disk.js";
@@ -104,6 +106,10 @@ export async function dispatchCommand(input, ctx) {
104
106
  handleStatus(ctx);
105
107
  return { kind: "handled" };
106
108
  }
109
+ if (trimmed === "/permissions" || trimmed.startsWith("/permissions ")) {
110
+ handlePermissions(trimmed, ctx);
111
+ return { kind: "handled" };
112
+ }
107
113
  if (trimmed === "/diff" || trimmed.startsWith("/diff ")) {
108
114
  await handleDiff(trimmed, ctx);
109
115
  return { kind: "handled" };
@@ -337,6 +343,59 @@ function handleStatus(ctx) {
337
343
  out.print(out.fit(line));
338
344
  }
339
345
  }
346
+ /**
347
+ * `/permissions` (cli#196) — what this session may do without asking, and the
348
+ * one way to take a standing grant back.
349
+ *
350
+ * REVOKE IS THE ARGUMENT FORM, not a selectable row. Views take no input: the
351
+ * TUI's key lease belongs to overlays, and a permissions screen that grabbed it
352
+ * to offer a cursor would be a drawer wearing a view's clothes. The argument
353
+ * form is also the one a user can script, a test can drive, and a REPL without
354
+ * a picker can still reach — `/permissions revoke 2` means the same thing in
355
+ * every shell.
356
+ *
357
+ * The number is the row number the list just printed. A `Scope` has no stable
358
+ * id to name instead, and inventing one would put a token on screen that exists
359
+ * for no other purpose; the user is pointing at a visible row, so the row's
360
+ * position is the honest handle. It is re-read from `list()` at the moment of
361
+ * the revoke, so a grant recorded since the last print shifts nothing — the
362
+ * order is append-only and a revoke only ever removes.
363
+ */
364
+ function handlePermissions(input, ctx) {
365
+ const { out, session } = ctx;
366
+ const t = out.theme;
367
+ const arg = input.slice("/permissions".length).trim();
368
+ if (arg !== "") {
369
+ const match = /^revoke\s+(\S+)$/.exec(arg);
370
+ if (!match) {
371
+ out.print(t.muted("usage: /permissions [revoke <n>]"));
372
+ return;
373
+ }
374
+ const allowlist = session.allowlist;
375
+ if (!allowlist) {
376
+ // Not "nothing to revoke": a session with no allowlist wired has no grant
377
+ // surface at all, and saying which is more useful than a failed lookup.
378
+ out.print(t.muted("this session does not track grants"));
379
+ return;
380
+ }
381
+ // Parsed strictly — `Number("2x")` is NaN but `parseInt("2x")` is 2, and a
382
+ // typo must never resolve to a row the user did not mean to drop.
383
+ const n = /^\d+$/.test(match[1]) ? Number(match[1]) : NaN;
384
+ const grant = Number.isNaN(n) ? null : allowlist.revoke(n - 1);
385
+ if (!grant) {
386
+ const count = allowlist.size();
387
+ out.print(t.muted(count === 0
388
+ ? "no standing grants to revoke"
389
+ : `no grant ${match[1]} — /permissions lists 1${count === 1 ? "" : `-${count}`}`));
390
+ return;
391
+ }
392
+ out.print(t.muted(revokedLine(grant)));
393
+ return;
394
+ }
395
+ for (const line of permissionsReportLines(permissionsReport(session), t)) {
396
+ out.print(out.fit(line));
397
+ }
398
+ }
340
399
  /**
341
400
  * `/diff` (P6 track 4) — what has changed on disk, without leaving the session.
342
401
  *
@@ -522,6 +522,10 @@ opts = {}) {
522
522
  onRunUsage,
523
523
  budget: sessionBudget,
524
524
  jobs: jobManager,
525
+ // The SAME allowlist the plan policy and the interactive policy share, so
526
+ // `/permissions` lists the grants this session's prompts actually spend —
527
+ // not a second list that agrees with them only by luck.
528
+ allowlist,
525
529
  recorder: opts.recorder,
526
530
  restore: opts.restore,
527
531
  });
@@ -1,8 +1,13 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globToRegexBody, isBinary } from "./util.js";
4
- /** Directories never descended into, regardless of ignore files. */
5
- const ALWAYS_IGNORE_DIRS = new Set([".git", "node_modules", ".cruxy"]);
4
+ /**
5
+ * Directories never descended into, regardless of ignore files. Exported because
6
+ * `checkpoint/coverage.ts` pairs each name with the reason a checkpoint cannot
7
+ * restore what is under it — a name added here without a reason there fails the
8
+ * build, so the walker cannot quietly widen what the ceiling still calls safe.
9
+ */
10
+ export const ALWAYS_IGNORE_DIRS = new Set([".git", "node_modules", ".cruxy"]);
6
11
  const DEFAULT_IGNORE_FILES = [".gitignore", ".cruxyignore"];
7
12
  /**
8
13
  * Hard denylist for secret-bearing files. Applied independently of ignore files
@@ -8,4 +8,4 @@
8
8
  * rendering lives in `tui/limits-panel.ts` — nothing here formats anything.
9
9
  */
10
10
  export { LimitsCache } from "./cache.js";
11
- export { reduceLimits, bindingWindow, usedFraction } from "./reduce.js";
11
+ export { reduceLimits, bindingWindow, scarcestWindow, usedFraction, } from "./reduce.js";
@@ -144,15 +144,26 @@ export function reduceLimits(res, readAt = Date.now()) {
144
144
  readAt,
145
145
  };
146
146
  }
147
+ /**
148
+ * TWO SELECTORS, TWO QUESTIONS. {@link bindingWindow} answers *which window will
149
+ * stop you first*; {@link scarcestWindow} answers *how much may you actually
150
+ * spend right now*. They are not the same question and they do not always pick
151
+ * the same window, so merging them would make one of the two answers wrong —
152
+ * see each function for which.
153
+ */
147
154
  /**
148
155
  * The window that will stop this user FIRST — the one the gate itself calls
149
156
  * binding: whichever of monthly/burst has the smaller REMAINING fraction
150
157
  * (`internal/budget/decide.go`).
151
158
  *
152
- * This is what earns the single bar the rail has room for. Drawing the month
153
- * because it is the bigger number would routinely show a comfortable 6% while
154
- * the trailing-12h window — a quarter of the month's cap on every self-serve
155
- * tier — is the one about to refuse the next request.
159
+ * FOR DISPLAY. This is what earns the single bar the rail has room for. Drawing
160
+ * the month because it is the bigger number would routinely show a comfortable
161
+ * 6% while the trailing-12h window — a quarter of the month's cap on every
162
+ * self-serve tier — is the one about to refuse the next request.
163
+ *
164
+ * The fraction is right HERE because a bar is a fraction: the pixels state
165
+ * proportion, so the window drawn must be the one whose proportion is the
166
+ * warning. It is wrong for admission, and {@link scarcestWindow} says why.
156
167
  */
157
168
  export function bindingWindow(monthly, burst) {
158
169
  if (!monthly)
@@ -164,6 +175,37 @@ export function bindingWindow(monthly, burst) {
164
175
  ? { window: burst, name: "burst" }
165
176
  : { window: monthly, name: "month" };
166
177
  }
178
+ /**
179
+ * The window with the least ABSOLUTE remaining — the ceiling an admission check
180
+ * must respect (cli#212).
181
+ *
182
+ * FOR ADMISSION. BOTH windows gate every request, so a draw of D is admitted
183
+ * only when `D ≤ monthly.remaining` AND `D ≤ burst.remaining` — which makes the
184
+ * spendable allowance exactly `min(remaining)`, a number of tokens rather than
185
+ * a proportion.
186
+ *
187
+ * WHY {@link bindingWindow} CANNOT SERVE HERE, concretely: a Core user 5% into
188
+ * the month (700k of 14M left) whose burst is at 14% (500k of 3.5M left). The
189
+ * fraction rule names the month, correctly — 5% is the figure worth showing.
190
+ * But handing 700k to an admission check authorises a batch the burst will
191
+ * refuse at 500k, and it refuses it AFTER the tokens are spent, against a
192
+ * sliding window that refills only by trickle over twelve hours. The two rules
193
+ * disagree exactly when the caps differ by 4×, which is to say on every
194
+ * self-serve tier, in the ordinary case rather than a corner one.
195
+ *
196
+ * The error is one-directional, which is why it is worth two functions: erring
197
+ * toward the smaller remainder narrows a fan-out that might have fitted, and
198
+ * erring toward the larger one spends a window that cannot be un-spent.
199
+ */
200
+ export function scarcestWindow(monthly, burst) {
201
+ if (!monthly)
202
+ return burst ? { window: burst, name: "burst" } : undefined;
203
+ if (!burst)
204
+ return { window: monthly, name: "month" };
205
+ return burst.remaining < monthly.remaining
206
+ ? { window: burst, name: "burst" }
207
+ : { window: monthly, name: "month" };
208
+ }
167
209
  /** The fraction of a cap consumed, clamped to [0,1] for display. */
168
210
  export function usedFraction(w) {
169
211
  if (!usableCap(w.cap))
@@ -1,3 +1,4 @@
1
+ import { MAX_SCHEMA_DEPTH, schemaDepth } from "../tools/schema-depth.js";
1
2
  const PERMISSIVE_SCHEMA = {
2
3
  type: "object",
3
4
  additionalProperties: true,
@@ -5,9 +6,16 @@ const PERMISSIVE_SCHEMA = {
5
6
  /**
6
7
  * Apply the bounds to a raw `tools/list`. Over the count cap → keep the first N
7
8
  * (by advertised order) and report `droppedCount`. Per tool: an over-long
8
- * description is truncated with a marker; an over-size input schema is replaced
9
- * with a permissive `object` schema and a note (we never forward an unbounded
10
- * schema, but we also never claim the args are constrained when we dropped it).
9
+ * description is truncated with a marker; an over-size OR over-deep input schema
10
+ * is replaced with a permissive `object` schema and a note (we never forward an
11
+ * unbounded or unsendable schema, but we also never claim the args are
12
+ * constrained when we dropped it).
13
+ *
14
+ * Degrading beats dying, and it is the same trade the byte cap already makes: an
15
+ * over-deep tool stays callable with unconstrained args and a visible note,
16
+ * rather than taking the session down with it. Nothing is lost locally by the
17
+ * swap — the adapter delegates arg validation to the server either way
18
+ * (`PASSTHROUGH`), and the approval gate is untouched.
11
19
  */
12
20
  export function boundToolList(tools, bounds) {
13
21
  const kept = tools.slice(0, bounds.maxTools);
@@ -30,6 +38,16 @@ export function boundToolList(tools, bounds) {
30
38
  inputSchema = { ...PERMISSIVE_SCHEMA };
31
39
  notes.push(`input schema (${schemaBytes} bytes) exceeded the ${bounds.maxSchemaBytes}-byte cap and was replaced with a permissive one`);
32
40
  }
41
+ // ORDER MATTERS: depth is measured on the POST-byte-cap value. A schema the
42
+ // byte cap already replaced is permissive (depth 1) and passes trivially, so
43
+ // the only schema this counter ever walks is one that fit `maxSchemaBytes` —
44
+ // which is what bounds the walk. Measuring the raw schema first would hand a
45
+ // hostile server an unbounded one.
46
+ const depth = schemaDepth(inputSchema);
47
+ if (depth >= MAX_SCHEMA_DEPTH) {
48
+ inputSchema = { ...PERMISSIVE_SCHEMA };
49
+ notes.push(`input schema nests ${depth} levels deep, at or over the ${MAX_SCHEMA_DEPTH}-level provider limit, and was replaced with a permissive one`);
50
+ }
33
51
  return { name: t.name, description, inputSchema, notes };
34
52
  });
35
53
  return { tools: bounded, droppedCount };
@@ -5,11 +5,23 @@
5
5
  * for the rest of the run, recording the real scope in the shared
6
6
  * {@link SessionAllowlist} so U.3's own scoping (`scopeCovers`) governs reuse.
7
7
  *
8
- * What it deliberately does NOT do — the two-tier boundary:
8
+ * What it deliberately does NOT do — the boundary, in three parts:
9
9
  * • **destructive** actions always fall through to the base policy (prompt);
10
10
  * • **ungrantable** actions (`scope:"none"`) always fall through (prompt),
11
11
  * even if the tier is `mutate` — because `SessionAllowlist.grant` is a no-op
12
- * on `none`, an ungrantable action can never be pre-approved.
12
+ * on `none`, an ungrantable action can never be pre-approved;
13
+ * • **irreversible** actions always fall through (prompt), even if the tier is
14
+ * `mutate` AND the scope is grantable (cli#193). This is the only one of the
15
+ * three that `tier` cannot express: a write to `/etc/passwd` is tier
16
+ * `mutate` with a perfectly good exact-file scope, and the checkpoint still
17
+ * cannot restore it. Without this clause, "approve + allow safe steps"
18
+ * silently authorized exactly that for the rest of the run.
19
+ *
20
+ * That last clause is why it is checked HERE rather than left to the allowlist.
21
+ * The ceiling in `SessionAllowlist.allows` covers grants being *spent*; this
22
+ * branch never asks `allows()` — it is pre-consent, so it *writes* a grant. It
23
+ * is the one path into the allowlist that the chokepoint cannot defend, so it
24
+ * carries its own copy of the same rule.
13
25
  *
14
26
  * So approving a plan (even with "allow safe steps") never consents to a
15
27
  * destructive or irreversible action; those re-confirm at execution time.
@@ -27,14 +39,18 @@ export class PlanExecutionPolicy {
27
39
  this.safeStepGrants = true;
28
40
  }
29
41
  async decide(request) {
30
- // Already covered by a prior session grant (scoped, tier-keyed) allow.
42
+ // Already covered by a prior session grant (scoped, tier-keyed, and — since
43
+ // cli#193 — reversible; `allows` enforces the ceiling itself) → allow.
31
44
  if (this.allowlist.allows(request))
32
45
  return { allow: true };
33
- // The one plan-mode shortcut: a grantable mutate action, pre-consented by
34
- // the "allow safe steps" choice. Record its concrete scope, then allow.
46
+ // The one plan-mode shortcut: a grantable, REVERSIBLE mutate action,
47
+ // pre-consented by the "allow safe steps" choice. Record its concrete scope,
48
+ // then allow. `!request.irreversible` is load-bearing, not defensive — see
49
+ // the third bullet in this class's doc comment.
35
50
  if (this.safeStepGrants &&
36
51
  request.tier === "mutate" &&
37
- request.scope.kind !== "none") {
52
+ request.scope.kind !== "none" &&
53
+ !request.irreversible) {
38
54
  this.allowlist.grant(request);
39
55
  return { allow: true };
40
56
  }
@@ -0,0 +1,88 @@
1
+ import { fit, reflow, visibleWidth } from "./layout.js";
2
+ /**
3
+ * The ceiling, in one line.
4
+ *
5
+ * Stated as an ABSOLUTE, because since cli#193 it is one: `allows()` checks
6
+ * `irreversible` before it matches any scope, so there is no longer a "unless
7
+ * you granted it" clause to add. #196 asked this screen to be honest about the
8
+ * asymmetry; the honest version today is that the asymmetry is closed, and the
9
+ * cost — a granted `git` not covering `git commit` — is what the inert marks
10
+ * and the re-prompt copy explain.
11
+ */
12
+ const CEILING = "irreversible actions always ask — in every mode, and no session grant outranks it";
13
+ /**
14
+ * How wide the label column may grow. A grant label is usually short (`git
15
+ * commands`, `changes under src/`), but an exact-file grant or a test grant
16
+ * carries a path or a whole command, and one long row must not cost every other
17
+ * row its tier and root.
18
+ */
19
+ const LABEL_MAX_COLS = 34;
20
+ /** `key value`, on the same gutter `/status` uses so the two screens align. */
21
+ function row(key, value, t) {
22
+ return ` ${t.muted(key.padEnd(11))} ${value}`;
23
+ }
24
+ /** `2 standing · 1 inert`, or what an empty/unwired list has to say instead. */
25
+ function grantSummary(grants, t) {
26
+ if (!grants)
27
+ return t.muted("not tracked in this session");
28
+ if (grants.length === 0) {
29
+ return t.muted("none — every action has been decided one at a time");
30
+ }
31
+ const dead = grants.filter((g) => g.dead).length;
32
+ const live = grants.length - dead;
33
+ const head = `${live} standing`;
34
+ return dead === 0
35
+ ? head
36
+ : `${head} ${t.warning(`${t.glyph.sep} ${dead} inert`)}`;
37
+ }
38
+ /** The full `/permissions` block as lines to print. */
39
+ export function permissionsReportLines(report, t, width = Infinity) {
40
+ const lines = [t.heading("permissions")];
41
+ lines.push(row("mode", `${t.strong(report.mode)} ${t.muted(`— ${report.modeDescription}`)}`, t));
42
+ // The ceiling is the one row that is the same in every session, and it is here
43
+ // for exactly that reason: a screen listing what runs without asking has to
44
+ // state what asks anyway, next to it, or the list reads as the whole answer.
45
+ lines.push(row("ceiling", report.autoApproves ? t.warning(CEILING) : t.muted(CEILING), t));
46
+ lines.push(row("grants", grantSummary(report.grants, t), t));
47
+ if (report.grants && report.grants.length > 0) {
48
+ lines.push("");
49
+ // One label column, sized to the longest label the list actually holds and
50
+ // capped so a pathological grant (a long exact path, a long test command)
51
+ // cannot push the tier and the root off a normal terminal. Padded on the
52
+ // VISIBLE width, because a coloured label carries escape bytes that
53
+ // `padEnd` would count and the terminal would not.
54
+ const labelCols = Math.min(LABEL_MAX_COLS, Math.max(...report.grants.map((g) => visibleWidth(g.label))));
55
+ for (const g of report.grants) {
56
+ const n = t.muted(`${g.n}`.padStart(2));
57
+ const text = fit(g.label, labelCols, t.glyph.ellipsis);
58
+ const pad = " ".repeat(Math.max(0, labelCols - visibleWidth(text)));
59
+ const label = g.dead ? t.muted(text) : t.strong(text);
60
+ lines.push(` ${n} ${label}${pad} ${t.muted(`${g.tier} ${t.glyph.sep} ${g.where}`)}`);
61
+ if (!g.dead)
62
+ continue;
63
+ // Why it can never match, on its own rows so the reason survives a narrow
64
+ // terminal intact — an inert grant the user cannot read the reason for is
65
+ // the same puzzle as an unmarked one.
66
+ const indent = " ";
67
+ const room = Number.isFinite(width)
68
+ ? Math.max(20, width - indent.length)
69
+ : Infinity;
70
+ const reason = `inert — ${g.dead}`;
71
+ const wrapped = Number.isFinite(room) ? reflow(reason, room) : [reason];
72
+ for (const line of wrapped)
73
+ lines.push(`${indent}${t.warning(line)}`);
74
+ }
75
+ }
76
+ lines.push("");
77
+ // THE SCOPE OF THE SCREEN. Every background job builds its own
78
+ // ApprovalService with its own allowlist, so a job's grants are neither
79
+ // listed here nor revoked by anything typed here. A permissions screen that
80
+ // implied otherwise would be understating what the session can still do.
81
+ lines.push(t.muted("this is the foreground session only — each background job keeps its own grants"));
82
+ if (report.grants && report.grants.length > 0) {
83
+ lines.push(t.muted("/permissions revoke <n> drops one — actions already decided keep their decision"));
84
+ }
85
+ return Number.isFinite(width)
86
+ ? lines.map((l) => fit(l, width, t.glyph.ellipsis))
87
+ : lines;
88
+ }
@@ -43,9 +43,28 @@ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
43
43
  const base = t && t.input + t.output > 0
44
44
  ? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
45
45
  : `thinking${e}`;
46
- // The real routing tier (C.30), appended honestly when present same
47
- // ` · ` joiner as the token counts.
48
- return phase.tier ? `${base} ${glyph.sep} ${phase.tier}` : base;
46
+ // The real routing tier (C.30) and the effort the gateway says it applied,
47
+ // appended honestly when present — same ` · ` joiner as the token counts.
48
+ //
49
+ // Order matters under truncation. `fitStatusLine`'s last resort clips the
50
+ // TAIL, so what is appended last is shed first: effort goes before the
51
+ // tier, which goes before the token counts, which go before "thinking…".
52
+ // That is the intended priority — the tier answers "what ran", effort only
53
+ // refines it.
54
+ //
55
+ // Effort is labelled rather than shown bare. `kavi · none` beside a tier
56
+ // name reads as a second tier; `kavi · effort none` cannot. The label
57
+ // costs seven columns and buys the line's only defence against being
58
+ // misread, which is worth more here than seven columns are.
59
+ const parts = [base];
60
+ if (phase.tier)
61
+ parts.push(phase.tier);
62
+ // A present `none` is the gateway SAYING no reasoning ran — a real answer
63
+ // to "what actually ran", and the one this readout exists to make
64
+ // visible. Absent stays absent; see ServedRouting.reasoningEffort.
65
+ if (phase.effort)
66
+ parts.push(`effort ${phase.effort}`);
67
+ return parts.join(` ${glyph.sep} `);
49
68
  }
50
69
  case "calling-tool":
51
70
  return `${phase.label}${e}`;
@@ -16,6 +16,6 @@ export { SessionLog } from "./log.js";
16
16
  export { defaultExportName, exportMarkdown, } from "./export.js";
17
17
  export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
18
18
  export { redactMessages } from "./redact.js";
19
- export { findSession, isAmbiguous, listSessions, summarizeSession, } from "./list.js";
20
- export { cwdMismatchWarning, describeSession, loadResume, relativeAge, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
21
- export { SESSION_FILE_VERSION, SessionEventSchema, SessionMetaSchema, } from "./types.js";
19
+ export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
20
+ export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
21
+ export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
@@ -1,6 +1,7 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { projectDir, SESSION_FILE_EXT } from "./paths.js";
4
+ import { readMeta } from "./replay.js";
4
5
  import { MessageSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
5
6
  /**
6
7
  * Listing sessions for the resume picker and the TUI sidebar (P2). Both read
@@ -22,10 +23,18 @@ function toTitle(text) {
22
23
  * Summarize one session file: its meta, its first user prompt (the title) and
23
24
  * how many user turns it holds.
24
25
  *
25
- * This reads the whole file, which is the honest cost of counting turns. It is
26
- * bounded in practice the picker asks for ten — and a session file is text
27
- * measured in tens of kilobytes. If it ever stops being cheap the fix is a
28
- * sidecar index, not a partial read that reports a wrong count.
26
+ * This reads the whole file, which is the honest cost of counting turns a
27
+ * partial read cannot produce a correct count, and reporting a wrong one is
28
+ * worse than paying for a right one.
29
+ *
30
+ * What changed: this used to claim the cost was "bounded in practice — the
31
+ * picker asks for ten". It was not. {@link listSessions} summarized EVERY file
32
+ * and sliced afterwards, so the limit bounded the rows and not the work; a
33
+ * project of 200 sessions paid 200 full parses to show 10. The limit now bounds
34
+ * the work (see {@link sessionRefsByRecency}), which is what finally makes that
35
+ * sentence true. A sidecar index is still the answer if per-file cost ever
36
+ * stops being acceptable — but the ordering fix had to come first, because an
37
+ * index would have made the same mistake faster.
29
38
  */
30
39
  export function summarizeSession(file) {
31
40
  let raw;
@@ -89,10 +98,18 @@ export function summarizeSession(file) {
89
98
  return { ...meta, title: title ?? UNTITLED, turns };
90
99
  }
91
100
  /**
92
- * Every session recorded for `cwd`'s project, most-recently-updated first.
93
- * Missing directory → empty list (not an error: no sessions yet is normal).
101
+ * Session files in `cwd`'s project, most-recently-modified first.
102
+ *
103
+ * `readdir` plus one `stat` each — NO file is opened. This is the ordering step
104
+ * that lets every caller below bound its own work: recency is knowable from the
105
+ * directory alone, so the expensive per-file read only ever has to happen for
106
+ * the files a caller is actually going to show.
107
+ *
108
+ * Missing directory → empty list (not an error: no sessions yet is normal). A
109
+ * file that vanishes between the `readdir` and the `stat` is skipped rather
110
+ * than throwing — listing races an active session by definition.
94
111
  */
95
- export function listSessions(cwd, limit = Infinity) {
112
+ function sessionRefsByRecency(cwd) {
96
113
  const dir = projectDir(cwd);
97
114
  let names;
98
115
  try {
@@ -101,37 +118,106 @@ export function listSessions(cwd, limit = Infinity) {
101
118
  catch {
102
119
  return [];
103
120
  }
104
- const summaries = [];
121
+ const files = [];
105
122
  for (const name of names) {
106
123
  if (!name.endsWith(SESSION_FILE_EXT))
107
124
  continue;
108
- const summary = summarizeSession(path.join(dir, name));
125
+ const file = path.join(dir, name);
126
+ try {
127
+ files.push({ file, mtimeMs: statSync(file).mtimeMs });
128
+ }
129
+ catch {
130
+ continue; // deleted mid-listing
131
+ }
132
+ }
133
+ // Newest first, with the path as a tiebreak so two files written in the same
134
+ // millisecond list in a stable order rather than whatever `readdir` returned.
135
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.file.localeCompare(b.file));
136
+ return files;
137
+ }
138
+ /**
139
+ * Every session recorded for `cwd`'s project, most-recently-updated first,
140
+ * capped at `limit`.
141
+ *
142
+ * `limit` bounds the WORK, not just the rows. Files are ordered by mtime first
143
+ * (cheap — see {@link sessionRefsByRecency}) and summarized one at a time until
144
+ * `limit` valid summaries exist, so the picker asking for ten reads ten files
145
+ * and not two hundred.
146
+ *
147
+ * OVER-FETCH AND REFILL is why this is a loop and not a `slice`. A file with no
148
+ * readable meta summarizes to `null` and is skipped — that tolerance is the
149
+ * point of the format and `tree.test.ts` pins it. Taking the ten most recent
150
+ * files and summarizing those would let one junk file among them silently
151
+ * return nine rows; walking until ten SUMMARIES exist costs one wasted parse
152
+ * per junk file and always returns ten when ten are there.
153
+ */
154
+ export function listSessions(cwd, limit = Infinity) {
155
+ const summaries = [];
156
+ for (const { file } of sessionRefsByRecency(cwd)) {
157
+ if (summaries.length >= limit)
158
+ break;
159
+ const summary = summarizeSession(file);
109
160
  if (summary)
110
161
  summaries.push(summary);
111
162
  }
112
- summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
113
- return Number.isFinite(limit) ? summaries.slice(0, limit) : summaries;
163
+ return summaries;
164
+ }
165
+ /**
166
+ * Every session's IDENTITY for `cwd`'s project, most-recently-updated first.
167
+ *
168
+ * One meta line per file, via {@link readMeta} — no turn counting, no title.
169
+ * This is the index `--resume <id>` resolves against, and it exists because
170
+ * resolution never needed the expensive half: on a 200-session project it costs
171
+ * ~10ms against ~120ms for the equivalent {@link listSessions}, and the resume
172
+ * path used to build the expensive one two or three times over.
173
+ *
174
+ * `meta` stays authoritative for the id, deliberately — the FILENAME also
175
+ * carries it, and matching on that would be cheaper still, but it would make a
176
+ * file with no readable meta resolvable and then unloadable. A session that
177
+ * cannot be summarized must not be offerable either.
178
+ */
179
+ export function listSessionRefs(cwd) {
180
+ const refs = [];
181
+ for (const { file, mtimeMs } of sessionRefsByRecency(cwd)) {
182
+ const meta = readMeta(file);
183
+ if (meta)
184
+ refs.push({ sessionId: meta.sessionId, file, mtimeMs });
185
+ }
186
+ return refs;
187
+ }
188
+ /**
189
+ * Match an id (or id prefix) against an already-built index.
190
+ *
191
+ * Pure, and separate from the reading, so ONE index can answer both "is this
192
+ * ambiguous?" and "which one is it?" — the two questions the resume path asks
193
+ * back to back, and used to rebuild the whole listing to answer separately.
194
+ *
195
+ * An exact id always wins outright: `abc` names the session called `abc` even
196
+ * when `abcdef` also exists, so a full id is never ambiguous against something
197
+ * that merely starts the same way.
198
+ */
199
+ export function matchSessionRefs(refs, id) {
200
+ const exact = refs.find((r) => r.sessionId === id);
201
+ if (exact)
202
+ return [exact];
203
+ return refs.filter((r) => r.sessionId.startsWith(id));
114
204
  }
115
205
  /**
116
206
  * Find one session by id (or unambiguous id prefix) within `cwd`'s project.
117
- * Returns null when nothing matches; throws nothing the caller decides how
118
- * loudly to fail.
207
+ * Returns null when nothing matches or the prefix is ambiguous; throws nothing
208
+ * — the caller decides how loudly to fail.
119
209
  *
120
210
  * Prefix matching exists because the ids are UUIDs and nobody is going to type
121
211
  * one; the picker and the sidebar both show a short form.
212
+ *
213
+ * Builds its own index. `resolveSessionId` in `resume.ts` is the path that
214
+ * builds ONE and asks both questions of it; prefer that when you need both.
122
215
  */
123
216
  export function findSession(cwd, id) {
124
- const all = listSessions(cwd);
125
- const exact = all.find((s) => s.sessionId === id);
126
- if (exact)
127
- return exact;
128
- const matches = all.filter((s) => s.sessionId.startsWith(id));
129
- return matches.length === 1 ? matches[0] : null;
217
+ const matches = matchSessionRefs(listSessionRefs(cwd), id);
218
+ return matches.length === 1 ? summarizeSession(matches[0].file) : null;
130
219
  }
131
220
  /** Whether an id prefix matches more than one session (an ambiguous resume). */
132
221
  export function isAmbiguous(cwd, id) {
133
- const all = listSessions(cwd);
134
- if (all.some((s) => s.sessionId === id))
135
- return false;
136
- return all.filter((s) => s.sessionId.startsWith(id)).length > 1;
222
+ return matchSessionRefs(listSessionRefs(cwd), id).length > 1;
137
223
  }
@@ -15,23 +15,42 @@ export class SessionLog {
15
15
  this.currentRunId = opts.currentRunId;
16
16
  }
17
17
  /**
18
- * Open (or create) a session log. A NEW file gets its `meta` line; reopening
19
- * an existing one (a `--resume`) does not — `meta` describes where and when
20
- * the conversation began, and a second copy written from wherever it was
21
- * resumed would make "the session's directory" ambiguous. Replay already
22
- * takes the first `meta`, so a duplicate could only ever mislead a later
23
- * reader, never help one.
18
+ * Open (or create) a session log.
19
+ *
20
+ * A NEW file gets its `meta` line. Reopening an existing one (a `--resume`)
21
+ * still does NOT get a second `meta` that ruling is unchanged and is the
22
+ * reason this method has always branched: `meta` describes where and when the
23
+ * conversation began, and a second copy written from wherever it was resumed
24
+ * would make "the session's directory" ambiguous. Replay takes the first
25
+ * `meta`, so a duplicate could only ever mislead a later reader.
26
+ *
27
+ * What a reopen gets instead is a `resumed` event (#172 item 2). It carries
28
+ * the directory this run is in, which is the fact that used to vanish: a
29
+ * session begun in one directory and continued in another recorded nothing
30
+ * about the second, so `cwdMismatchWarning` told the user at the time and the
31
+ * file forgot immediately. `meta` stays singular and authoritative; the
32
+ * reopen is a separate kind, and no reader can confuse the two.
24
33
  *
25
34
  * Returns `null` when a new log cannot be created at all — the caller then
26
35
  * runs without persistence rather than failing, exactly as a corrupt usage
27
- * store is survivable. A reopen never returns null: a write problem on an
28
- * existing file surfaces on the first append, which warns and goes inert.
36
+ * store is survivable. A reopen still never returns null: the `resumed` write
37
+ * is now the first append, and a write problem there warns and goes inert
38
+ * exactly as any other failed append does. A session that cannot record its
39
+ * own reopen is not a session worth refusing to continue.
29
40
  */
30
41
  static open(opts) {
31
42
  const file = opts.file ?? sessionFile(opts.cwd, opts.sessionId);
32
43
  const log = new SessionLog(file, opts);
33
- if (hasContent(file))
44
+ if (hasContent(file)) {
45
+ log.write({
46
+ kind: "resumed",
47
+ at: new Date().toISOString(),
48
+ cwd: opts.cwd,
49
+ roots: opts.roots ?? [],
50
+ cliVersion: APP_VERSION,
51
+ });
34
52
  return log;
53
+ }
35
54
  const ok = log.write({
36
55
  kind: "meta",
37
56
  version: SESSION_FILE_VERSION,