@cruxy/cli 1.4.0 → 1.6.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 (55) hide show
  1. package/dist/agent/session.js +75 -2
  2. package/dist/agent/status.js +41 -1
  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 +34 -3
  13. package/dist/cli/commands/test.js +28 -16
  14. package/dist/cli/session-commands.js +321 -70
  15. package/dist/cli/session-factory.js +13 -0
  16. package/dist/errors/constructors.js +111 -9
  17. package/dist/errors/types.js +15 -0
  18. package/dist/hooks/config.js +18 -0
  19. package/dist/hooks/index.js +1 -1
  20. package/dist/hooks/router.js +1 -1
  21. package/dist/hooks/service.js +4 -4
  22. package/dist/hooks/slash.js +10 -26
  23. package/dist/limits/cache.js +100 -0
  24. package/dist/limits/index.js +11 -0
  25. package/dist/limits/reduce.js +172 -0
  26. package/dist/limits/types.js +25 -0
  27. package/dist/memory/secrets.js +43 -0
  28. package/dist/onboarding/detect.js +95 -9
  29. package/dist/onboarding/types.js +29 -1
  30. package/dist/render/context-view.js +2 -2
  31. package/dist/render/plan-view.js +1 -1
  32. package/dist/render/status-view.js +56 -4
  33. package/dist/render/units.js +22 -0
  34. package/dist/session/index.js +1 -0
  35. package/dist/session/log.js +19 -0
  36. package/dist/session/redact.js +74 -0
  37. package/dist/session/replay.js +16 -0
  38. package/dist/session/resume.js +8 -0
  39. package/dist/session/types.js +38 -0
  40. package/dist/subagent/orchestrator.js +82 -5
  41. package/dist/theme/resolve.js +1 -0
  42. package/dist/theme/tokens.js +7 -0
  43. package/dist/tui/app.js +7 -4
  44. package/dist/tui/approval-overlay.js +7 -1
  45. package/dist/tui/disk-status.js +47 -0
  46. package/dist/tui/index.js +1 -0
  47. package/dist/tui/layout.js +8 -2
  48. package/dist/tui/limits-panel.js +247 -0
  49. package/dist/tui/overview.js +16 -4
  50. package/dist/tui/palette.js +11 -19
  51. package/dist/tui/panels.js +2 -0
  52. package/dist/tui/renderer.js +66 -0
  53. package/dist/usage/weighted.js +14 -0
  54. package/dist/utils/disk.js +103 -0
  55. package/package.json +3 -3
