@cruxy/cli 1.5.0 → 1.7.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.
Files changed (56) hide show
  1. package/README.md +116 -0
  2. package/dist/agent/session.js +76 -7
  3. package/dist/budget/index.js +9 -0
  4. package/dist/budget/session-budget.js +223 -0
  5. package/dist/checkpoint/diff.js +130 -0
  6. package/dist/checkpoint/git-store.js +52 -0
  7. package/dist/checkpoint/index.js +2 -0
  8. package/dist/checkpoint/run-rollback.js +100 -0
  9. package/dist/cli/command-catalog.js +144 -0
  10. package/dist/cli/commands/hooks.js +1 -1
  11. package/dist/cli/commands/rollback.js +21 -57
  12. package/dist/cli/commands/run.js +9 -2
  13. package/dist/cli/commands/test.js +28 -16
  14. package/dist/cli/session-commands.js +315 -69
  15. package/dist/cli/session-factory.js +13 -0
  16. package/dist/components/frame.js +39 -1
  17. package/dist/errors/constructors.js +43 -4
  18. package/dist/errors/types.js +15 -0
  19. package/dist/hooks/config.js +18 -0
  20. package/dist/hooks/index.js +1 -1
  21. package/dist/hooks/router.js +1 -1
  22. package/dist/hooks/service.js +4 -4
  23. package/dist/hooks/slash.js +10 -26
  24. package/dist/lsp/index.js +1 -1
  25. package/dist/lsp/registry.js +28 -10
  26. package/dist/memory/secrets.js +43 -0
  27. package/dist/plan/service.js +26 -1
  28. package/dist/plan/submit-plan.js +11 -0
  29. package/dist/render/capabilities.js +9 -2
  30. package/dist/render/context-view.js +2 -2
  31. package/dist/render/index.js +6 -1
  32. package/dist/render/plan-view.js +1 -1
  33. package/dist/render/status-view.js +5 -5
  34. package/dist/render/units.js +22 -0
  35. package/dist/session/index.js +1 -0
  36. package/dist/session/log.js +19 -0
  37. package/dist/session/redact.js +74 -0
  38. package/dist/session/replay.js +16 -0
  39. package/dist/session/resume.js +8 -0
  40. package/dist/session/types.js +38 -0
  41. package/dist/subagent/orchestrator.js +82 -5
  42. package/dist/theme/resolve.js +1 -0
  43. package/dist/tui/app.js +25 -7
  44. package/dist/tui/approval-overlay.js +7 -1
  45. package/dist/tui/index.js +3 -2
  46. package/dist/tui/layout.js +7 -2
  47. package/dist/tui/limits-panel.js +6 -14
  48. package/dist/tui/mode-ring.js +84 -0
  49. package/dist/tui/palette.js +11 -19
  50. package/dist/tui/renderer.js +145 -4
  51. package/dist/tui/restore.js +137 -0
  52. package/dist/tui/supports.js +22 -0
  53. package/dist/tui/tool-versions.js +119 -18
  54. package/dist/usage/weighted.js +14 -0
  55. package/dist/utils/disk.js +11 -3
  56. package/package.json +2 -2
package/README.md CHANGED
@@ -143,6 +143,122 @@ default 10; disable with `checkpoint.enabled = false`).
143
143
  **Boundary:** checkpoints cover working-tree files only. Commits, pushes, and
144
144
  PRs made during a run are never undone — the rollback preview says so.
145
145
 
