@cruxy/cli 1.8.1 → 1.10.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 +1 -1
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +62 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/login.js +18 -5
- package/dist/cli/commands/pr.js +10 -1
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +55 -5
- package/dist/cli/commands/sessions.js +156 -0
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/credential-lifetime.js +42 -0
- package/dist/config/credentials.js +66 -0
- package/dist/config/schema.js +141 -9
- package/dist/constants.js +12 -2
- package/dist/errors/boundary.js +4 -4
- package/dist/errors/constructors.js +136 -57
- package/dist/errors/types.js +18 -0
- package/dist/index.js +27 -1
- package/dist/jobs/manager.js +269 -17
- package/dist/limits/cache.js +21 -5
- package/dist/mcp/client.js +16 -0
- package/dist/onboarding/flow.js +121 -6
- package/dist/onboarding/steps.js +112 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +106 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +71 -31
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +62 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +27 -0
- package/package.json +2 -2
package/dist/onboarding/steps.js
CHANGED
|
@@ -8,6 +8,118 @@ import { loadProjectInstructions, scaffoldProjectInstructions, } from "../config
|
|
|
8
8
|
*/
|
|
9
9
|
const MAX_KEY_ATTEMPTS = 3;
|
|
10
10
|
const c = (io) => themeForColor(io.color);
|
|
11
|
+
/**
|
|
12
|
+
* Sign in by approving in a browser — the default path.
|
|
13
|
+
*
|
|
14
|
+
* WHY IT IS THE DEFAULT, and why it is not merely a nicer prompt: a pasted key
|
|
15
|
+
* is minted through the admin issue-key route, which cannot produce a
|
|
16
|
+
* subscription credential — asking for one there is explicitly refused. So every
|
|
17
|
+
* pasted CLI key lands in the metered `apikey` bucket with no token pool, while
|
|
18
|
+
* the same human's web chat and desktop draw on their plan. One human, two
|
|
19
|
+
* budgets. The device flow mints through the login issuer, which is the only
|
|
20
|
+
* path to the subscription bucket, so the credential it produces draws on the
|
|
21
|
+
* pool the user already has.
|
|
22
|
+
*
|
|
23
|
+
* THE KEY IS NOT RE-VALIDATED. The gateway minted it seconds ago and returned it
|
|
24
|
+
* over the same connection; a `validateKey` call here would spend a real,
|
|
25
|
+
* billable request to re-prove a fact we were just told — against the very pool
|
|
26
|
+
* this step exists to establish. The paste path validates because there a key is
|
|
27
|
+
* an unverified claim by the user; here it is the gateway's own answer.
|
|
28
|
+
*
|
|
29
|
+
* Every outcome is reported, never thrown: a denial and an expiry are the flow
|
|
30
|
+
* working correctly, and both leave the existing credential untouched.
|
|
31
|
+
*/
|
|
32
|
+
export async function deviceLoginStep(io, deps, provider) {
|
|
33
|
+
const col = c(io);
|
|
34
|
+
if (!deps.deviceLogin)
|
|
35
|
+
return { status: "skipped" };
|
|
36
|
+
const outcome = await deps.deviceLogin(deviceIO(io));
|
|
37
|
+
switch (outcome.status) {
|
|
38
|
+
case "ok": {
|
|
39
|
+
deps.writeCredentialWithMeta(provider, outcome.apiKey, {
|
|
40
|
+
...(outcome.expiresAt !== undefined
|
|
41
|
+
? { expiresAt: outcome.expiresAt }
|
|
42
|
+
: {}),
|
|
43
|
+
...(outcome.keyId !== undefined ? { keyId: outcome.keyId } : {}),
|
|
44
|
+
source: "device",
|
|
45
|
+
});
|
|
46
|
+
io.write(`${col.success(col.glyph.success)} signed in — credential saved to ~/.cruxy\n`);
|
|
47
|
+
if (outcome.expiresAt) {
|
|
48
|
+
io.write(col.muted(` it expires on ${formatExpiry(outcome.expiresAt)}; run \`cruxy login\` again before then.\n`));
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
status: "ok",
|
|
52
|
+
apiKey: outcome.apiKey,
|
|
53
|
+
...(outcome.expiresAt !== undefined
|
|
54
|
+
? { expiresAt: outcome.expiresAt }
|
|
55
|
+
: {}),
|
|
56
|
+
...(outcome.keyId !== undefined ? { keyId: outcome.keyId } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
case "denied":
|
|
60
|
+
io.write(`${col.danger(col.glyph.failure)} the sign-in was declined in the browser.\n`);
|
|
61
|
+
return { status: "failed", message: "sign-in declined" };
|
|
62
|
+
case "expired":
|
|
63
|
+
io.write(`${col.danger(col.glyph.failure)} the code expired before it was approved.\n`);
|
|
64
|
+
return {
|
|
65
|
+
status: "failed",
|
|
66
|
+
message: "the code expired — run `cruxy login` to get a new one",
|
|
67
|
+
};
|
|
68
|
+
case "invalid":
|
|
69
|
+
// The gateway collapses four causes into one code on purpose and tells us
|
|
70
|
+
// nothing about which; all four are answered by starting over.
|
|
71
|
+
io.write(`${col.danger(col.glyph.failure)} that sign-in could not be completed.\n`);
|
|
72
|
+
return {
|
|
73
|
+
status: "failed",
|
|
74
|
+
message: "sign-in could not be completed — run `cruxy login` to retry",
|
|
75
|
+
};
|
|
76
|
+
case "unreachable":
|
|
77
|
+
io.write(`${col.danger(col.glyph.failure)} couldn't reach the gateway to sign in.\n`);
|
|
78
|
+
return { status: "failed", message: outcome.message };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Render the device flow's two messages onto the onboarding IO. */
|
|
82
|
+
function deviceIO(io) {
|
|
83
|
+
const col = c(io);
|
|
84
|
+
let lastNote = "";
|
|
85
|
+
return {
|
|
86
|
+
prompt: (session) => {
|
|
87
|
+
const link = session.verificationUriComplete ?? session.verificationUri;
|
|
88
|
+
io.write(`\nTo sign in, open ${col.accent(link)}\n` +
|
|
89
|
+
`and enter the code ${col.strong(session.userCode)}\n\n` +
|
|
90
|
+
col.muted(`waiting for approval (expires in ${humanDuration(session.expiresInMs)})…\n`));
|
|
91
|
+
},
|
|
92
|
+
waiting: (info) => {
|
|
93
|
+
// Only the throttle is worth saying out loud, and only once: a line per
|
|
94
|
+
// poll would scroll a quiet wait off the screen, and "still waiting" adds
|
|
95
|
+
// nothing to the "waiting for approval" already on it.
|
|
96
|
+
if (!info.throttled || lastNote === "throttled")
|
|
97
|
+
return;
|
|
98
|
+
lastNote = "throttled";
|
|
99
|
+
io.write(col.muted(" the gateway asked us to slow down; still waiting…\n"));
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** "2 days", "9 minutes" — coarse on purpose; this is a reassurance, not a timer. */
|
|
104
|
+
function humanDuration(ms) {
|
|
105
|
+
const minutes = Math.round(ms / 60_000);
|
|
106
|
+
if (minutes < 1)
|
|
107
|
+
return "less than a minute";
|
|
108
|
+
if (minutes < 60)
|
|
109
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
110
|
+
const hours = Math.round(minutes / 60);
|
|
111
|
+
if (hours < 24)
|
|
112
|
+
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
|
113
|
+
const days = Math.round(hours / 24);
|
|
114
|
+
return `${days} day${days === 1 ? "" : "s"}`;
|
|
115
|
+
}
|
|
116
|
+
/** The date part of an RFC 3339 expiry, or the raw string if it will not parse. */
|
|
117
|
+
function formatExpiry(expiresAt) {
|
|
118
|
+
const at = Date.parse(expiresAt);
|
|
119
|
+
if (Number.isNaN(at))
|
|
120
|
+
return expiresAt;
|
|
121
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
122
|
+
}
|
|
11
123
|
/**
|
|
12
124
|
* Acquire and persist a provider key: print the create-key URL, read it masked,
|
|
13
125
|
* validate it live, and **only then** write it to the credentials store. Loops on
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { compactTokens } from "./units.js";
|
|
2
|
+
import { buildLimitsSummary } from "./limits-view.js";
|
|
3
|
+
/**
|
|
4
|
+
* Rendering `cruxy limits` (cli#138) — the session-less half of the P9 reading.
|
|
5
|
+
*
|
|
6
|
+
* WHY A COMMAND AND NOT A COLUMN IN `cruxy usage`. The two answer questions with
|
|
7
|
+
* no common denominator, and joining them produces a number that reads as
|
|
8
|
+
* authoritative while being about two different things: `usage/store.ts` counts
|
|
9
|
+
* this machine's last FIFTY RUNS — a count, not a duration — while the pool is a
|
|
10
|
+
* calendar month and a trailing 12h window, already inclusive of every other
|
|
11
|
+
* surface on the account. The gap is not a rounding error. A credential minted
|
|
12
|
+
* seconds earlier, with zero local runs behind it, reported 63% of its month
|
|
13
|
+
* already drawn from web chat and desktop; any ratio over those two numerators
|
|
14
|
+
* would have been wrong by nearly its whole value on day one.
|
|
15
|
+
*
|
|
16
|
+
* So `cruxy usage` keeps its local-only guarantee and this command makes the
|
|
17
|
+
* call. THE COMMAND SPLIT IS THE BOUNDARY, and it is worth saying plainly that
|
|
18
|
+
* the no-phone-home guard is not: that guard scans a named list of files, this
|
|
19
|
+
* file is deliberately not on it, and a green run of it proves nothing whatever
|
|
20
|
+
* about a file it was never pointed at. What keeps `cruxy usage` local is that
|
|
21
|
+
* the network lives in a different command, not that a test noticed.
|
|
22
|
+
*
|
|
23
|
+
* IT NAMES THE WINDOW RATHER THAN THE ABSTRACTION. The rail picks one window
|
|
24
|
+
* because it has room for one bar, and `session-budget.ts` picks one because
|
|
25
|
+
* admission has to spend against one — both are right, and both are choices a
|
|
26
|
+
* SURFACE has to make. A command has room for neither excuse: it prints
|
|
27
|
+
* `monthly` and `12h burst` by name, in the vocabulary `/budget` already uses,
|
|
28
|
+
* because "the binding window" is a phrase that at Free tier (where
|
|
29
|
+
* `burst.cap === monthly.cap`) resolves to the same window essentially always —
|
|
30
|
+
* an abstraction that never varies, costing the reader the one fact they came
|
|
31
|
+
* for.
|
|
32
|
+
*/
|
|
33
|
+
/** Bar width. Wider than the rail's twelve cells; a command has the columns. */
|
|
34
|
+
const BAR_CELLS = 24;
|
|
35
|
+
/** The label each window goes by here — see the module comment on naming. */
|
|
36
|
+
const WINDOW_LABEL = {
|
|
37
|
+
month: "monthly",
|
|
38
|
+
burst: "12h burst",
|
|
39
|
+
};
|
|
40
|
+
/** Widest label, so every row's bar starts in the same column. */
|
|
41
|
+
const LABEL_COLS = Math.max(...Object.values(WINDOW_LABEL).map((l) => l.length));
|
|
42
|
+
function bar(theme, fraction) {
|
|
43
|
+
const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BAR_CELLS);
|
|
44
|
+
return (theme.glyph.barFilled.repeat(filled) +
|
|
45
|
+
theme.glyph.barEmpty.repeat(BAR_CELLS - filled));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A percentage, right-aligned to the width of "100%", so the figures beside it
|
|
49
|
+
* start in the same column on every row. A ragged column reads as two unrelated
|
|
50
|
+
* numbers rather than one comparison, which is the whole point of stacking the
|
|
51
|
+
* windows.
|
|
52
|
+
*/
|
|
53
|
+
function pct(fraction) {
|
|
54
|
+
return `${Math.round(fraction * 100)}%`.padStart(4);
|
|
55
|
+
}
|
|
56
|
+
/** 27.77 → "$27.77", 29 → "$29". Same rule the panel uses. */
|
|
57
|
+
function usd(n) {
|
|
58
|
+
return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
|
|
59
|
+
}
|
|
60
|
+
/** "in 18d" / "in 4h", or nothing at all when there is no reset to state. */
|
|
61
|
+
function resetLabel(iso, now) {
|
|
62
|
+
if (!iso)
|
|
63
|
+
return undefined;
|
|
64
|
+
const at = Date.parse(iso);
|
|
65
|
+
if (Number.isNaN(at))
|
|
66
|
+
return undefined;
|
|
67
|
+
const ms = at - now;
|
|
68
|
+
if (ms <= 0)
|
|
69
|
+
return "resets now";
|
|
70
|
+
const minutes = Math.floor(ms / 60_000);
|
|
71
|
+
if (minutes < 60)
|
|
72
|
+
return `resets in ${Math.max(1, minutes)}m`;
|
|
73
|
+
const hours = Math.floor(minutes / 60);
|
|
74
|
+
if (hours < 24)
|
|
75
|
+
return `resets in ${hours}h`;
|
|
76
|
+
return `resets in ${Math.floor(hours / 24)}d`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* How long ago a reading was taken, as a whole sentence.
|
|
80
|
+
*
|
|
81
|
+
* "just now" is a real case here and is not one for the rail: the panel only
|
|
82
|
+
* ever says this past a five-minute staleness threshold, while a command probes
|
|
83
|
+
* and prints in the same breath. Rounding that up to "1m ago" would age a
|
|
84
|
+
* reading by a minute it did not have.
|
|
85
|
+
*/
|
|
86
|
+
function readAgeLine(ms) {
|
|
87
|
+
const minutes = Math.floor(ms / 60_000);
|
|
88
|
+
if (minutes < 1)
|
|
89
|
+
return "read just now";
|
|
90
|
+
if (minutes < 60)
|
|
91
|
+
return `read ${minutes}m ago`;
|
|
92
|
+
const hours = Math.floor(minutes / 60);
|
|
93
|
+
return hours < 24
|
|
94
|
+
? `read ${hours}h ago`
|
|
95
|
+
: `read ${Math.floor(hours / 24)}d ago`;
|
|
96
|
+
}
|
|
97
|
+
/** One window: name, bar, percentage, the figures, and its reset if it has one. */
|
|
98
|
+
function windowLine(theme, w, now) {
|
|
99
|
+
const style = w.tone === "danger"
|
|
100
|
+
? theme.danger
|
|
101
|
+
: w.tone === "warn"
|
|
102
|
+
? theme.warning
|
|
103
|
+
: theme.strong;
|
|
104
|
+
const label = WINDOW_LABEL[w.key].padEnd(LABEL_COLS);
|
|
105
|
+
const figures = `${compactTokens(w.window.used)} / ${compactTokens(w.window.cap)}`;
|
|
106
|
+
const reset = resetLabel(w.window.resetsAt, now);
|
|
107
|
+
return (` ${theme.strong(label)} ${style(bar(theme, w.fraction))} ` +
|
|
108
|
+
`${style(pct(w.fraction))} ${theme.muted(figures)}` +
|
|
109
|
+
(reset ? theme.muted(`${theme.sep}${reset}`) : ""));
|
|
110
|
+
}
|
|
111
|
+
function spendCapLine(theme, label, cap) {
|
|
112
|
+
const name = label === "ws" ? "workspace spend cap" : "key spend cap";
|
|
113
|
+
return theme.muted(` ${name} ${usd(cap.remaining)} of ${usd(cap.cap)} left`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The whole report, or the one honest sentence when there is no reading.
|
|
117
|
+
*
|
|
118
|
+
* The three not-a-reading states are said three ways, on the same discipline the
|
|
119
|
+
* panel keeps: "still asking" must not read as "no limits", and neither may read
|
|
120
|
+
* as "your key is bad". A command exits after saying it, so each one also gets
|
|
121
|
+
* the next step rather than leaving the user to infer it from four words.
|
|
122
|
+
*/
|
|
123
|
+
export function limitsReportLines(theme, state, now = Date.now()) {
|
|
124
|
+
if (state.status === "pending") {
|
|
125
|
+
return [theme.muted("the limits reading has not arrived yet")];
|
|
126
|
+
}
|
|
127
|
+
if (state.status === "error") {
|
|
128
|
+
switch (state.reason) {
|
|
129
|
+
case "unauthenticated":
|
|
130
|
+
return [
|
|
131
|
+
theme.warning("not signed in"),
|
|
132
|
+
theme.muted("run `cruxy login` to sign in"),
|
|
133
|
+
];
|
|
134
|
+
// A different FACT from "not signed in", and the fact is the point: this
|
|
135
|
+
// user's setup was right and simply aged out. Sending them to look for
|
|
136
|
+
// what they configured wrong would be sending them after nothing.
|
|
137
|
+
case "expired":
|
|
138
|
+
return [
|
|
139
|
+
theme.warning("your sign-in has expired"),
|
|
140
|
+
theme.muted("run `cruxy login` to sign in again"),
|
|
141
|
+
];
|
|
142
|
+
// The gateway ANSWERED — it has no limits to report. Telling this user to
|
|
143
|
+
// check their network would be the wrong errand entirely.
|
|
144
|
+
case "unsupported":
|
|
145
|
+
return [
|
|
146
|
+
theme.muted("this gateway does not report limits"),
|
|
147
|
+
theme.muted("nothing here is wrong with your setup or your network"),
|
|
148
|
+
];
|
|
149
|
+
case "unreachable":
|
|
150
|
+
return [
|
|
151
|
+
theme.warning("could not reach the gateway"),
|
|
152
|
+
theme.muted("check your connection and try again"),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const s = buildLimitsSummary(state.reading, now);
|
|
157
|
+
const lines = [
|
|
158
|
+
`${theme.heading(s.tier)} ${theme.muted(`${theme.glyph.sep} ${s.bucket}`)}`,
|
|
159
|
+
];
|
|
160
|
+
if (s.windows.length > 0) {
|
|
161
|
+
lines.push("");
|
|
162
|
+
for (const w of s.windows)
|
|
163
|
+
lines.push(windowLine(theme, w, now));
|
|
164
|
+
// The unit, once, under the windows it applies to. Named because a bare
|
|
165
|
+
// "942.8k" invites being read as requests or as raw tokens, and it is
|
|
166
|
+
// neither: it is `(billable_input + output) × tier multiplier`, the unit the
|
|
167
|
+
// meter counts in and the only one anything enforces.
|
|
168
|
+
lines.push(theme.muted(` ${" ".repeat(LABEL_COLS)} weighted tokens`));
|
|
169
|
+
}
|
|
170
|
+
// Why there is nothing to draw, for the shapes that have nothing. Stated, not
|
|
171
|
+
// omitted: an empty section reads as a figure that failed to load.
|
|
172
|
+
if (s.noAllowance) {
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push(` ${theme.muted(s.noAllowance)}`);
|
|
175
|
+
}
|
|
176
|
+
// What this credential could have and does not (cli#263). An offer, not a
|
|
177
|
+
// correction — a deliberate `--paste` user is on an apikey on purpose and
|
|
178
|
+
// should be able to read this line and correctly ignore it.
|
|
179
|
+
for (const note of s.notes) {
|
|
180
|
+
lines.push(` ${theme.muted(note)}`);
|
|
181
|
+
}
|
|
182
|
+
const pool = s.budget;
|
|
183
|
+
if (pool.kind === "pool") {
|
|
184
|
+
// Both stated whenever they exist. The rail shows them only when the pool is
|
|
185
|
+
// already low, because it has five lines; this has a terminal, and "what can
|
|
186
|
+
// I still run" is a fair question before the answer becomes urgent.
|
|
187
|
+
if (pool.mira) {
|
|
188
|
+
lines.push(theme.muted(` mira ${compactTokens(pool.mira.remaining)} of ${compactTokens(pool.mira.cap)} requests left${theme.sep}${pool.mira.window}`));
|
|
189
|
+
}
|
|
190
|
+
if (pool.blockedModels.length > 0) {
|
|
191
|
+
lines.push(theme.danger(` refusing: ${pool.blockedModels.join(", ")}`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (pool.kind === "credits") {
|
|
195
|
+
lines.push("");
|
|
196
|
+
lines.push(` ${theme.strong(usd(pool.remaining))} ${theme.muted(`of ${usd(pool.granted)} left`)}`);
|
|
197
|
+
const reset = resetLabel(pool.resetsAt, now);
|
|
198
|
+
if (reset)
|
|
199
|
+
lines.push(theme.muted(` ${reset}`));
|
|
200
|
+
}
|
|
201
|
+
if (s.rpm) {
|
|
202
|
+
lines.push(theme.muted(` rate ${s.rpm.remaining} of ${s.rpm.limit} requests/min`));
|
|
203
|
+
}
|
|
204
|
+
for (const { label, cap } of s.spendCaps) {
|
|
205
|
+
lines.push(spendCapLine(theme, label, cap));
|
|
206
|
+
}
|
|
207
|
+
// Always, not only past a staleness threshold. The rail is repainting and can
|
|
208
|
+
// afford to stay quiet while a figure is fresh; a command prints once and is
|
|
209
|
+
// read later, so the reading's age is part of what it said.
|
|
210
|
+
lines.push("");
|
|
211
|
+
lines.push(theme.muted(readAgeLine(s.ageMs)));
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { usedFraction } from "../limits/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* A metered credential's one missing capability, stated as availability
|
|
4
|
+
* (cli#263).
|
|
5
|
+
*
|
|
6
|
+
* KEYED OFF `bucket`, NOT off a missing credential expiry. The two look
|
|
7
|
+
* interchangeable and answer different questions: `bucket === "apikey"` says
|
|
8
|
+
* *this credential type cannot have a pool*, while a missing `keyMeta.expiresAt`
|
|
9
|
+
* says *we do not know this key's lifetime*. Absence is load-bearing for the
|
|
10
|
+
* second — every pre-existing key has no entry, and an admin-minted key
|
|
11
|
+
* genuinely never expires, so `config/credentials.ts` rests on absence meaning
|
|
12
|
+
* "unknown lifetime, never expired". Reusing it here would overload the one
|
|
13
|
+
* invariant the expiry design is built on, to answer a question `bucket` already
|
|
14
|
+
* answers exactly.
|
|
15
|
+
*
|
|
16
|
+
* AND IT IS AN OFFER, NOT A CORRECTION. `--paste` exists for the air-gapped
|
|
17
|
+
* machine and the deliberate long-lived admin key, and those users are on
|
|
18
|
+
* `apikey` on purpose. "Your key is wrong" would be false for them; a line that
|
|
19
|
+
* says what logging in ADDS is true for everyone and safely ignorable by anyone
|
|
20
|
+
* who does not want it. It is also not a start-up nag: `cli/commands/run.ts`
|
|
21
|
+
* owns the pre-session line for the expiry warning, and a second one there would
|
|
22
|
+
* teach people to skip both.
|
|
23
|
+
*
|
|
24
|
+
* Returns `undefined` the moment the bucket is anything else — including an
|
|
25
|
+
* `apikey` whose response this build could not read, where the honest answer is
|
|
26
|
+
* to advise nothing rather than to advise from a shape we did not understand.
|
|
27
|
+
*/
|
|
28
|
+
export function poolAvailabilityNote(reading) {
|
|
29
|
+
if (reading.bucket !== "apikey")
|
|
30
|
+
return undefined;
|
|
31
|
+
if (reading.budget.kind !== "metered")
|
|
32
|
+
return undefined;
|
|
33
|
+
return "cruxy login adds a pool";
|
|
34
|
+
}
|
|
35
|
+
/** The tone a window's state maps to. `low` and `blocked` are different acts. */
|
|
36
|
+
function toneFor(state) {
|
|
37
|
+
if (state === "blocked")
|
|
38
|
+
return "danger";
|
|
39
|
+
if (state === "low")
|
|
40
|
+
return "warn";
|
|
41
|
+
return "normal";
|
|
42
|
+
}
|
|
43
|
+
/** A capped window, or nothing — never a fraction over a cap that isn't one. */
|
|
44
|
+
function toMeter(key, window, poolState) {
|
|
45
|
+
if (!window)
|
|
46
|
+
return undefined;
|
|
47
|
+
const fraction = usedFraction(window);
|
|
48
|
+
if (fraction === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
return { key, window, fraction, tone: toneFor(window.state ?? poolState) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve one reading into {@link LimitsSummary}.
|
|
54
|
+
*
|
|
55
|
+
* Pure and synchronous: every value is read off the reading it is handed. The
|
|
56
|
+
* network already happened in `limits/cache.ts`, and the tri-state that wraps
|
|
57
|
+
* this (`pending` / `error` / `ready`) is deliberately NOT reduced here — a
|
|
58
|
+
* summary is what a *reading* means, and a caller with no reading has a
|
|
59
|
+
* different thing to say, in words that differ per surface.
|
|
60
|
+
*/
|
|
61
|
+
export function buildLimitsSummary(reading, now) {
|
|
62
|
+
const budget = reading.budget;
|
|
63
|
+
const windows = [];
|
|
64
|
+
let noAllowance;
|
|
65
|
+
switch (budget.kind) {
|
|
66
|
+
case "pool": {
|
|
67
|
+
const month = toMeter("month", budget.monthly, budget.state);
|
|
68
|
+
const burst = toMeter("burst", budget.burst, budget.state);
|
|
69
|
+
if (month)
|
|
70
|
+
windows.push(month);
|
|
71
|
+
if (burst)
|
|
72
|
+
windows.push(burst);
|
|
73
|
+
// Unreachable through `reduceLimits` (an enforced pool with no readable
|
|
74
|
+
// window reduces to `unknown`), but the type permits it and inventing a
|
|
75
|
+
// fraction is the one thing no surface here may do while being defensive.
|
|
76
|
+
if (windows.length === 0)
|
|
77
|
+
noAllowance = "figures not reported";
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
// Enterprise: a POSITIVE statement that there is no ceiling. Distinct from
|
|
81
|
+
// `unknown` below, and the distinction is the one that matters most in this
|
|
82
|
+
// file — "we lost track of your ceiling" rendered as "you have none" tells
|
|
83
|
+
// someone they are safe to spend at the exact moment we cannot say.
|
|
84
|
+
case "unenforced":
|
|
85
|
+
noAllowance = "pool not enforced";
|
|
86
|
+
break;
|
|
87
|
+
case "metered":
|
|
88
|
+
noAllowance = "no pool cap";
|
|
89
|
+
break;
|
|
90
|
+
case "credits":
|
|
91
|
+
// A dollar pool, and the only allowance that is not weighted tokens. It
|
|
92
|
+
// stays off `windows` for that reason: those are token windows, and a
|
|
93
|
+
// renderer that iterated them would print dollars in the tokens' unit.
|
|
94
|
+
break;
|
|
95
|
+
case "unknown":
|
|
96
|
+
noAllowance = "budget not reported";
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const spendCaps = [];
|
|
100
|
+
if (reading.keySpendCap)
|
|
101
|
+
spendCaps.push({ label: "key", cap: reading.keySpendCap });
|
|
102
|
+
if (reading.workspaceSpendCap)
|
|
103
|
+
spendCaps.push({ label: "ws", cap: reading.workspaceSpendCap });
|
|
104
|
+
const note = poolAvailabilityNote(reading);
|
|
105
|
+
return {
|
|
106
|
+
tier: reading.tier,
|
|
107
|
+
bucket: reading.bucket,
|
|
108
|
+
budget,
|
|
109
|
+
windows,
|
|
110
|
+
...(noAllowance !== undefined ? { noAllowance } : {}),
|
|
111
|
+
notes: note ? [note] : [],
|
|
112
|
+
spendCaps,
|
|
113
|
+
// Only where it is the whole story. A rate limit beside a real allowance
|
|
114
|
+
// reads as part of it; beside a metered key's absence, it is the ceiling.
|
|
115
|
+
...(budget.kind === "metered" && reading.chat
|
|
116
|
+
? {
|
|
117
|
+
rpm: {
|
|
118
|
+
remaining: reading.chat.perKey.remaining,
|
|
119
|
+
limit: reading.chat.perKey.limit,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
: {}),
|
|
123
|
+
ageMs: now - reading.readAt,
|
|
124
|
+
};
|
|
125
|
+
}
|
package/dist/sandbox/service.js
CHANGED
|
@@ -53,6 +53,15 @@ export class SandboxService {
|
|
|
53
53
|
* (surfacing the pull via U.4), then delegates to the runtime. A container
|
|
54
54
|
* that fails to start throws a coded error; an ordinary non-zero command exit
|
|
55
55
|
* comes back as a normal {@link ExecResult} — exit code is truth.
|
|
56
|
+
*
|
|
57
|
+
* ONE POLICY PER SESSION, not per action. `SandboxRuntime.exec` takes a policy
|
|
58
|
+
* argument and this one does not: {@link create} resolved it once from config
|
|
59
|
+
* + cwd and every call here runs under that same {@link IsolationPolicy}. So a
|
|
60
|
+
* caller cannot tighten the box for a particular command — no argument does
|
|
61
|
+
* it, and reaching around to the runtime would bypass the image memoization.
|
|
62
|
+
* Per-action confinement is a new seam here, not a parameter that already
|
|
63
|
+
* exists; recorded because the shape of `SandboxRuntime.exec` suggests
|
|
64
|
+
* otherwise at a glance.
|
|
56
65
|
*/
|
|
57
66
|
async exec(command, opts) {
|
|
58
67
|
await this.ensureImage();
|
package/dist/sandbox/types.js
CHANGED
|
@@ -9,6 +9,21 @@
|
|
|
9
9
|
* sandbox contains what an approved command is able to do; it does not replace
|
|
10
10
|
* the decision to run it.
|
|
11
11
|
*
|
|
12
|
+
* ── It bounds the EXECUTION surface, and only that ──
|
|
13
|
+
* Worth saying in the words that get misread: this is not "the session runs
|
|
14
|
+
* confined." `ToolContext.sandbox` is consulted at exactly two call sites —
|
|
15
|
+
* `tools/shell/exec.ts` (`execShell`) and `testing/run-tests-tool.ts` — so
|
|
16
|
+
* `run_command` and `run_tests` are the whole of what a container ever bounds.
|
|
17
|
+
* `write_file`, `edit_file`, and `apply_patch` call the filesystem directly and
|
|
18
|
+
* run on the HOST in every mode, sandbox or no sandbox; their bound is the C.26
|
|
19
|
+
* path confinement plus the C.32 checkpoint, not this boundary.
|
|
20
|
+
*
|
|
21
|
+
* That distinction is load-bearing wherever a rule is stated in terms of
|
|
22
|
+
* confinement: a slogan like "an auto-approving mode runs confined" reads as
|
|
23
|
+
* covering file writes and does not. See the cli#194 section in
|
|
24
|
+
* `approval/classify.ts` for what a mode rule keyed on this boundary would and
|
|
25
|
+
* would not have bought.
|
|
26
|
+
*
|
|
12
27
|
* Execution is abstracted behind {@link SandboxRuntime} (Docker ships; podman /
|
|
13
28
|
* none slot in without touching call sites), and the neutral {@link ExecResult}
|
|
14
29
|
* matches what host execution conceptually returns so `run_command`/`run_tests`
|
package/dist/session/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - `log.ts` — the writer: one line per event, `0600`, non-fatal on failure;
|
|
9
9
|
* - `replay.ts` — the fold back to state, tolerant of torn/unknown lines;
|
|
10
10
|
* - `list.ts` — what the picker and the TUI sidebar both read;
|
|
11
|
+
* - `prune.ts` — retention: what the tree is allowed to keep (#257);
|
|
11
12
|
* - `resume.ts` — `--resume <id>` and the bare-`--resume` picker;
|
|
12
13
|
* - `paths.ts` — the layout, including the subtrees reserved for P3+.
|
|
13
14
|
*/
|
|
@@ -16,6 +17,7 @@ export { SessionLog } from "./log.js";
|
|
|
16
17
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
17
18
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
18
19
|
export { redactMessages } from "./redact.js";
|
|
19
|
-
export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
20
|
+
export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, sessionFilesByRecency, summarizeSession, } from "./list.js";
|
|
21
|
+
export { pruneSessions, } from "./prune.js";
|
|
20
22
|
export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
21
23
|
export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
package/dist/session/list.js
CHANGED
|
@@ -31,7 +31,7 @@ function toTitle(text) {
|
|
|
31
31
|
* picker asks for ten". It was not. {@link listSessions} summarized EVERY file
|
|
32
32
|
* and sliced afterwards, so the limit bounded the rows and not the work; a
|
|
33
33
|
* project of 200 sessions paid 200 full parses to show 10. The limit now bounds
|
|
34
|
-
* the work (see {@link
|
|
34
|
+
* the work (see {@link sessionFilesByRecency}), which is what finally makes that
|
|
35
35
|
* sentence true. A sidecar index is still the answer if per-file cost ever
|
|
36
36
|
* stops being acceptable — but the ordering fix had to come first, because an
|
|
37
37
|
* index would have made the same mistake faster.
|
|
@@ -108,8 +108,19 @@ export function summarizeSession(file) {
|
|
|
108
108
|
* Missing directory → empty list (not an error: no sessions yet is normal). A
|
|
109
109
|
* file that vanishes between the `readdir` and the `stat` is skipped rather
|
|
110
110
|
* than throwing — listing races an active session by definition.
|
|
111
|
+
*
|
|
112
|
+
* EXPORTED for `prune.ts` (#257), which needs exactly this and nothing more:
|
|
113
|
+
* mtime ordering is what both bounds consume, and a second scan in the pruner
|
|
114
|
+
* would reintroduce the duplication #255 removed. It is the ONLY directory walk
|
|
115
|
+
* over a project's sessions — keep it that way.
|
|
116
|
+
*
|
|
117
|
+
* The `.jsonl` filter and the `isFile` test are load-bearing beyond tidiness.
|
|
118
|
+
* `subagents/` lives in this same directory (reserved by `paths.ts` for #172
|
|
119
|
+
* item 1), `statSync` succeeds on a directory, and everything this returns is
|
|
120
|
+
* something prune will consider deleting. Anything added here is added to that
|
|
121
|
+
* set.
|
|
111
122
|
*/
|
|
112
|
-
function
|
|
123
|
+
export function sessionFilesByRecency(cwd) {
|
|
113
124
|
const dir = projectDir(cwd);
|
|
114
125
|
let names;
|
|
115
126
|
try {
|
|
@@ -124,7 +135,10 @@ function sessionRefsByRecency(cwd) {
|
|
|
124
135
|
continue;
|
|
125
136
|
const file = path.join(dir, name);
|
|
126
137
|
try {
|
|
127
|
-
|
|
138
|
+
const stat = statSync(file);
|
|
139
|
+
if (!stat.isFile())
|
|
140
|
+
continue;
|
|
141
|
+
files.push({ file, mtimeMs: stat.mtimeMs, size: stat.size });
|
|
128
142
|
}
|
|
129
143
|
catch {
|
|
130
144
|
continue; // deleted mid-listing
|
|
@@ -140,7 +154,7 @@ function sessionRefsByRecency(cwd) {
|
|
|
140
154
|
* capped at `limit`.
|
|
141
155
|
*
|
|
142
156
|
* `limit` bounds the WORK, not just the rows. Files are ordered by mtime first
|
|
143
|
-
* (cheap — see {@link
|
|
157
|
+
* (cheap — see {@link sessionFilesByRecency}) and summarized one at a time until
|
|
144
158
|
* `limit` valid summaries exist, so the picker asking for ten reads ten files
|
|
145
159
|
* and not two hundred.
|
|
146
160
|
*
|
|
@@ -153,7 +167,7 @@ function sessionRefsByRecency(cwd) {
|
|
|
153
167
|
*/
|
|
154
168
|
export function listSessions(cwd, limit = Infinity) {
|
|
155
169
|
const summaries = [];
|
|
156
|
-
for (const { file } of
|
|
170
|
+
for (const { file } of sessionFilesByRecency(cwd)) {
|
|
157
171
|
if (summaries.length >= limit)
|
|
158
172
|
break;
|
|
159
173
|
const summary = summarizeSession(file);
|
|
@@ -178,7 +192,7 @@ export function listSessions(cwd, limit = Infinity) {
|
|
|
178
192
|
*/
|
|
179
193
|
export function listSessionRefs(cwd) {
|
|
180
194
|
const refs = [];
|
|
181
|
-
for (const { file, mtimeMs } of
|
|
195
|
+
for (const { file, mtimeMs } of sessionFilesByRecency(cwd)) {
|
|
182
196
|
const meta = readMeta(file);
|
|
183
197
|
if (meta)
|
|
184
198
|
refs.push({ sessionId: meta.sessionId, file, mtimeMs });
|