@@ -0,0 +1,247 @@
1
+ import { compactTokens } from "../render/units.js";
2
+ import { bindingWindow, usedFraction } from "../limits/index.js";
3
+ /**
4
+ * The limits panel (P9): what this credential may spend, and how much is left.
5
+ *
6
+ * THE PANEL IS DRIVEN BY THE BUCKET, NEVER BY THE LOGIN. Which numbers exist —
7
+ * and whether a fraction exists at all — is decided by the billing bucket the
8
+ * gateway reports, because that is the only thing that actually decides it. The
9
+ * inference this replaces (a CLI login is an apikey, so it is metered and
10
+ * ungated) was true when it was written and silently stopped being true when the
11
+ * gateway changed what a login mints (cruxy-ai/api#174), with nothing locally
12
+ * observable changing. So there are four bodies here, one per shape, and the
13
+ * type system hands each of them only the numbers that shape may state.
14
+ *
15
+ * AND THIS ONE GETS A BAR. The context panel refuses one under a locking test,
16
+ * for reasons that are exactly right there: its numerator is a chars/4 estimate
17
+ * and its denominator a local config default, so a filled bar would render a
18
+ * guess as a measurement. Every number here is the opposite — server-authored,
19
+ * in the meter's own unit, peeked from the same buckets the gate enforces on, so
20
+ * the fraction the bar fills IS the fraction the next request is judged against.
21
+ * A bar is the right claim for a real measurement, and only for one.
22
+ *
23
+ * The corollary is the rule the rest of this file exists to keep: NO CAP, NO
24
+ * FRACTION. An enterprise pool reports `{enforced: false}` and a developer key
25
+ * reports `metered: true`; neither has an allowance, so neither gets a bar, a
26
+ * percentage, or an X-of-Y — not even a reassuring full one. Spend caps are not
27
+ * a substitute, because they appear only when someone set one.
28
+ */
29
+ /** Cells in the bar. Sized so `bar + " " + "100%"` clears {@link RAIL_COLS}. */
30
+ const BAR_CELLS = 12;
31
+ /**
32
+ * A proportion bar. Empty under the screen-reader glyph table (both cells are
33
+ * ""), which is deliberate — a run of blocks announces as nothing, and the
34
+ * percentage that follows it carries the entire meaning.
35
+ */
36
+ export function bar(theme, fraction, cells = BAR_CELLS) {
37
+ const filled = Math.round(Math.min(1, Math.max(0, fraction)) * cells);
38
+ return (theme.glyph.barFilled.repeat(filled) +
39
+ theme.glyph.barEmpty.repeat(cells - filled));
40
+ }
41
+ /**
42
+ * The styler for a window's state. `low` and `blocked` are the two the user can
43
+ * act on, and they are different acts — one is "wrap up", the other is "you are
44
+ * already being refused" — so they are not merged into one warning.
45
+ */
46
+ function stateStyle(theme, state) {
47
+ if (state === "blocked")
48
+ return theme.danger;
49
+ if (state === "low")
50
+ return theme.warning;
51
+ return theme.strong;
52
+ }
53
+ /**
54
+ * Re-exported, not defined here any more (P10 track 3): `/budget` states the
55
+ * same windows in the same unit, so the two share one formatter.
56
+ */
57
+ export { compactTokens };
58
+ /** 27.77 → "$27.77", 29 → "$29", 0.5 → "$0.50". */
59
+ export function usd(n) {
60
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
61
+ }
62
+ /**
63
+ * How long until an ISO instant, compactly: "18d", "4h", "12m", "now".
64
+ * `undefined` for an absent or unparseable one — a reset we cannot state is
65
+ * simply not stated, never guessed at.
66
+ */
67
+ export function untilLabel(iso, now) {
68
+ if (!iso)
69
+ return undefined;
70
+ const at = Date.parse(iso);
71
+ if (Number.isNaN(at))
72
+ return undefined;
73
+ const ms = at - now;
74
+ if (ms <= 0)
75
+ return "now";
76
+ const minutes = Math.floor(ms / 60_000);
77
+ if (minutes < 60)
78
+ return `${Math.max(1, minutes)}m`;
79
+ const hours = Math.floor(minutes / 60);
80
+ if (hours < 24)
81
+ return `${hours}h`;
82
+ return `${Math.floor(hours / 24)}d`;
83
+ }
84
+ /** A percentage for display: 0.618 → "62%". */
85
+ function pct(fraction) {
86
+ return `${Math.round(fraction * 100)}%`;
87
+ }
88
+ /**
89
+ * Beyond this, the reading is called out as old rather than presented as
90
+ * current. The pool is shared with every other surface on the account, so an
91
+ * idle CLI's figures can go stale without this process doing anything at all —
92
+ * which is precisely the case a timestamp has to cover.
93
+ */
94
+ const STALE_AFTER_MS = 5 * 60_000;
95
+ /**
96
+ * The subscription pool: a bar on the BINDING window, the same one the gate
97
+ * decides against.
98
+ *
99
+ * Drawing the month instead — the bigger, friendlier number — would routinely
100
+ * show a comfortable 6% while the trailing-12h burst, a quarter of the month's
101
+ * cap on every self-serve tier, is the window actually about to refuse the next
102
+ * request. The bar is the thing that stops you, and the line under it names
103
+ * which window that is so the figure is never ambiguous.
104
+ */
105
+ function poolLines(theme, pool, now) {
106
+ const binding = bindingWindow(pool.monthly, pool.burst);
107
+ // Unreachable via `reduceLimits` (a pool with no readable window reduces to
108
+ // `unknown`), but the type permits it and inventing a bar is the one thing
109
+ // this panel must never do on the way to being defensive.
110
+ if (!binding)
111
+ return [theme.muted("figures not reported")];
112
+ const { window: w, name } = binding;
113
+ const fraction = usedFraction(w) ?? 0;
114
+ const style = stateStyle(theme, w.state ?? pool.state);
115
+ const lines = [
116
+ `${style(bar(theme, fraction))} ${style(pct(fraction))}`,
117
+ `${compactTokens(w.used)} / ${compactTokens(w.cap)} ${name}`,
118
+ ];
119
+ // The window that is NOT binding, so both dimensions are visible — a user
120
+ // whose burst is tight still needs to know the month is nearly gone too.
121
+ const other = name === "burst" ? pool.monthly : pool.burst;
122
+ const otherName = name === "burst" ? "month" : "burst";
123
+ const until = untilLabel(w.resetsAt, now);
124
+ if (other) {
125
+ const otherPct = pct(usedFraction(other) ?? 0);
126
+ lines.push(theme.muted(until
127
+ ? `${otherName} ${otherPct} ${theme.sep}${until}`
128
+ : `${otherName} ${otherPct}`));
129
+ }
130
+ else if (until) {
131
+ lines.push(theme.muted(`resets ${until}`));
132
+ }
133
+ // Only when it is actionable. A blocked model list is the answer to "why did
134
+ // that request fail"; mira's remaining requests are the answer to "what can I
135
+ // still run" — and neither question is being asked while the pool is healthy.
136
+ if (pool.blockedModels.length > 0) {
137
+ lines.push(theme.danger(`no ${pool.blockedModels.join(" ")}`));
138
+ }
139
+ else if (pool.state === "low" && pool.mira) {
140
+ lines.push(theme.muted(`mira ${compactTokens(pool.mira.remaining)} left`));
141
+ }
142
+ return lines;
143
+ }
144
+ /** A spend cap line. Text, never a bar: the bar belongs to the allowance. */
145
+ function spendCapLine(theme, label, cap) {
146
+ return theme.muted(`${label} ${usd(cap.remaining)} of ${usd(cap.cap)}`);
147
+ }
148
+ /** How long ago, compactly: the mirror of {@link untilLabel}. */
149
+ function agoLabel(ms) {
150
+ const minutes = Math.floor(ms / 60_000);
151
+ if (minutes < 60)
152
+ return `${Math.max(1, minutes)}m`;
153
+ const hours = Math.floor(minutes / 60);
154
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
155
+ }
156
+ /** The body for a ready reading, chosen by the budget shape. */
157
+ function readingLines(theme, reading, now) {
158
+ const lines = [];
159
+ const budget = reading.budget;
160
+ switch (budget.kind) {
161
+ case "pool":
162
+ lines.push(...poolLines(theme, budget, now));
163
+ break;
164
+ // Enterprise. NO fraction, and no consolation bar drawn at 0% either: a full
165
+ // green bar and "no limit" are read the same way at a glance, and only one
166
+ // of them is true.
167
+ case "unenforced":
168
+ lines.push(theme.strong(reading.tier));
169
+ lines.push(theme.muted("pool not enforced"));
170
+ break;
171
+ // A developer key: billed per token, gated by nothing it draws down. Saying
172
+ // "no pool" explicitly matters here — an empty budget section would read as
173
+ // a figure that failed to load rather than as a figure that does not exist.
174
+ case "metered":
175
+ lines.push(`${theme.strong(reading.tier)}${theme.muted(" metered")}`);
176
+ lines.push(theme.muted("no pool cap"));
177
+ break;
178
+ case "credits": {
179
+ const fraction = budget.granted > 0
180
+ ? Math.min(1, Math.max(0, budget.used / budget.granted))
181
+ : undefined;
182
+ if (fraction !== undefined) {
183
+ const style = fraction >= 0.9 ? theme.warning : theme.strong;
184
+ lines.push(`${style(bar(theme, fraction))} ${style(pct(fraction))}`);
185
+ }
186
+ lines.push(`${usd(budget.remaining)} of ${usd(budget.granted)}`);
187
+ const until = untilLabel(budget.resetsAt, now);
188
+ if (until)
189
+ lines.push(theme.muted(`resets ${until}`));
190
+ break;
191
+ }
192
+ // The bucket, or the pool inside it, is one this build cannot read. Both
193
+ // facts it DOES have are real and server-resolved, so both are shown; what
194
+ // it does not have, it declines to imply.
195
+ case "unknown":
196
+ lines.push(`${theme.strong(reading.tier)}${theme.muted(` ${theme.glyph.sep} ${reading.bucket}`)}`);
197
+ lines.push(theme.muted("budget not reported"));
198
+ break;
199
+ }
200
+ // Rate and spend caps hang off every bucket, so they are appended once here
201
+ // rather than repeated per branch. They are the ONLY ceilings a metered key
202
+ // has — and they appear only when they exist, which is why they can never
203
+ // stand in for the pool a metered key does not have.
204
+ if (budget.kind === "metered" && reading.chat) {
205
+ const { remaining, limit } = reading.chat.perKey;
206
+ lines.push(theme.muted(`${remaining} / ${limit} rpm`));
207
+ }
208
+ if (reading.keySpendCap) {
209
+ lines.push(spendCapLine(theme, "key", reading.keySpendCap));
210
+ }
211
+ if (reading.workspaceSpendCap) {
212
+ lines.push(spendCapLine(theme, "ws", reading.workspaceSpendCap));
213
+ }
214
+ // The cache keeps the last good reading through a failed refresh, so the panel
215
+ // owes the user the age of what it is showing rather than the impression that
216
+ // it is live.
217
+ const age = now - reading.readAt;
218
+ if (age > STALE_AFTER_MS) {
219
+ lines.push(theme.muted(`as of ${agoLabel(age)} ago`));
220
+ }
221
+ return lines;
222
+ }
223
+ /**
224
+ * The limits panel's body.
225
+ *
226
+ * `pending` and each error reason are said differently, on the tri-state
227
+ * discipline the git panel established: "still asking" must not read as "no
228
+ * limits", and neither may read as "your key is bad".
229
+ */
230
+ export function limitsPanelLines(theme, state, now = Date.now()) {
231
+ if (state === undefined || state.status === "pending") {
232
+ return [theme.muted(`checking${theme.glyph.ellipsis}`)];
233
+ }
234
+ if (state.status === "error") {
235
+ switch (state.reason) {
236
+ case "unauthenticated":
237
+ return [theme.muted("not signed in"), theme.muted("run cruxy login")];
238
+ case "unsupported":
239
+ // The gateway answered — it simply has no limits to report. Sending this
240
+ // user to debug their network would be the wrong errand entirely.
241
+ return [theme.muted("gateway reports"), theme.muted("no limits")];
242
+ case "unreachable":
243
+ return [theme.muted("gateway unreachable")];
244
+ }
245
+ }
246
+ return readingLines(theme, state.reading, now);
247
+ }
@@ -20,7 +20,8 @@ import { sessionStatusLines } from "../render/status-view.js";
20
20
  * cost two subprocesses each, ~45ms warm and worse on Windows, and the pane
