@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.
- package/dist/agent/session.js +75 -2
- package/dist/agent/status.js +41 -1
- 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 +34 -3
- package/dist/cli/commands/test.js +28 -16
- package/dist/cli/session-commands.js +321 -70
- package/dist/cli/session-factory.js +13 -0
- package/dist/errors/constructors.js +111 -9
- 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/limits/cache.js +100 -0
- package/dist/limits/index.js +11 -0
- package/dist/limits/reduce.js +172 -0
- package/dist/limits/types.js +25 -0
- package/dist/memory/secrets.js +43 -0
- package/dist/onboarding/detect.js +95 -9
- package/dist/onboarding/types.js +29 -1
- package/dist/render/context-view.js +2 -2
- package/dist/render/plan-view.js +1 -1
- package/dist/render/status-view.js +56 -4
- 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/theme/tokens.js +7 -0
- package/dist/tui/app.js +7 -4
- package/dist/tui/approval-overlay.js +7 -1
- package/dist/tui/disk-status.js +47 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/layout.js +8 -2
- package/dist/tui/limits-panel.js +247 -0
- package/dist/tui/overview.js +16 -4
- package/dist/tui/palette.js +11 -19
- package/dist/tui/panels.js +2 -0
- package/dist/tui/renderer.js +66 -0
- package/dist/usage/weighted.js +14 -0
- package/dist/utils/disk.js +103 -0
- 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
|
+
}
|
package/dist/tui/overview.js
CHANGED
|
@@ -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
|
}
|
package/dist/tui/palette.js
CHANGED
|
@@ -1,29 +1,21 @@
|
|
|
1
1
|
import { fuzzyFind } from "../components/fuzzy.js";
|
|
2
|
-
import { COMMAND_CATALOG } from "../cli/
|
|
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
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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, ...
|
|
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 }),
|
package/dist/tui/panels.js
CHANGED
|
@@ -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",
|
package/dist/tui/renderer.js
CHANGED
|
@@ -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()) }),
|
package/dist/usage/weighted.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
55
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
56
56
|
"clean": "rm -rf dist",
|
|
57
57
|
"test": "vitest run"
|
|
58
58
|
}
|