146
+ ## Terminal & accessibility
147
+
148
+ Cruxy probes the terminal once per run and reduces it to a handful of
149
+ capabilities — color, unicode, cursor control, motion, screen-reader mode, size
150
+ — which every rendered surface resolves a theme from. That is why degradation is
151
+ consistent: there is one place that decides, not one decision per panel.
152
+
153
+ Each axis has an environment override, and **the axes are independent**. Turning
154
+ color off does not change which glyphs are used; asking for ASCII glyphs does not
155
+ turn color off. A `NO_COLOR` terminal still gets `✓`, a colored `CRUXY_ASCII`
156
+ terminal gets a green `[ok]`.
157
+
158
+ ### Color
159
+
160
+ | Variable | Effect |
161
+ | ------------- | ---------------------------------------------------------------- |
162
+ | `NO_COLOR` | No ANSI color anywhere. Not muted color — **zero** escape bytes. |
163
+ | `FORCE_COLOR` | Keep color even when stdout is a pipe or a file. |
164
+
165
+ Color is on by default only when the stream is a TTY. `TERM=dumb` forces it off
166
+ regardless.
167
+
168
+ ### Glyphs
169
+
170
+ | Variable | Effect |
171
+ | ------------- | ----------------------------------------------------------------------------------------- |
172
+ | `CRUXY_ASCII` | Use the ASCII glyph table: `[ok]` `[x]` `[ ]` `->` `...` in place of `✓` `✗` `○` `→` `…`. |
173
+
174
+ Also implied by `TERM=dumb`. Piped output keeps unicode — writing `✓` to a file
175
+ is fine, and forcing ASCII there would change long-standing behaviour for
176
+ everything that already parses cruxy's output.
177
+
178
+ ### Screen readers
179
+
180
+ | Variable | Effect |
181
+ | --------------------- | ---------------------------------------------- |
182
+ | `CRUXY_SCREEN_READER` | Screen-reader mode. |
183
+ | `ACCESSIBLE` | The same thing, under the ecosystem-wide name. |
184
+
185
+ In this mode:
186
+
187
+ - **status glyphs become words** — `✓ read_file` is announced as `done
188
+ read_file`, and `◐` / `○` become `working` / `pending`, so a status mark is
189
+ never read out as bare punctuation;
190
+ - **decorative marks collapse to nothing** — the `↻` cache marker, the text
191
+ cursor bar, and the cells of a progress bar all render as empty, because a run
192
+ of block characters announces as noise and the percentage beside it already
193
+ carries the whole meaning;
194
+ - **animation is off** — screen-reader mode implies reduced motion, since a
195
+ spinner has no live region to animate into.
196
+
197
+ **It is opt-in and never inferred.** Cruxy has no way to detect a screen reader,
198
+ and inferring one from a non-TTY stdout would reword every piped and CI run in
199
+ the world:
200
+
201
+ ```bash
202
+ export CRUXY_SCREEN_READER=1
203
+ ```
204
+
205
+ ### Motion
206
+
207
+ | Variable | Effect |
208
+ | ---------------------- | ---------------------------------------------- |
209
+ | `NO_MOTION` | The ecosystem-wide reduced-motion signal. |
210
+ | `CRUXY_REDUCED_MOTION` | The explicit cruxy knob. |
211
+ | `CRUXY_NO_SPINNER` | Kept as an alias; flows through the same axis. |
212
+
213
+ Any of them disables the frame clock outright — nothing is scheduled and no
214
+ frame ever ticks, so every animation collapses to its static end-state, drawn
215
+ once. Reduced motion is also implied by screen-reader mode.
216
+
217
+ ### The alternate screen
218
+
219
+ | Variable | Effect |
220
+ | --------------------- | ------------------------------------------------------------------------------ |
221
+ | `CRUXY_NO_ALT_SCREEN` | Keep the full-screen TUI in the normal buffer instead of the alternate screen. |
222
+
223
+ By default the TUI runs on the terminal's alternate screen — the second buffer
224
+ `less` and `vim` use. Leaving it restores the normal buffer byte for byte, so
225
+ the prompt you started cruxy from comes back exactly as it was, with no
226
+ frame-shaped hole in your scrollback.
227
+
228
+ On a clean exit the last dozen lines of the conversation are echoed into the
229
+ normal buffer, so the session survives in the shell's scrollback rather than
230
+ being discarded with the alternate screen. The full transcript is the session
231
+ log; this is the tail.
232
+
233
+ The opt-out exists because the alternate screen is not universally available or
234
+ wanted — multiplexers and emulators can be configured to refuse it, some
235
+ capture-and-replay tooling reads only the normal buffer, and you may simply
236
+ prefer your shell to keep the frame. It changes nothing else about the TUI, and
237
+ it is ignored where the TUI does not run at all (a pipe, a screen reader,
238
+ `TERM=dumb`).
239
+
240
+ However cruxy exits — quit, `kill -TERM`, a hangup when the window closes, an
241
+ uncaught error — the terminal is handed back: the frame erased, the cursor
242
+ shown, the alternate screen left.
243
+
244
+ ### Size
245
+
246
+ | Variable | Effect |
247
+ | --------- | -------------------------------------------- |
248
+ | `COLUMNS` | Terminal width, overriding what it reports. |
249
+ | `LINES` | Terminal height, overriding what it reports. |
250
+
251
+ These win over the stream's own values, which is what makes
252
+ `COLUMNS=100 cruxy …` work in CI where the terminal reports no size at all. They
253
+ fall back to 80 × 24. Unlike the flags above they take a **positive integer**; a
254
+ value that is not one is ignored rather than treated as "set".
255
+
256
+ ### The "set" convention
257
+
258
+ Every variable except `COLUMNS` / `LINES` follows the `NO_COLOR` rule: **any
259
+ non-empty value counts as set.** `CRUXY_ASCII=1` and `CRUXY_ASCII=false` both
260
+ enable ASCII glyphs — to turn one off, unset it or set it to the empty string.
261
+
146
262
  ## Errors & exit codes