21
21
  * repaints on every frame and every resize. {@link WorkspaceGitCache} does
22
22
  * the probing after a turn; {@link lines} only ever reads the last settled
23
- * value.
23
+ * value. Free space joins them: microseconds on a local disk, and an
24
+ * unbounded kernel block on a network mount that has stopped answering.
24
25
  *
25
26
  * The rule the split enforces: nothing in `lines` may spawn, block, or touch
26
27
  * the disk. Anything that needs to is a cache read here and a `refresh` there.
@@ -31,19 +32,30 @@ export function createOverviewView(session, git,
31
32
  * because it changes per turn and the view is registered once. Supplied by
32
33
  * the renderer, the only object that sees the stream's routing frame.
33
34
  */
34
- servedTier = () => undefined) {
35
+ servedTier = () => undefined,
36
+ /**
37
+ * Free space where cruxy writes. Optional so a caller that has no disk cache
38
+ * gets a status screen without disk rows rather than a probe it didn't ask
39
+ * for — the same shape `servedTier` uses for a fact only some callers have.
40
+ */
41
+ disk) {
35
42
  return {
36
43
  id: "overview",
37
44
  label: "overview",
38
- lines: (theme, cols) => sessionStatusLines(buildSessionStatus(session, (absPath) => git.current(absPath), servedTier()), theme, cols),
45
+ lines: (theme, cols) => sessionStatusLines(buildSessionStatus(session, (absPath) => git.current(absPath), servedTier(), disk ? (path) => disk.current(path) : undefined), theme, cols),
39
46
  /**
40
47
  * Re-probe every root after a turn. Invalidate-then-refresh because a turn
41
48
  * is exactly when the tree may have moved; the cache coalesces, so the ten
42
49
  * writes a turn makes still cost one probe per root.
50
+ *
51
+ * Disk refreshes alongside, and unconditionally — it has no invalidate,
52
+ * because free space moves for reasons that have nothing to do with this
53
+ * session (see `disk-status.ts`). Concurrently, not in sequence: they are
54
+ * independent reads and neither should wait on the other's slowest mount.
43
55
  */
44
56
  refresh: async () => {
45
57
  git.invalidate();
46
- await git.refresh();
58
+ await Promise.all([git.refresh(), disk?.refresh()]);
47
59
  },
48
60
  };
49
61
  }
