@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.
- package/README.md +116 -0
- package/dist/agent/session.js +76 -7
- package/dist/budget/index.js +9 -0
- package/dist/budget/session-budget.js +223 -0
- package/dist/checkpoint/diff.js +130 -0
- package/dist/checkpoint/git-store.js +52 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/run-rollback.js +100 -0
- package/dist/cli/command-catalog.js +144 -0
- package/dist/cli/commands/hooks.js +1 -1
- package/dist/cli/commands/rollback.js +21 -57
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/test.js +28 -16
- package/dist/cli/session-commands.js +315 -69
- package/dist/cli/session-factory.js +13 -0
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +43 -4
- package/dist/errors/types.js +15 -0
- package/dist/hooks/config.js +18 -0
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/router.js +1 -1
- package/dist/hooks/service.js +4 -4
- package/dist/hooks/slash.js +10 -26
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- package/dist/memory/secrets.js +43 -0
- package/dist/plan/service.js +26 -1
- package/dist/plan/submit-plan.js +11 -0
- package/dist/render/capabilities.js +9 -2
- package/dist/render/context-view.js +2 -2
- package/dist/render/index.js +6 -1
- package/dist/render/plan-view.js +1 -1
- package/dist/render/status-view.js +5 -5
- package/dist/render/units.js +22 -0
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +19 -0
- package/dist/session/redact.js +74 -0
- package/dist/session/replay.js +16 -0
- package/dist/session/resume.js +8 -0
- package/dist/session/types.js +38 -0
- package/dist/subagent/orchestrator.js +82 -5
- package/dist/theme/resolve.js +1 -0
- package/dist/tui/app.js +25 -7
- package/dist/tui/approval-overlay.js +7 -1
- package/dist/tui/index.js +3 -2
- package/dist/tui/layout.js +7 -2
- package/dist/tui/limits-panel.js +6 -14
- package/dist/tui/mode-ring.js +84 -0
- package/dist/tui/palette.js +11 -19
- package/dist/tui/renderer.js +145 -4
- package/dist/tui/restore.js +137 -0
- package/dist/tui/supports.js +22 -0
- package/dist/tui/tool-versions.js +119 -18
- package/dist/usage/weighted.js +14 -0
- package/dist/utils/disk.js +11 -3
- 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
|
package/dist/agent/session.js
CHANGED
|
@@ -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,
|
|
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
|
|
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:
|
|
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) {
|