147
263
 
148
264
  Every user-facing error prints a title, the cause (when known), concrete next
@@ -1,11 +1,16 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { UNRESOLVED_TIER } from "../budget/index.js";
2
3
  import { loadProjectInstructions } from "../config/index.js";
4
+ import { sessionBudgetExhausted } from "../errors/index.js";
5
+ // The concrete module, not `session/index.js`: that barrel imports this file's
6
+ // package for its own types, and the transform is a leaf that needs none of it.
7
+ import { redactMessages } from "../session/redact.js";
3
8
  import { resolveTaskModel, } from "../routing/index.js";
4
9
  import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
5
10
  import { Budget } from "./budget.js";
6
11
  import { estimateTokens, findCut } from "./context.js";
7
12
  import { runAgent, } from "./loop.js";
8
- import { DEFAULT_MODE, modeAutoApproves, modePlans, nextMode, parseMode, } from "./mode.js";
13
+ import { DEFAULT_MODE, modeAutoApproves, modePlans, parseMode, } from "./mode.js";
9
14
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
10
15
  /**
11
16
  * Re-exported from `agent/context.ts`, where the estimate now lives beside the
@@ -65,6 +70,25 @@ export class Session {
65
70
  * previous iteration fully resolved its tool calls.
66
71
  */
67
72
  recordedCount = 0;