@@ -1,29 +1,21 @@
1
1
  import { fuzzyFind } from "../components/fuzzy.js";
2
- import { COMMAND_CATALOG } from "../cli/session-commands.js";
2
+ import { COMMAND_CATALOG, TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
3
3
  import { canOverlay, createOverlayIO } from "./overlay.js";
4
- /** The TUI's own commands, which the shared catalogue deliberately excludes. */
5
- const PANEL_COMMANDS = [
6
- {
7
- name: "/close",
8
- summary: "hide a panel or the whole rail",
9
- args: "<sidebar | context | model | git | tools | rail>",
10
- },
11
- {
12
- name: "/open",
13
- summary: "show a hidden panel",
14
- args: "<sidebar | context | model | git | tools | rail>",
15
- },
16
- ];
17
4
  /**
18
- * Everything the palette offers: the shared catalogue, this shell's panel
5
+ * Everything the palette offers: the shared catalogue, this shell's own
19
6
  * commands, and the project's own slash commands.
20
7
  *
21
- * Custom commands come LAST and are labelled. `resolveSlash` consults builtins
22
- * first, so a custom command named `clear` can never shadow `/clear` listing
23
- * it above the builtin would show an order the dispatcher does not honour.
8
+ * The TUI's own three are {@link TUI_ONLY_COMMANDS} rather than a copy kept
9
+ * here. The copy had drifted it listed `/close` and `/open` and had never
10
+ * gained `/view`, so the one shell with a palette was also the one place `/view`
11
+ * could not be discovered.
12
+ *
13
+ * Custom commands come LAST and are labelled. A custom command can no longer be
14
+ * named after a reserved one at all (the loader refuses the file), so this list
15
+ * cannot contain two rows for one name.
24
16
  */
25
17
  export function paletteItems(slashCommands = []) {
26
- const builtins = [...COMMAND_CATALOG, ...PANEL_COMMANDS].map((c) => ({
18
+ const builtins = [...COMMAND_CATALOG, ...TUI_ONLY_COMMANDS].map((c) => ({
27
19
  name: c.name,
28
20
  summary: c.summary,
29
21
  ...(c.args === undefined ? {} : { args: c.args }),
@@ -17,6 +17,7 @@ import { RAIL_PANELS, } from "./layout.js";
17
17
  export const PANEL_LABELS = {
18
18
  sidebar: "sidebar",
19
19
  context: "context",
20
+ limits: "limits",
20
21
  model: "model",
21
22
  git: "git",
22
23
  tools: "tools",
@@ -30,6 +31,7 @@ export const COLUMN_LABELS = {
30
31
  /** Titles drawn at the top of each rail panel. */
31
32
  const RAIL_TITLES = {
32
33
  context: "context",
34
+ limits: "limits",
33
35
  model: "model",
34
36
  git: "git",
35
37
  tools: "tools",
@@ -11,6 +11,7 @@ import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "
11
11
  import { budgetColumns, bodyRows, composeScreen, droppedForWidth, fitOverlay, overlayRows, scrollNotice, scrollWindow, stackPanels, CLOSABLE_PANELS, } from "./layout.js";
12
12
  import { CONVERSATION_VIEW, cycleView, navLines, } from "./views.js";
13
13
  import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
14
+ import { limitsPanelLines } from "./limits-panel.js";
14
15
  /**
15
16
  * The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
16
17
  * only one that owns the whole screen rather than a single managed line.
@@ -93,6 +94,10 @@ export class TuiRenderer {
93
94
  git;
94
95
  /** Context-budget reading for the context panel (P4 track 3); absent → unwired. */
95
96
  context;
97
+ /** The account's headroom for the limits panel (P9); absent → panel unwired. */
98
+ limits;
99
+ /** Whether the first limits read has been kicked off (see {@link startLimits}). */
100
+ limitsStarted = false;
96
101
  /**
97
102
  * The configured model/tier as known at construction — the renderer is built
98
103
  * before the session exists, so this covers the window before {@link attachModel}
@@ -196,6 +201,20 @@ export class TuiRenderer {
196
201
  gauge.sample();
197
202
  this.schedulePaint();
198
203
  }
204
+ /**
205
+ * Attach the limits cache (P9). Set after construction like the context gauge,
206
+ * because the credential it reads with is resolved alongside the session.
207
+ *
208
+ * NO PROBE HAPPENS HERE. The first read is deferred to the first paint that
209
+ * actually shows the panel — the rule the tool probes already follow — so a
210
+ * user who keeps `limits` closed, or who runs a one-shot that never paints a
211
+ * rail, never makes the request at all. The panel is a status surface; it does
212
+ * not get to spend a round trip on someone who is not looking at it.
213
+ */
214
+ attachLimits(limits) {
215
+ this.limits = limits;
216
+ this.schedulePaint();
217
+ }
199
218
  /**
200
219
  * Adopt the session's live model choice (P6 track 1). Set after construction
201
220
  * for the same reason the context gauge is: the choice belongs to the session,
@@ -233,6 +252,24 @@ export class TuiRenderer {
233
252
  * nothing after the first; the repaint callback is what lets each row appear
234
253
  * as its own probe lands rather than all at once at the end.
235
254
  */
255
+ /**
256
+ * Kick off the first limits read, at the first paint that actually SHOWS the
257
+ * panel. Returns the cache so the paint path can read it in one expression.
258
+ *
259
+ * Guarded by its own flag rather than by the cache's interval floor: the floor
260
+ * exists to coalesce refreshes AFTER a reading exists, and would not stop the
261
+ * paint path from firing a request on every frame before the first one lands.
262
+ */
263
+ startLimits(limits) {
264
+ if (!this.limitsStarted) {
265
+ this.limitsStarted = true;
266
+ void limits.refresh().then(() => {
267
+ if (!this.closed)
268
+ this.schedulePaint();
269
+ });
270
+ }
271
+ return limits;
272
+ }
236
273
  startTools(tools) {
237
274
  tools.start(() => {
238
275
  if (!this.closed)
@@ -639,8 +676,29 @@ export class TuiRenderer {
639
676
  this.context?.sample();
640
677
  this.paintNow();
641
678
  this.refreshGit();
679
+ this.refreshLimits();
642
680
  this.refreshViews();
643
681
  }
682
+ /**
683
+ * Re-read the account's headroom after a turn (P9) — the one moment it is
684
+ * KNOWN to have moved, because this process just spent some of it.
685
+ *
686
+ * Same rules as the git probe: never awaited, a failure leaves the last good
687
+ * reading standing, and the cache coalesces. Unlike git it is also floored by
688
+ * a minimum interval, because this one crosses the network — and unlike git it
689
+ * is skipped entirely while the panel is closed, since a user who has hidden
690
+ * the figures has no use for the request that fetches them.
691
+ */
692
+ refreshLimits() {
693
+ const limits = this.limits;
694
+ if (limits === undefined || this.closed || !this.open.has("limits"))
695
+ return;
696
+ void limits.refresh().then(() => {
697
+ if (this.closed)
698
+ return;
699
+ this.schedulePaint();
700
+ });
701
+ }
644
702
  /**
645
703
  * Re-probe the working tree, off the paint path, and repaint when it lands.
646
704
  *
@@ -1043,6 +1101,14 @@ export class TuiRenderer {
1043
1101
  ...(this.context === undefined
1044
1102
  ? {}
1045
1103
  : { context: contextPanelLines(this.theme, this.context.current()) }),
1104
+ // Like the tool probes: the first network read starts at the first paint
1105
+ // that SHOWS this panel, never at startup and never at all while it is
1106
+ // closed. `startLimits` is idempotent, so painting it costs one request.
1107
+ ...(this.limits === undefined || !this.open.has("limits")
1108
+ ? {}
1109
+ : {
1110
+ limits: limitsPanelLines(this.theme, this.startLimits(this.limits).current()),
1111
+ }),
1046
1112
  ...(this.git === undefined
1047
1113
  ? {}
1048
1114
  : { git: gitPanelLines(this.theme, this.git.current()) }),
@@ -41,6 +41,20 @@ export const TIER_MULTIPLIERS = {
41
41
  export function multiplierForTier(tier) {
42
42
  return TIER_MULTIPLIERS[tier];
43
43
  }
44
+ /**
45
+ * The heaviest multiplier any chat tier carries.
46
+ *
47
+ * FOR ADMISSION ONLY, never for accounting. A request routed `auto` has no tier
48
+ * until the gateway answers, so an admission check that must decide BEFORE the
49
+ * request has to assume something — and the only safe assumption is the most
50
+ * expensive one. Assuming the cheapest (or skipping the check) admits a batch
51
+ * that then trips a sliding window which refills by trickle over twelve hours.
52
+ *
53
+ * {@link weightedFor} deliberately does NOT use this: after the fact the tier is
54
+ * known, and substituting a guess there would be exactly the confidently-wrong
55
+ * number that function's doc comment refuses to produce.
56
+ */
57
+ export const MAX_TIER_MULTIPLIER = Math.max(...Object.values(TIER_MULTIPLIERS));
44
58
  /**
45
59
  * Weighted tokens for one request's figures, or `undefined` when the request
46
60
  * cannot be weighed honestly.
@@ -0,0 +1,103 @@
1
+ import { statfsSync } from "node:fs";
2
+ import { statfs } from "node:fs/promises";
3
+ const GIB = 1024 ** 3;
4
+ /** Below this, a run that shadow-copies a repo can plausibly fail. */
5
+ const LOW_FREE_BYTES = 5 * GIB;
6
+ /** Below this, assume the next sizeable write fails. */
7
+ const CRITICAL_FREE_BYTES = 1 * GIB;
8
+ export function capacityLevel(capacity) {
9
+ if (capacity.freeBytes < CRITICAL_FREE_BYTES)
10
+ return "critical";
11
+ if (capacity.freeBytes < LOW_FREE_BYTES)
12
+ return "low";
13
+ return "ok";
14
+ }
15
+ /**
16
+ * Turn a `statfs` result into a capacity, or `undefined` when it cannot say
17
+ * anything true.
18
+ *
19
+ * `blocks === 0` is the guard that matters: some pseudo-filesystems report a
20
+ * zero total, and a percentage derived from it is `NaN` or `Infinity` — a
21
+ * "0% free" that means "we have no idea" is the single worst thing this could
22
+ * render, because it is indistinguishable from a genuinely full disk.
23
+ *
24
+ * Exported for the test that pins that guard: it is the one branch here that
25
+ * cannot be reached through a real `statfs` on any machine CI runs on.
26
+ */
27
+ export function capacityFromStats(stats) {
28
+ const blockSize = Number(stats.bsize);
29
+ const blocks = Number(stats.blocks);
30
+ const available = Number(stats.bavail);
31
+ if (!(blockSize > 0) || !(blocks > 0) || !(available >= 0))
32
+ return undefined;
33
+ const totalBytes = blocks * blockSize;
34
+ const freeBytes = available * blockSize;
35
+ return {
36
+ freeBytes,
37
+ totalBytes,
38
+ freePercent: Math.floor((freeBytes / totalBytes) * 100),
39
+ };
40
+ }
41
+ /**
42
+ * Capacity of the filesystem holding `path`, or `undefined` if it can't be
43
+ * read (a path that doesn't exist, a platform or mount that won't answer).
44
+ *
45
+ * ASYNC because a `statfs` is only microseconds on a local disk — the whole
46
+ * reason this is affordable — but can block for SECONDS on an unresponsive
47
+ * network mount, and someone will eventually run cruxy in a repo on NFS. The
48
+ * cost model that justifies the feature holds for the common case; the API is
49
+ * shaped for the uncommon one.
50
+ */
51
+ export async function readDiskCapacity(path) {
52
+ try {
53
+ return capacityFromStats(await statfs(path));
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ /**
60
+ * The synchronous read, for the one caller that is allowed one: a command the
61
+ * user just typed, which has no frame to lose and every reason to print a real
62
+ * answer instead of "checking…". Never call this from a paint path.
63
+ */
64
+ export function readDiskCapacitySync(path) {
65
+ try {
66
+ return capacityFromStats(statfsSync(path));
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ const UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
73
+ /**
74
+ * `8.1 GiB`, `465 GiB`, `912 MiB`.
75
+ *
76
+ * Binary units with binary labels. `GB` for 2^30 is the ambiguity that makes
77
+ * people distrust a number they were about to act on; if the unit is 1024-based
78
+ * the label says so.
79
+ */
80
+ export function formatBytes(bytes) {
81
+ let value = Math.max(0, bytes);
82
+ let unit = 0;
83
+ while (value >= 1024 && unit < UNITS.length - 1) {
84
+ value /= 1024;
85
+ unit++;
86
+ }
87
+ // One decimal only while it carries information: "8.1 GiB" is a different
88
+ // amount from "8 GiB", "465.3 GiB" is not meaningfully different from "465".
89
+ const digits = unit > 0 && value < 10 ? 1 : 0;
90
+ return `${value.toFixed(digits)} ${UNITS[unit]}`;
91
+ }
92
+ /**
93
+ * `2% free · 8.1 GiB of 465 GiB` — the percentage first, since that's the read.
94
+ *
95
+ * The joiner is a PARAMETER rather than a literal: this module has no theme (it
96
+ * is a `statfs` reader, not a renderer), and a hardcoded `·` would survive
97
+ * `CRUXY_ASCII` and `TERM=dumb` on a terminal that renders it as mojibake, and
98
+ * announce as noise to a screen reader. Callers pass `theme.glyph.sep`; the
99
+ * default keeps every existing caller and every log line unchanged.
100
+ */
101
+ export function formatCapacity(capacity, sep = "·") {
102
+ return `${capacity.freePercent}% free ${sep} ${formatBytes(capacity.freeBytes)} of ${formatBytes(capacity.totalBytes)}`;
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "undici": "^6.21.0",
37
37
  "zod": "^3.23.8",
38
38
  "zod-to-json-schema": "^3.23.5",
39
- "@cruxy/sdk": "0.4.1"
39
+ "@cruxy/sdk": "0.5.0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"
@@ -52,7 +52,7 @@
52
52
  "build": "tsc -p tsconfig.json",
53
53
  "dev": "tsx src/index.ts",
54
54
  "start": "node dist/index.js",
55
- "typecheck": "tsc --noEmit",
55
+ "typecheck": "tsc -p tsconfig.typecheck.json",
56
56
  "clean": "rm -rf dist",
57
57
  "test": "vitest run"
58
58
  }