73
+ /**
74
+ * The tier the next turn will draw on, in the three-way shape admission needs.
75
+ *
76
+ * A session with no {@link SessionModel} is a bring-your-own-provider session:
77
+ * its tokens never reach the weighted pool, so there is nothing to weigh and
78
+ * `undefined` says so. `auto` is a cruxy request whose tier the gateway picks,
79
+ * which is a different unknown entirely — it WILL draw on the pool — so it is
80
+ * passed through as {@link UNRESOLVED_TIER} and weighed at the worst case.
81
+ */
82
+ turnTier() {
83
+ const choice = this.args.model?.current();
84
+ if (choice === undefined)
85
+ return undefined;
86
+ return choice === "auto" ? UNRESOLVED_TIER : choice;
87
+ }
88
+ /** The session's weighted-token budget, when one is wired (`/budget` reads it). */
89
+ get budget() {
90
+ return this.args.budget;
91
+ }
68
92
  constructor(args) {
69
93
  this.args = args;
70
94
  this.projectInstructions = args.projectInstructions ?? null;
@@ -165,10 +189,6 @@ export class Session {
165
189
  this.args.recorder?.mode(this.mode);
166
190
  return this.mode;
167
191
  }
168
- /** Advance one step around the mode ring (Shift+Tab). Returns the new mode. */
169
- cycleMode() {
170
- return this.setMode(nextMode(this.mode));
171
- }
172
192
  /** Whether this session proposes a plan before executing (C.31). */
173
193
  getPlanMode() {
174
194
  return modePlans(this.mode);
@@ -213,11 +233,29 @@ export class Session {
213
233
  // Unset/0 → no budget object at all, so the loop's budget check is a no-op
214
234
  // and behavior is byte-identical to before. Rebuilt each `send`, so the cap
215
235
  // is per-turn, never cumulative across the session (cost is C.22's concern).
236
+ //
237
+ // P10 track 3 narrows that cap further when a session `/budget` is set. The
238
+ // session budget is stated in WEIGHTED tokens (the unit the gateway meters)
239
+ // and this guard counts LOCAL ones, so the conversion is the budget's job,
240
+ // not this call site's — `admit` returns the local ceiling that fits.
241
+ // Refusal (nothing left) throws before the model is engaged: a turn that
242
+ // cannot run must not spend a request finding that out.
216
243
  const maxTokensPerTurn = this.args.config.agent.maxTokensPerTurn;
217
- const budget = maxTokensPerTurn > 0
244
+ const admission = this.args.budget?.admit({
245
+ count: 1,
246
+ perRunTokens: maxTokensPerTurn,
247
+ tier: this.turnTier(),
248
+ });
249
+ if (admission?.kind === "refused") {
250
+ throw sessionBudgetExhausted(admission.reason);
251
+ }
252
+ // `admit` only ever narrows: `maxTokens: 0` from an unbudgeted session means
253
+ // "no cap", exactly as the config value does.
254
+ const turnTokenCap = admission?.maxTokens ?? maxTokensPerTurn;
255
+ const budget = turnTokenCap > 0
218
256
  ? new Budget({
219
257
  maxIterations: Number.POSITIVE_INFINITY,
220
- maxTokens: maxTokensPerTurn,
258
+ maxTokens: turnTokenCap,
221
259
  })
222
260
  : undefined;
223
261
  // before-run (C.19): a blocking pre-run hook — or an untrusted project's
@@ -272,6 +310,9 @@ export class Session {
272
310
  const record = collector.toRecord(randomUUID(), this.sessionId, startedAt);
273
311
  this.lastRun = record;
274
312
  this.args.onRunUsage?.(record);
313
+ // The budget draws down from the SAME record the usage store persists, so
314
+ // `/budget` and `/usage` can never report different spends for one turn.
315
+ this.args.budget?.record(record);
275
316
  // after-run (C.19): advisory by default (a blocking after-run hook throws
276
317
  // and surfaces at the boundary). The turn already completed and its history
277
318
  // is adopted above — an advisory failure never rewrites it.
@@ -296,6 +337,34 @@ export class Session {
296
337
  this.args.recorder?.clear();
297
338
  this.recordedCount = 0;
298
339
  }
340
+ /**
341
+ * `/redact` (P10 track 5) — mask every recognised secret in the history the
342
+ * model can still see, and record that it happened.
343
+ *
344
+ * THE LIVE ARRAY AND THE LOG'S FOLD RUN THE SAME TRANSFORM, so a resumed
345
+ * session sees exactly what this one sees from here on. The event carries no
346
+ * secret; replay re-derives the spans with the same detector.
347
+ *
348
+ * The recorder's watermark is deliberately NOT rewound. `recordedCount` says
349
+ * how many messages of the current history are already on disk, and every one
350
+ * of them still is — the redaction changed their CONTENT in memory, not how
351
+ * many there are. Rewinding would re-append the whole conversation as if it
352
+ * were new, doubling the file and (worse) writing the freshly-masked copies
353
+ * beside the originals, which is a second copy of nothing useful.
354
+ *
355
+ * Returns what it found, so the caller can say so — including zero, which is
356
+ * a real answer and not a no-op to swallow.
357
+ */
358
+ redact() {
359
+ const result = redactMessages(this.messages);
360
+ if (result.count > 0)
361
+ this.messages = result.messages;
362
+ // Recorded even at zero: the fold is idempotent, and a log that records the
363
+ // request rather than only its hits keeps meaning the same thing when a
364
+ // later build's denylist recognises something this one did not.
365
+ this.args.recorder?.redact(result.kinds, result.count);
366
+ return { kinds: result.kinds, count: result.count };
367
+ }
299
368
  /**
300
369
  * Compact `this.messages` only when it has grown past threshold, adopting the
301
370
  * result. On success logs a one-line notice and returns the number of older
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The session's weighted-token budget (P10 track 3 / cli#212).
3
+ *
4
+ * One object, two exposures: `/budget` (what the user sets and reads) and
5
+ * admission control at the fan-out seam (what stops a parallel batch draining a
6
+ * sliding window that refills by trickle). See `session-budget.ts` for why they
7
+ * are not two things.
8
+ */
9
+ export { SessionBudget, UNRESOLVED_TIER, } from "./session-budget.js";
@@ -0,0 +1,223 @@
1
+ import { bindingWindow } from "../limits/index.js";
2
+ import { compactTokens } from "../render/units.js";
3
+ import { MAX_TIER_MULTIPLIER, multiplierForTier, weightedFor, } from "../usage/weighted.js";
4
+ /**
5
+ * The tier is not knowable before the request — routing is `auto` and the
6
+ * gateway resolves it. Weighed at the worst case, never skipped.
7
+ */
8
+ export const UNRESOLVED_TIER = "auto";
9
+ export class SessionBudget {
10
+ opts;
11
+ limits;
12
+ limitWeighted = null;
13
+ spentWeighted = 0;
14
+ /** Requests whose weight could not be computed — surfaced, never assumed 0. */
15
+ unweighed = 0;
16
+ constructor(opts) {
17
+ this.opts = opts;
18
+ this.limits = opts.limits;
19
+ }
20
+ /**
21
+ * Adopt the server denominator once it exists.
22
+ *
23
+ * A seam rather than a constructor argument because of the order the real
24
+ * wiring happens in: the limits cache needs the resolved provider and key,
25
+ * which the session factory has already consumed by the time it returns, and
26
+ * the TUI attaches the SAME cache a few lines later. Handing both the one
27
+ * instance is the point — a budget reading one cache while the rail panel
28
+ * draws another would be two answers to one question, and they would disagree
29
+ * exactly when the pool is moving.
30
+ */
31
+ attachLimits(limits) {
32
+ this.limits = limits;
33
+ }
34
+ /** The session cap in weighted tokens, or `null` when the user has set none. */
35
+ get limit() {
36
+ return this.limitWeighted;
37
+ }
38
+ /**
39
+ * Set or clear the cap. SESSION STATE, never written to config — the same rule
40
+ * `/model` and `/mode` follow, and for the strongest version of the reason: a
41
+ * spending cap silently re-applied from a file to every future session would
42
+ * eventually refuse a turn with nothing on screen explaining why.
43
+ */
44
+ setLimit(weighted) {
45
+ this.limitWeighted = weighted !== null && weighted > 0 ? weighted : null;
46
+ }
47
+ /** Weighted tokens this session has drawn, as far as they could be weighed. */
48
+ get spent() {
49
+ return this.spentWeighted;
50
+ }
51
+ /** Requests this session could not weigh (a non-cruxy provider, or an old gateway). */
52
+ get unweighable() {
53
+ return this.unweighed;
54
+ }
55
+ /**
56
+ * Fold one completed run's usage in, per REQUEST rather than from the run's
57
+ * summed totals — the same discipline `summarizeRuns` states: the multiplier
58
+ * is per-tier, and a run can mix tiers and mix weighable with unweighable
59
+ * requests, so a sum multiplied once would weigh tokens that were never
60
+ * eligible.
61
+ */
62
+ record(run) {
63
+ for (const e of run.entries) {
64
+ const weighted = weightedFor(e.tier, e.billableInputTokens, e.outputTokens);
65
+ if (weighted === undefined) {
66
+ this.unweighed++;
67
+ continue;
68
+ }
69
+ this.spentWeighted += weighted;
70
+ }
71
+ }
72
+ /** Weighted tokens left on the SESSION cap, or `null` when none is set. */
73
+ remaining() {
74
+ if (this.limitWeighted === null)
75
+ return null;
76
+ return Math.max(0, this.limitWeighted - this.spentWeighted);
77
+ }
78
+ /** The server's denominator, resolved to a statement (see {@link ServerHeadroom}). */
79
+ serverHeadroom() {
80
+ const state = this.limits?.current();
81
+ if (!state)
82
+ return { kind: "unreadable", why: "no limits reading is wired" };
83
+ if (state.status === "pending") {
84
+ return {
85
+ kind: "unreadable",
86
+ why: "the limits reading has not arrived yet",
87
+ };
88
+ }
89
+ if (state.status === "error") {
90
+ return {
91
+ kind: "unreadable",
92
+ why: `the limits reading is ${state.reason}`,
93
+ };
94
+ }
95
+ const budget = state.reading.budget;
96
+ if (budget.kind === "unenforced")
97
+ return { kind: "uncapped" };
98
+ if (budget.kind !== "pool") {
99
+ // metered / credits / unknown: no weighted pool to bound against. Only
100
+ // `unknown` is a failure, but none of the three yields a weighted window,
101
+ // and inventing one from a dollar balance would be a unit error.
102
+ return {
103
+ kind: "unreadable",
104
+ why: `this account is ${budget.kind}, which has no weighted pool`,
105
+ };
106
+ }
107
+ const binding = bindingWindow(budget.monthly, budget.burst);
108
+ if (!binding) {
109
+ return {
110
+ kind: "unreadable",
111
+ why: "the pool reported no readable window",
112
+ };
113
+ }
114
+ return {
115
+ kind: "window",
116
+ name: binding.name,
117
+ remaining: binding.window.remaining,
118
+ cap: binding.window.cap,
119
+ };
120
+ }
121
+ /**
122
+ * THE ONE DECISION. Both exposures land here.
123
+ *
124
+ * The arithmetic is deliberately an UPPER BOUND on the draw, not an estimate:
125
+ * `perRunTokens × count × multiplier`. The local ceiling counts input+output
126
+ * including re-sent prompt, while the meter counts `billable_input` (cache
127
+ * reads excluded), so the true draw is lower — often much lower. Erring high is
128
+ * the only safe direction for an admission check: erring low admits a batch
129
+ * that then trips a sliding window which refills by trickle over twelve hours.
130
+ *
131
+ * A tier we cannot weigh (a non-cruxy provider, or routing unresolved) yields
132
+ * NO weighted bound, because there is no honest multiplier to apply. The run
133
+ * proceeds — those tokens never touch the weighted pool.
134
+ */
135
+ admit(req) {
136
+ const allow = (count, maxTokens) => ({
137
+ kind: "allow",
138
+ count,
139
+ maxTokens,
140
+ });
141
+ const configCap = this.opts.maxTokensPerTurn;
142
+ const perRun = req.perRunTokens > 0 ? req.perRunTokens : configCap > 0 ? configCap : 0;
143
+ const multiplier = req.tier === undefined
144
+ ? undefined
145
+ : req.tier === UNRESOLVED_TIER
146
+ ? MAX_TIER_MULTIPLIER
147
+ : multiplierForTier(req.tier);
148
+ const allowance = this.allowance();
149
+ // Nothing to enforce against, or nothing to enforce with.
150
+ if (allowance === null || multiplier === undefined || perRun === 0) {
151
+ return allow(req.count, req.perRunTokens);
152
+ }
153
+ if (allowance.weighted <= 0) {
154
+ return { kind: "refused", reason: exhaustedReason(allowance) };
155
+ }
156
+ // How many runs of `perRun` local tokens the allowance covers.
157
+ const perRunWeighted = perRun * multiplier;
158
+ const affordable = Math.floor(allowance.weighted / perRunWeighted);
159
+ if (affordable >= req.count)
160
+ return allow(req.count, req.perRunTokens);
161
+ if (affordable >= 1) {
162
+ return {
163
+ kind: "narrowed",
164
+ count: affordable,
165
+ maxTokens: req.perRunTokens,
166
+ reason: narrowedReason(req.count, affordable, req, allowance, multiplier),
167
+ };
168
+ }
169
+ // Not even one run at the caller's ceiling. A single run is still admitted,
170
+ // with its token cap cut to what the allowance covers — refusing outright
171
+ // would leave a user with real headroom unable to ask anything at all. A
172
+ // fan-out of many, though, is narrowed to exactly one.
173
+ const cap = Math.max(1, Math.floor(allowance.weighted / multiplier));
174
+ if (req.count === 1) {
175
+ return {
176
+ kind: "narrowed",
177
+ count: 1,
178
+ maxTokens: cap,
179
+ reason: `capped this turn at ${compactTokens(cap)} tokens — ` +
180
+ `${allowanceLabel(allowance)} leaves room for about that much on ${req.tier}`,
181
+ };
182
+ }
183
+ return {
184
+ kind: "narrowed",
185
+ count: 1,
186
+ maxTokens: cap,
187
+ reason: narrowedReason(req.count, 1, req, allowance, multiplier),
188
+ };
189
+ }
190
+ /**
191
+ * The tighter of the two denominators, or `null` when neither bounds anything.
192
+ * Never their sum, and never one standing in for the other.
193
+ */
194
+ allowance() {
195
+ const session = this.remaining();
196
+ const server = this.serverHeadroom();
197
+ const serverRemaining = server.kind === "window"
198
+ ? { weighted: server.remaining, source: server.name }
199
+ : null;
200
+ if (session === null)
201
+ return serverRemaining;
202
+ const local = { weighted: session, source: "session" };
203
+ if (!serverRemaining)
204
+ return local;
205
+ return serverRemaining.weighted < local.weighted ? serverRemaining : local;
206
+ }
207
+ }
208
+ function allowanceLabel(a) {
209
+ return a.source === "session"
210
+ ? "your /budget for this session"
211
+ : `your ${a.source === "burst" ? "12h burst" : "monthly"} window`;
212
+ }
213
+ function exhaustedReason(a) {
214
+ return a.source === "session"
215
+ ? "this session's /budget is used up — raise it with `/budget <n>` or clear it with `/budget off`"
216
+ : `your ${a.source === "burst" ? "12h burst" : "monthly"} window has nothing left`;
217
+ }
218
+ function narrowedReason(asked, granted, req, a, multiplier) {
219
+ const wouldDraw = asked * req.perRunTokens * multiplier;
220
+ return (`narrowed ${asked} → ${granted}: ${asked} concurrent runs could draw up to ` +
221
+ `${compactTokens(wouldDraw)} weighted tokens on ${req.tier}, and ` +
222
+ `${allowanceLabel(a)} has ${compactTokens(a.weighted)} left`);
223
+ }
@@ -0,0 +1,130 @@
1
+ import { GitCheckpointStore } from "./git-store.js";
2
+ import { CheckpointService, isGitWorkTree } from "./service.js";
3
+ import { listSets, readSet } from "./set.js";
4
+ /**
5
+ * Resolve `--since [id]` against the workspace.
6
+ *
7
+ * With no id: the most recent run, which is the same default `/undo-last` and
8
+ * `cruxy rollback` use — "since the last thing that happened" is the question
9
+ * being asked often enough that making it typed-out is friction for nothing.
10
+ *
11
+ * With an id: a set manifest first (so a background job's id works, C.28), then
12
+ * each root's own checkpoints. A checkpoint id is searched across EVERY declared
13
+ * root rather than only the primary, because a multi-root run writes one
14
+ * checkpoint per root and the id the user copied off a rollback preview may
15
+ * belong to any of them.
16
+ */
17
+ export async function resolveCheckpointDiff(roots, id, config) {
18
+ const primary = roots.find((r) => r.primary) ?? roots[0];
19
+ if (id === undefined) {
20
+ const sets = await listSets(primary.absPath);
21
+ if (sets.length > 0) {
22
+ return {
23
+ kind: "run",
24
+ runId: sets[0].runId,
25
+ targets: await runTargets(sets[0].members, config),
26
+ };
27
+ }
28
+ // No set manifest — the pre-C.26 shape, and also a session whose only run
29
+ // touched nothing. Fall back to the primary root's newest checkpoint.
30
+ const newest = (await new CheckpointService({
31
+ root: primary.absPath,
32
+ config,
33
+ }).list())[0];
34
+ if (!newest)
35
+ return { kind: "none" };
36
+ return {
37
+ kind: "checkpoint",
38
+ targets: [await target(primary.name, primary.absPath, newest)],
39
+ };
40
+ }
41
+ // A run id first: `readSet` throws CHECKPOINT_SET_INCOMPLETE for a manifest
42
+ // that is missing OR corrupt, and only the second is worth reporting as an
43
+ // error — a plain miss just means the id was a checkpoint id.
44
+ const sets = await listSets(primary.absPath);
45
+ if (sets.some((s) => s.runId === id)) {
46
+ const set = await readSet(primary.absPath, id);
47
+ return {
48
+ kind: "run",
49
+ runId: set.runId,
50
+ targets: await runTargets(set.members, config),
51
+ };
52
+ }
53
+ for (const root of roots) {
54
+ const service = new CheckpointService({ root: root.absPath, config });
55
+ let checkpoint;
56
+ try {
57
+ checkpoint = await service.read(id);
58
+ }
59
+ catch {
60
+ continue; // not this root's — try the next
61
+ }
62
+ return {
63
+ kind: "checkpoint",
64
+ targets: [await target(root.name, root.absPath, checkpoint)],
65
+ };
66
+ }
67
+ return { kind: "not-found", id };
68
+ }
69
+ /** Build one target per set member, in the order the set records them. */
70
+ async function runTargets(members, config) {
71
+ const targets = [];
72
+ for (const member of members) {
73
+ const service = new CheckpointService({
74
+ root: member.rootPath,
75
+ config,
76
+ });
77
+ let checkpoint;
78
+ try {
79
+ checkpoint = await service.read(member.checkpointId);
80
+ }
81
+ catch (err) {
82
+ // A missing member is fatal for a ROLLBACK (validate-all, R3) and merely
83
+ // a gap for a diff. Reporting it per root and showing the rest beats
84
+ // refusing to show anything — this command changes nothing.
85
+ targets.push({
86
+ rootName: member.rootName,
87
+ rootPath: member.rootPath,
88
+ checkpointId: member.checkpointId,
89
+ unavailable: `checkpoint ${member.checkpointId} is missing or unreadable (${err.message})`,
90
+ });
91
+ continue;
92
+ }
93
+ targets.push(await target(member.rootName, member.rootPath, checkpoint));
94
+ }
95
+ return targets;
96
+ }
97
+ /**
98
+ * One root's tree-ish, or the reason there isn't one.
99
+ *
100
+ * The two honest failures, both reported rather than papered over:
101
+ * • a SHADOW-store checkpoint — written when the root is not a git work tree,
102
+ * or when git plumbing failed mid-snapshot. Its content is a private blob
103
+ * pool git has never heard of, so there is no tree-ish to hand `git diff`.
104
+ * Rollback still works; only the preview does not.
105
+ * • pruned objects — `treeish` throws what `readContent` throws, and for the
106
+ * same reason.
107
+ */
108
+ async function target(rootName, rootPath, checkpoint) {
109
+ const base = {
110
+ rootName,
111
+ rootPath,
112
+ checkpointId: checkpoint.id,
113
+ };
114
+ if (checkpoint.store !== "git" || !isGitWorkTree(rootPath)) {
115
+ return {
116
+ ...base,
117
+ unavailable: "this checkpoint was stored outside git (shadow copy), so there is no tree to diff against — " +
118
+ "`cruxy rollback` still restores it",
119
+ };
120
+ }
121
+ try {
122
+ return {
123
+ ...base,
124
+ treeish: await new GitCheckpointStore(rootPath).treeish(checkpoint.files),
125
+ };
126
+ }
127
+ catch (err) {
128
+ return { ...base, unavailable: err.message };
129
+ }
130
+ }
@@ -83,6 +83,58 @@ export class GitCheckpointStore {
83
83
  await fsp.rm(tmpDir, { recursive: true, force: true });
84
84
  }
85
85
  }
86
+ /**
87
+ * Rebuild a checkpoint's tree from its manifest entries and return the tree
88
+ * oid — a tree-ish that `git diff <treeish>` accepts (P10 track 2).
89
+ *
90
+ * WHY REBUILD RATHER THAN STORE. `snapshot` already calls `write-tree` and
91
+ * already throws the resulting oid away, on the grounds that per-file blob oids
92
+ * are what restore needs. Persisting it now would only help checkpoints written
93
+ * after this change and would leave every existing manifest undiffable, so the
94
+ * tree is reassembled instead — from the same manifest, into the same kind of
95
+ * temporary index, producing (by construction) the same oid.
96
+ *
97
+ * SAME FIVE-GIT-COMMAND DISCIPLINE as `snapshot`, and the same enforcement:
98
+ * `update-index --index-info` and `write-tree` both run against a
99
+ * `GIT_INDEX_FILE` in `os.tmpdir()`, no ref is created or moved, and the
100
+ * before/after fingerprint is asserted. This does not even write new objects —
101
+ * every blob it names is one the snapshot already wrote — so the only thing it
102
+ * can produce is a tree that was already there.
103
+ *
104
+ * Throws `CRUXY_E_CHECKPOINT_FAILED` when an object has been pruned out from
105
+ * under the manifest, which is the same failure `readContent` reports and for
106
+ * the same reason (`git gc --prune=now`).
107
+ */
108
+ async treeish(entries) {
109
+ const before = this.fingerprint();
110
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "cruxy-ckdiff-"));
111
+ const indexFile = path.join(tmpDir, "index");
112
+ try {
113
+ const env = { GIT_INDEX_FILE: indexFile };
114
+ // `--index-info` with `-z`: "<mode> <oid>\t<path>" records, NUL-terminated,
115
+ // so a path containing a newline or a quote is carried verbatim.
116
+ const stdin = entries
117
+ .map((e) => `${e.mode} ${e.oid}\t${e.path}\0`)
118
+ .join("");
119
+ const add = this.git(["update-index", "-z", "--index-info"], {
120
+ env,
121
+ input: stdin,
122
+ });
123
+ if (!add.ok) {
124
+ throw checkpointFailed(`rebuilding the checkpoint tree failed — its objects may have been pruned ` +
125
+ `by \`git gc --prune\` (${add.stderr.trim()})`);
126
+ }
127
+ const tree = this.git(["write-tree"], { env });
128
+ if (!tree.ok) {
129
+ throw checkpointFailed(`writing the checkpoint tree failed: ${tree.stderr.trim()}`);
130
+ }
131
+ assertGitStateUnchanged(before, this.fingerprint());
132
+ return tree.stdout.toString("utf8").trim();
133
+ }
134
+ finally {
135
+ await fsp.rm(tmpDir, { recursive: true, force: true });
136
+ }
137
+ }
86
138
  async readContent(entry) {
87
139
  const res = this.git(["cat-file", "blob", entry.oid]);
88
140
  if (!res.ok) {