@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
package/dist/hooks/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { isReservedSlash } from "../cli/command-catalog.js";
|
|
3
4
|
import { globalDir } from "../config/paths.js";
|
|
4
5
|
import { COMMANDS_DIR_NAME, GLOBAL_DIR_NAME, HOOKS_FILE_NAME, } from "../constants.js";
|
|
5
6
|
import { defaultBlocking, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, SlashFrontmatterSchema, } from "./types.js";
|
|
@@ -143,6 +144,23 @@ async function scanCommandsDir(source, dir, out, errors) {
|
|
|
143
144
|
});
|
|
144
145
|
continue;
|
|
145
146
|
}
|
|
147
|
+
// A reserved name is refused HERE, at load, and excluded from the catalog
|
|
148
|
+
// (P10 track 0). Both halves matter. Loading it and letting it lose the
|
|
149
|
+
// race later is what shipped before: `commands/status.md` parsed, appeared
|
|
150
|
+
// in `cruxy hooks list` and in the palette, and could not run, because the
|
|
151
|
+
// shells dispatch `/status` themselves several branches before the custom
|
|
152
|
+
// catalogue is consulted. Nothing said so at load and nothing said so at
|
|
153
|
+
// use. Keeping it in the catalog with a warning would fix half of that and
|
|
154
|
+
// leave the palette advertising a command that does nothing.
|
|
155
|
+
if (isReservedSlash(name)) {
|
|
156
|
+
errors.push({
|
|
157
|
+
source,
|
|
158
|
+
file,
|
|
159
|
+
name,
|
|
160
|
+
message: `"/${name}" is a built-in command and cannot be overridden — rename this file`,
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
146
164
|
let text;
|
|
147
165
|
try {
|
|
148
166
|
text = await fs.readFile(file, "utf8");
|
package/dist/hooks/index.js
CHANGED
|
@@ -2,6 +2,6 @@ export { HOOK_EVENTS, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, HooksFileSchema, S
|
|
|
2
2
|
export { defaultHookSources, loadHookCatalog, } from "./config.js";
|
|
3
3
|
export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPath, } from "./trust.js";
|
|
4
4
|
export { HookRunner, } from "./runner.js";
|
|
5
|
-
export {
|
|
5
|
+
export { expandTemplate, resolveSlash } from "./slash.js";
|
|
6
6
|
export { buildHooksService, } from "./service.js";
|
|
7
7
|
export { buildHooksRouter, } from "./router.js";
|
package/dist/hooks/router.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function buildHooksRouter(opts) {
|
|
|
79
79
|
for (const root of opts.workspace.roots()) {
|
|
80
80
|
const catalog = await loadCatalog(root);
|
|
81
81
|
for (const err of catalog.errors) {
|
|
82
|
-
opts.logger.warn(`ignoring
|
|
82
|
+
opts.logger.warn(`ignoring ${err.source} hook/command "${err.name}" in ${root.name}: ${err.message}`);
|
|
83
83
|
}
|
|
84
84
|
// Each root's runner owns ONLY its project hooks. User hooks are global —
|
|
85
85
|
// loaded once (from the primary, whose `sources.user` is identical for every
|
package/dist/hooks/service.js
CHANGED
|
@@ -5,15 +5,15 @@ import { defaultHookSources, loadHookCatalog, } from "./config.js";
|
|
|
5
5
|
import { HookRunner } from "./runner.js";
|
|
6
6
|
import { fileTrustStore } from "./trust.js";
|
|
7
7
|
/**
|
|
8
|
-
* Load the catalog and assemble the {@link HooksService}.
|
|
9
|
-
*
|
|
10
|
-
* valid ones proceed.
|
|
8
|
+
* Load the catalog and assemble the {@link HooksService}. Rejected definitions
|
|
9
|
+
* — malformed, or a command name the shells reserve — are surfaced (never
|
|
10
|
+
* eval'd, never silently dropped) through the logger; the valid ones proceed.
|
|
11
11
|
*/
|
|
12
12
|
export async function buildHooksService(opts) {
|
|
13
13
|
const sources = opts.sources ?? defaultHookSources(opts.cwd);
|
|
14
14
|
const catalog = await loadHookCatalog(sources);
|
|
15
15
|
for (const err of catalog.errors) {
|
|
16
|
-
opts.logger.warn(`ignoring
|
|
16
|
+
opts.logger.warn(`ignoring ${err.source} hook/command "${err.name}": ${err.message}`);
|
|
17
17
|
}
|
|
18
18
|
const trust = opts.trust ?? fileTrustStore();
|
|
19
19
|
const runner = new HookRunner({
|
package/dist/hooks/slash.js
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
|
+
import { isReservedSlash } from "../cli/command-catalog.js";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"help",
|
|
11
|
-
"clear",
|
|
12
|
-
"compact",
|
|
13
|
-
"reload",
|
|
14
|
-
"plan",
|
|
15
|
-
"exit",
|
|
16
|
-
"quit",
|
|
17
|
-
];
|
|
18
|
-
const BUILTINS = new Set(BUILTIN_SLASH_COMMANDS);
|
|
19
|
-
/** Is `name` (no leading slash) a reserved builtin? */
|
|
20
|
-
export function isBuiltinSlash(name) {
|
|
21
|
-
return BUILTINS.has(name);
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Resolve one input line. Non-slash input and unknown names → `none`. Builtins
|
|
25
|
-
* short-circuit to `builtin` BEFORE the custom catalog is consulted, so a custom
|
|
26
|
-
* command named after a builtin is inert (surfaced separately at load time).
|
|
3
|
+
* Resolve one input line. Non-slash input and unknown names → `none`. Reserved
|
|
4
|
+
* names short-circuit to `builtin` BEFORE the custom catalog is consulted.
|
|
5
|
+
*
|
|
6
|
+
* This is now belt-and-braces rather than the only guard: the loader refuses a
|
|
7
|
+
* command file whose name is reserved, so the colliding spec should never reach
|
|
8
|
+
* this catalogue at all. It stays because a caller may assemble a spec list by
|
|
9
|
+
* some other route, and the failure mode this prevents — a project-authored
|
|
10
|
+
* `shell` command answering to `/clear` — is not one to leave to a single check.
|
|
27
11
|
*/
|
|
28
12
|
export function resolveSlash(line, commands) {
|
|
29
13
|
const trimmed = line.trim();
|
|
@@ -34,7 +18,7 @@ export function resolveSlash(line, commands) {
|
|
|
34
18
|
const args = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
35
19
|
if (name === "")
|
|
36
20
|
return { kind: "none" };
|
|
37
|
-
if (
|
|
21
|
+
if (isReservedSlash(name))
|
|
38
22
|
return { kind: "builtin", name };
|
|
39
23
|
const spec = commands.find((c) => c.name === name);
|
|
40
24
|
if (!spec)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { reduceLimits } from "./reduce.js";
|
|
2
|
+
/**
|
|
3
|
+
* How long a probe's answer is treated as current. The pool moves when the user
|
|
4
|
+
* spends, and the surfaces that spend it (this CLI, web chat, desktop) do so in
|
|
5
|
+
* turns — so a per-turn refresh is the natural cadence and this floor exists
|
|
6
|
+
* only to coalesce the several triggers a single turn can produce.
|
|
7
|
+
*/
|
|
8
|
+
const MIN_INTERVAL_MS = 15_000;
|
|
9
|
+
export class LimitsCache {
|
|
10
|
+
probe;
|
|
11
|
+
now;
|
|
12
|
+
minIntervalMs;
|
|
13
|
+
state;
|
|
14
|
+
/** The refresh in flight, so concurrent triggers share one request. */
|
|
15
|
+
inFlight = null;
|
|
16
|
+
lastAttemptAt = 0;
|
|
17
|
+
constructor(
|
|
18
|
+
/**
|
|
19
|
+
* Absent when there is no credential to ask with. That is not a failure to
|
|
20
|
+
* report later — it is knowable now, costs no request to determine, and the
|
|
21
|
+
* panel's answer ("sign in") is the same either way.
|
|
22
|
+
*/
|
|
23
|
+
probe, opts = {}) {
|
|
24
|
+
this.probe = probe;
|
|
25
|
+
this.now = opts.now ?? Date.now;
|
|
26
|
+
this.minIntervalMs = opts.minIntervalMs ?? MIN_INTERVAL_MS;
|
|
27
|
+
this.state = probe
|
|
28
|
+
? { status: "pending" }
|
|
29
|
+
: { status: "error", reason: "unauthenticated" };
|
|
30
|
+
}
|
|
31
|
+
/** The last settled state — a field read, safe from the paint path. */
|
|
32
|
+
current() {
|
|
33
|
+
return this.state;
|
|
34
|
+
}
|
|
35
|
+
/** True once a reading has been obtained, whatever has happened since. */
|
|
36
|
+
hasReading() {
|
|
37
|
+
return this.state.status === "ready";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Re-read the limits, off the paint path. Coalesces concurrent callers onto
|
|
41
|
+
* one request and declines to re-probe within {@link minIntervalMs} of the
|
|
42
|
+
* last attempt.
|
|
43
|
+
*
|
|
44
|
+
* Never rejects: a refresh is a background probe for a status panel, and a
|
|
45
|
+
* caller that has to `.catch` a status update will eventually forget to.
|
|
46
|
+
*/
|
|
47
|
+
async refresh() {
|
|
48
|
+
if (!this.probe)
|
|
49
|
+
return;
|
|
50
|
+
if (this.inFlight)
|
|
51
|
+
return this.inFlight;
|
|
52
|
+
const at = this.now();
|
|
53
|
+
// The floor applies only once something is on screen. Before the first
|
|
54
|
+
// answer there is nothing to protect, and rate-limiting our way to a blank
|
|
55
|
+
// panel would be the floor working against its own purpose.
|
|
56
|
+
if (this.state.status === "ready" &&
|
|
57
|
+
at - this.lastAttemptAt < this.minIntervalMs) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.lastAttemptAt = at;
|
|
61
|
+
this.inFlight = this.run(this.probe);
|
|
62
|
+
try {
|
|
63
|
+
await this.inFlight;
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
this.inFlight = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async run(probe) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await probe();
|
|
72
|
+
this.state = { status: "ready", reading: reduceLimits(res, this.now()) };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// Keep a good reading rather than blanking a panel that was correct a
|
|
76
|
+
// moment ago; only report an error when there is nothing else to say.
|
|
77
|
+
if (this.state.status === "ready")
|
|
78
|
+
return;
|
|
79
|
+
this.state = { status: "error", reason: classify(err) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Why the probe failed, in the three distinctions a user can act on.
|
|
85
|
+
*
|
|
86
|
+
* Matched on the SDK's error class names rather than `instanceof`, so this stays
|
|
87
|
+
* a pure classification with no import of the transport into a module the TUI
|
|
88
|
+
* loads. A 404/501 is the interesting case: it means a gateway that does not
|
|
89
|
+
* serve `/limits` at all — an older deployment, or a base URL pointed somewhere
|
|
90
|
+
* else entirely — and telling that user "you are offline" would send them
|
|
91
|
+
* debugging a network that is working fine.
|
|
92
|
+
*/
|
|
93
|
+
function classify(err) {
|
|
94
|
+
const e = err;
|
|
95
|
+
if (e?.name === "AuthError")
|
|
96
|
+
return "unauthenticated";
|
|
97
|
+
if (e?.status === 404 || e?.status === 501)
|
|
98
|
+
return "unsupported";
|
|
99
|
+
return "unreachable";
|
|
100
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limits (P9): the gateway's own account of what this credential may spend, and
|
|
3
|
+
* how much of it is left.
|
|
4
|
+
*
|
|
5
|
+
* Three files, one direction: `reduce.ts` turns the wire response into the
|
|
6
|
+
* closed {@link LimitsReading} union, `cache.ts` owns the network read and the
|
|
7
|
+
* staleness rules, and `types.ts` holds the union both sides agree on. The
|
|
8
|
+
* rendering lives in `tui/limits-panel.ts` — nothing here formats anything.
|
|
9
|
+
*/
|
|
10
|
+
export { LimitsCache } from "./cache.js";
|
|
11
|
+
export { reduceLimits, bindingWindow, usedFraction } from "./reduce.js";
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire response → {@link LimitsReading}: one place where "which absence means
|
|
3
|
+
* what" is decided (P9).
|
|
4
|
+
*
|
|
5
|
+
* The reduction is driven by `bucket` and NOTHING else. Not by how the user
|
|
6
|
+
* logged in, not by whether a key looks like `cxy_live_`, not by which config
|
|
7
|
+
* fields are set — every one of those was a plausible inference and every one of
|
|
8
|
+
* them is now wrong, because the gateway changed which bucket a CLI login mints
|
|
9
|
+
* into (cruxy-ai/api#174) without changing anything a client could see locally.
|
|
10
|
+
* The endpoint is the answer to that question; asking anything else is guessing.
|
|
11
|
+
*/
|
|
12
|
+
/** A cap is only a denominator when it is a positive, finite number. */
|
|
13
|
+
function usableCap(cap) {
|
|
14
|
+
return Number.isFinite(cap) && cap > 0;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A weighted-token window, or `undefined` when it cannot serve as a fraction.
|
|
18
|
+
*
|
|
19
|
+
* A cap of 0 is dropped rather than rendered as "100% used": zero is what an
|
|
20
|
+
* unprovisioned or misconfigured plan reports, and dividing by it produces
|
|
21
|
+
* either a crash or a full red bar, neither of which is a true statement about
|
|
22
|
+
* what the user may spend.
|
|
23
|
+
*/
|
|
24
|
+
function toWindow(w) {
|
|
25
|
+
if (!w || !usableCap(w.cap))
|
|
26
|
+
return undefined;
|
|
27
|
+
return {
|
|
28
|
+
cap: w.cap,
|
|
29
|
+
used: w.used,
|
|
30
|
+
remaining: w.remaining,
|
|
31
|
+
...(w.state !== undefined ? { state: w.state } : {}),
|
|
32
|
+
...(w.resets_at !== undefined ? { resetsAt: w.resets_at } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function toSpendCap(c) {
|
|
36
|
+
if (!c || !usableCap(c.cap))
|
|
37
|
+
return undefined;
|
|
38
|
+
return {
|
|
39
|
+
cap: c.cap,
|
|
40
|
+
spent: c.spent,
|
|
41
|
+
remaining: c.remaining,
|
|
42
|
+
...(c.resets_at !== undefined ? { resetsAt: c.resets_at } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function toRateWindow(w) {
|
|
46
|
+
return {
|
|
47
|
+
limit: w.limit,
|
|
48
|
+
remaining: w.remaining,
|
|
49
|
+
windowSeconds: w.window_seconds,
|
|
50
|
+
...(w.resets_at !== undefined ? { resetsAt: w.resets_at } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The budget shape for this credential.
|
|
55
|
+
*
|
|
56
|
+
* Ordered by bucket, and each branch answers only for its own bucket — a
|
|
57
|
+
* `subscription` response that somehow also carried `credits` does not get to
|
|
58
|
+
* fall through into the headless branch. The server decides which section is
|
|
59
|
+
* authoritative by telling us the bucket; honouring that literally is what keeps
|
|
60
|
+
* this reduction and the gate in agreement.
|
|
61
|
+
*/
|
|
62
|
+
function reduceBudget(res) {
|
|
63
|
+
switch (res.bucket) {
|
|
64
|
+
case "subscription": {
|
|
65
|
+
const pool = res.token_pool;
|
|
66
|
+
// No section at all: gated by something we were not told about.
|
|
67
|
+
if (!pool)
|
|
68
|
+
return { kind: "unknown" };
|
|
69
|
+
// Enterprise. The one place "no numbers" is a complete answer.
|
|
70
|
+
if (!pool.enforced)
|
|
71
|
+
return { kind: "unenforced" };
|
|
72
|
+
const monthly = toWindow(pool.monthly);
|
|
73
|
+
const burst = toWindow(pool.burst);
|
|
74
|
+
// Enforced, but nothing readable to enforce against. NOT "unenforced":
|
|
75
|
+
// the pool exists and will stop this user, we just cannot say when.
|
|
76
|
+
if (!monthly && !burst)
|
|
77
|
+
return { kind: "unknown" };
|
|
78
|
+
return {
|
|
79
|
+
kind: "pool",
|
|
80
|
+
unit: pool.unit ?? "weighted_tokens",
|
|
81
|
+
...(pool.state !== undefined ? { state: pool.state } : {}),
|
|
82
|
+
...(monthly ? { monthly } : {}),
|
|
83
|
+
...(burst ? { burst } : {}),
|
|
84
|
+
...(pool.mira && usableCap(pool.mira.cap)
|
|
85
|
+
? {
|
|
86
|
+
mira: {
|
|
87
|
+
cap: pool.mira.cap,
|
|
88
|
+
used: pool.mira.used,
|
|
89
|
+
remaining: pool.mira.remaining,
|
|
90
|
+
state: pool.mira.state,
|
|
91
|
+
window: pool.mira.window,
|
|
92
|
+
...(pool.mira.resets_at !== undefined
|
|
93
|
+
? { resetsAt: pool.mira.resets_at }
|
|
94
|
+
: {}),
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
: {}),
|
|
98
|
+
blockedModels: pool.blocked_models ?? [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case "apikey":
|
|
102
|
+
// `metered: true` is the server's positive statement that there is no pool
|
|
103
|
+
// to report. Its absence on an apikey bucket means this build is reading a
|
|
104
|
+
// response it does not fully understand, so it says so.
|
|
105
|
+
return res.metered === true ? { kind: "metered" } : { kind: "unknown" };
|
|
106
|
+
case "headless": {
|
|
107
|
+
const c = res.credits;
|
|
108
|
+
if (!c)
|
|
109
|
+
return { kind: "unknown" };
|
|
110
|
+
return {
|
|
111
|
+
kind: "credits",
|
|
112
|
+
plan: c.plan,
|
|
113
|
+
granted: c.granted,
|
|
114
|
+
used: c.used,
|
|
115
|
+
remaining: c.remaining,
|
|
116
|
+
...(c.resets_at !== undefined ? { resetsAt: c.resets_at } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
// A bucket added after this build. The tier and the rate limits below are
|
|
121
|
+
// still real and still reported; only the budget shape is unreadable.
|
|
122
|
+
return { kind: "unknown" };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Reduce a wire response to the reading the panel consumes. */
|
|
126
|
+
export function reduceLimits(res, readAt = Date.now()) {
|
|
127
|
+
const chat = res.rate_limits?.chat;
|
|
128
|
+
const keySpendCap = toSpendCap(res.key_spend_cap);
|
|
129
|
+
const workspaceSpendCap = toSpendCap(res.workspace_spend_cap);
|
|
130
|
+
const rate = chat
|
|
131
|
+
? {
|
|
132
|
+
perKey: toRateWindow(chat.per_key),
|
|
133
|
+
perOrg: toRateWindow(chat.per_org),
|
|
134
|
+
maxConcurrentRequests: chat.max_concurrent_requests,
|
|
135
|
+
}
|
|
136
|
+
: undefined;
|
|
137
|
+
return {
|
|
138
|
+
tier: res.tier,
|
|
139
|
+
bucket: res.bucket,
|
|
140
|
+
budget: reduceBudget(res),
|
|
141
|
+
...(rate ? { chat: rate } : {}),
|
|
142
|
+
...(keySpendCap ? { keySpendCap } : {}),
|
|
143
|
+
...(workspaceSpendCap ? { workspaceSpendCap } : {}),
|
|
144
|
+
readAt,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The window that will stop this user FIRST — the one the gate itself calls
|
|
149
|
+
* binding: whichever of monthly/burst has the smaller REMAINING fraction
|
|
150
|
+
* (`internal/budget/decide.go`).
|
|
151
|
+
*
|
|
152
|
+
* This is what earns the single bar the rail has room for. Drawing the month
|
|
153
|
+
* because it is the bigger number would routinely show a comfortable 6% while
|
|
154
|
+
* the trailing-12h window — a quarter of the month's cap on every self-serve
|
|
155
|
+
* tier — is the one about to refuse the next request.
|
|
156
|
+
*/
|
|
157
|
+
export function bindingWindow(monthly, burst) {
|
|
158
|
+
if (!monthly)
|
|
159
|
+
return burst ? { window: burst, name: "burst" } : undefined;
|
|
160
|
+
if (!burst)
|
|
161
|
+
return { window: monthly, name: "month" };
|
|
162
|
+
const frac = (w) => w.remaining / w.cap;
|
|
163
|
+
return frac(burst) < frac(monthly)
|
|
164
|
+
? { window: burst, name: "burst" }
|
|
165
|
+
: { window: monthly, name: "month" };
|
|
166
|
+
}
|
|
167
|
+
/** The fraction of a cap consumed, clamped to [0,1] for display. */
|
|
168
|
+
export function usedFraction(w) {
|
|
169
|
+
if (!usableCap(w.cap))
|
|
170
|
+
return undefined;
|
|
171
|
+
return Math.min(1, Math.max(0, w.used / w.cap));
|
|
172
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's reading of `GET /api/v1/limits` (P9).
|
|
3
|
+
*
|
|
4
|
+
* THE WIRE SHAPE IS NOT THE RENDER SHAPE, and the gap between them is the whole
|
|
5
|
+
* reason this module exists. On the wire a section is present or absent and a
|
|
6
|
+
* reader must know which absence means what: no `token_pool` on an `apikey` is
|
|
7
|
+
* normal, no `monthly` inside an enforced pool is a gateway this build cannot
|
|
8
|
+
* read, and `{enforced: false}` is a definite statement that there is no cap —
|
|
9
|
+
* three absences with three different meanings, one of which must never be shown
|
|
10
|
+
* as another.
|
|
11
|
+
*
|
|
12
|
+
* So the reduction resolves them ONCE, into a closed union where each variant
|
|
13
|
+
* carries exactly the numbers that variant may legitimately state. A renderer
|
|
14
|
+
* handed a {@link Unenforced} has no cap to divide by because the type has no
|
|
15
|
+
* cap in it — the honesty is structural, not a rule someone has to remember at
|
|
16
|
+
* the call site.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THIS ADDS TO `usage/weighted.ts`, which computes the same unit locally:
|
|
19
|
+
* that module is explicit that it can state consumption and never headroom,
|
|
20
|
+
* because a cap needs the user's plan and what every OTHER surface (web chat,
|
|
21
|
+
* desktop, phone) has already spent — "neither of which is on this machine".
|
|
22
|
+
* This is the other half arriving over the wire: the denominator, already
|
|
23
|
+
* inclusive of every surface, from the meter itself.
|
|
24
|
+
*/
|
|
25
|
+
export {};
|
package/dist/memory/secrets.js
CHANGED
|
@@ -59,3 +59,46 @@ export function containsSecret(text) {
|
|
|
59
59
|
}
|
|
60
60
|
return { secret: false };
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The global twins of {@link SECRET_PATTERNS}, built once.
|
|
64
|
+
*
|
|
65
|
+
* Detection asks "is there one?" and can stop at the first hit; redaction has to
|
|
66
|
+
* replace EVERY occurrence, which needs the `g` flag — and a `g` regex carries
|
|
67
|
+
* `lastIndex` state that would make `containsSecret` return alternating answers
|
|
68
|
+
* for the same input. So the two uses get their own objects rather than sharing
|
|
69
|
+
* one and remembering to reset it.
|
|
70
|
+
*/
|
|
71
|
+
const GLOBAL_PATTERNS = SECRET_PATTERNS.map(({ kind, re }) => ({ kind, re: new RegExp(re.source, `${re.flags}g`) }));
|
|
72
|
+
/**
|
|
73
|
+
* Replace every recognised secret in `text` with an opaque marker (P10 track 5).
|
|
74
|
+
*
|
|
75
|
+
* THE SAME DENYLIST as detection, deliberately — a `/redact` that used a
|
|
76
|
+
* different pattern set from the one that refuses a memory write would give two
|
|
77
|
+
* different answers about what counts as a secret, and the weaker of the two
|
|
78
|
+
* would be the one a user found out about.
|
|
79
|
+
*
|
|
80
|
+
* The marker is fixed text with no quotes, backslashes or control characters, so
|
|
81
|
+
* a redacted string survives being embedded in JSON unchanged — which is what
|
|
82
|
+
* lets a `tool_use` input be redacted by round-tripping through its serialised
|
|
83
|
+
* form rather than by walking an arbitrary shape.
|
|
84
|
+
*
|
|
85
|
+
* Everything the pattern matched goes, including a `api_key =` prefix on the
|
|
86
|
+
* generic assignment rule. Keeping the field name would read better and would
|
|
87
|
+
* leak the shape of the thing next to a marker announcing that something was
|
|
88
|
+
* there; the marker's `kind` already says as much as is safe to say.
|
|
89
|
+
*/
|
|
90
|
+
export function redactSecrets(text) {
|
|
91
|
+
const kinds = [];
|
|
92
|
+
let count = 0;
|
|
93
|
+
let out = text;
|
|
94
|
+
for (const { kind, re } of GLOBAL_PATTERNS) {
|
|
95
|
+
re.lastIndex = 0;
|
|
96
|
+
out = out.replace(re, () => {
|
|
97
|
+
count++;
|
|
98
|
+
if (!kinds.includes(kind))
|
|
99
|
+
kinds.push(kind);
|
|
100
|
+
return `[redacted ${kind}]`;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { text: out, kinds, count };
|
|
104
|
+
}
|
|
@@ -1,36 +1,122 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { ONBOARDING_FILE_NAME } from "../constants.js";
|
|
4
4
|
import { globalDir, resolveApiKey } from "../config/index.js";
|
|
5
|
+
import { ONBOARDING_VERSION, OnboardingStateSchema, } from "./types.js";
|
|
5
6
|
/**
|
|
6
7
|
* First-run detection + onboarding-state persistence (U.6). "First run" is
|
|
7
8
|
* deliberately derived from observable facts (no key + no completion marker)
|
|
8
9
|
* rather than a flag, and is **TTY-gated** so a non-interactive run never
|
|
9
10
|
* branches into an interactive flow.
|
|
10
11
|
*/
|
|
11
|
-
const ONBOARDING_VERSION = 1;
|
|
12
12
|
/** `~/.cruxy/onboarding.json` */
|
|
13
13
|
export function onboardingStatePath() {
|
|
14
14
|
return join(globalDir(), ONBOARDING_FILE_NAME);
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Read persisted onboarding state, or `null` if absent, unreadable, or not the
|
|
18
|
+
* shape this file is supposed to hold.
|
|
19
|
+
*
|
|
20
|
+
* VALIDATED, not cast. `as OnboardingState` accepted any object at all, so a
|
|
21
|
+
* truncated write or a hand-edit could hand the flow a `completedAt` that was a
|
|
22
|
+
* number — and the completion marker is the one fact that decides whether a
|
|
23
|
+
* user is walked through setup again or silently isn't.
|
|
24
|
+
*
|
|
25
|
+
* An invalid file reads as ABSENT rather than throwing, which is the same
|
|
26
|
+
* treatment corrupt JSON already got: the worst outcome of re-onboarding is one
|
|
27
|
+
* extra prompt, and the worst outcome of a hard error is a CLI that won't start
|
|
28
|
+
* because of a file that only exists to say "you've done this before".
|
|
29
|
+
*/
|
|
17
30
|
export function readOnboardingState(file = onboardingStatePath()) {
|
|
18
31
|
if (!existsSync(file))
|
|
19
32
|
return null;
|
|
33
|
+
let parsed;
|
|
20
34
|
try {
|
|
21
|
-
|
|
22
|
-
if (parsed && typeof parsed === "object")
|
|
23
|
-
return parsed;
|
|
35
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
24
36
|
}
|
|
25
37
|
catch {
|
|
26
38
|
// A corrupt marker is treated as absent — onboarding can rewrite it.
|
|
39
|
+
return null;
|
|
27
40
|
}
|
|
28
|
-
|
|
41
|
+
const result = OnboardingStateSchema.safeParse(parsed);
|
|
42
|
+
return result.success ? migrateState(result.data) : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The version branch the file has been writing a number for since U.6 and
|
|
46
|
+
* nothing has ever read.
|
|
47
|
+
*
|
|
48
|
+
* OLDER than this build: bring it forward. There is exactly one version today,
|
|
49
|
+
* so this is a re-stamp and nothing else — the point is that the next bump has
|
|
50
|
+
* one obvious place to add its step, instead of discovering at that moment that
|
|
51
|
+
* every reader assumed v1.
|
|
52
|
+
*
|
|
53
|
+
* NEWER than this build: return it untouched and keep trusting `completedAt`.
|
|
54
|
+
* Both alternatives are worse. Discarding it re-runs onboarding for someone who
|
|
55
|
+
* has plainly finished it, every time they use the older binary — running two
|
|
56
|
+
* cruxy versions against one home directory is ordinary, and being re-prompted
|
|
57
|
+
* for a key you already saved is not a reasonable punishment for it. Rewriting
|
|
58
|
+
* it down to our shape would destroy state we don't understand. `completedAt`
|
|
59
|
+
* is the marker in every version that has one; reading it across a version we
|
|
60
|
+
* don't know is a smaller assumption than either.
|
|
61
|
+
*/
|
|
62
|
+
function migrateState(state) {
|
|
63
|
+
if (state.version >= ONBOARDING_VERSION)
|
|
64
|
+
return state;
|
|
65
|
+
return { ...state, version: ONBOARDING_VERSION };
|
|
29
66
|
}
|
|
30
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Persist onboarding state (creates `~/.cruxy` if needed), owner-only.
|
|
69
|
+
*
|
|
70
|
+
* MERGED over what is on disk, not written over it. The schema passes unknown
|
|
71
|
+
* keys through, so a field a newer cruxy wrote survives a run of an older one
|
|
72
|
+
* — tolerating it on read and then flattening it on the next write would lose
|
|
73
|
+
* the data anyway, just one write later.
|
|
74
|
+
*
|
|
75
|
+
* The version is the MAX of the two for the same reason `migrateState` doesn't
|
|
76
|
+
* discard a future file: this build preserved the newer keys, so stamping our
|
|
77
|
+
* own lower number over them would tell the newer cruxy that its migration
|
|
78
|
+
* hasn't run when its data is in fact all still there.
|
|
79
|
+
*/
|
|
31
80
|
export function writeOnboardingState(state, file = onboardingStatePath()) {
|
|
81
|
+
const existing = readOnboardingState(file);
|
|
82
|
+
const merged = {
|
|
83
|
+
...existing,
|
|
84
|
+
...state,
|
|
85
|
+
version: Math.max(existing?.version ?? 0, state.version),
|
|
86
|
+
};
|
|
32
87
|
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
-
writeFileSync(file, JSON.stringify(
|
|
88
|
+
writeFileSync(file, JSON.stringify(merged, null, 2) + "\n", {
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
mode: 0o600,
|
|
91
|
+
});
|
|
92
|
+
restrictToOwner(file);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Make the marker owner-only. It was the one store under `~/.cruxy` still at
|
|
96
|
+
* `0644` — usage, memory trust, MCP trust and hook trust are all `0600` — and
|
|
97
|
+
* while it holds no secret, it does record that this machine's user has a cruxy
|
|
98
|
+
* key configured and when they set it up. That is nobody else's business on a
|
|
99
|
+
* shared box, and "every file cruxy writes to your home directory is yours
|
|
100
|
+
* alone" is a rule worth being able to state without an exception.
|
|
101
|
+
*
|
|
102
|
+
* The `mode` on the write above only applies when the file is CREATED, so it
|
|
103
|
+
* does nothing for the `0644` files already out there; this fixes those on the
|
|
104
|
+
* next write. Best-effort and non-fatal, deliberately: unlike the credential
|
|
105
|
+
* store — which refuses to persist a secret it cannot secure — refusing to
|
|
106
|
+
* write this one would mean re-running onboarding forever on a filesystem
|
|
107
|
+
* without modes. Windows is skipped because POSIX modes are meaningless there
|
|
108
|
+
* (see `config/owner-only.ts`), and an `icacls` edit is more than a marker
|
|
109
|
+
* warrants.
|
|
110
|
+
*/
|
|
111
|
+
function restrictToOwner(file) {
|
|
112
|
+
if (process.platform === "win32")
|
|
113
|
+
return;
|
|
114
|
+
try {
|
|
115
|
+
chmodSync(file, 0o600);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// A filesystem that can't do modes still gets a working marker.
|
|
119
|
+
}
|
|
34
120
|
}
|
|
35
121
|
/** A fresh state object. */
|
|
36
122
|
export function newOnboardingState() {
|
package/dist/onboarding/types.js
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
/**
|
|
2
3
|
* Types for the first-run onboarding flow (U.6). The flow is built from injected
|
|
3
4
|
* dependencies (validation, persistence, the first-win runner) so every step is
|
|
4
5
|
* unit-testable with scripted IO and no real network or filesystem.
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Bump when the on-disk onboarding state shape changes. The number is only
|
|
9
|
+
* worth writing if something READS it — see `migrateState` in `detect.ts`,
|
|
10
|
+
* which is where the branch it enables lives.
|
|
11
|
+
*/
|
|
12
|
+
export const ONBOARDING_VERSION = 1;
|
|
13
|
+
/**
|
|
14
|
+
* The persisted state (`~/.cruxy/onboarding.json`), as a schema rather than a
|
|
15
|
+
* cast. It was read with `as OnboardingState` — a cast validates nothing, so
|
|
16
|
+
* `{"completedAt": 42}` or a JSON array parsed clean and the completion marker
|
|
17
|
+
* became whatever the file happened to contain.
|
|
18
|
+
*
|
|
19
|
+
* `.passthrough()`, for the reason `usage/types.ts` sets out at length: a read
|
|
20
|
+
* that can't validate is a read that discards, and a store hostile to its own
|
|
21
|
+
* future silently drops what a newer cruxy wrote the moment an older one runs
|
|
22
|
+
* against the same home directory. Passthrough both TOLERATES an unknown key
|
|
23
|
+
* and PRESERVES it, so `writeOnboardingState`'s merge hands it back intact.
|
|
24
|
+
*/
|
|
25
|
+
export const OnboardingStateSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
/** Shape version — see {@link ONBOARDING_VERSION}. */
|
|
28
|
+
version: z.number().int().nonnegative(),
|
|
29
|
+
/** ISO timestamp; **its presence is the completion marker**. */
|
|
30
|
+
completedAt: z.string().min(1).optional(),
|
|
31
|
+
/** Whether a key has been successfully configured at least once. */
|
|
32
|
+
keyConfigured: z.boolean().optional(),
|
|
33
|
+
})
|
|
34
|
+
.passthrough();
|
|
@@ -51,8 +51,8 @@ export function contextReportLines(report, t, width = Infinity) {
|
|
|
51
51
|
// The headline, worded exactly as the panel words it — same estimate, same
|
|
52
52
|
// caveats, so the detail view can never read as the more authoritative one.
|
|
53
53
|
lines.push(`${t.strong(`${approx(reading.used)} / ${formatTokens(reading.total)} budget`)} ` +
|
|
54
|
-
t.muted(`(estimated
|
|
55
|
-
lines.push(t.muted(`${report.messages} message${report.messages === 1 ? "" : "s"}
|
|
54
|
+
t.muted(`(estimated${t.sep}budget is a local setting, not the model's window)`));
|
|
55
|
+
lines.push(t.muted(`${report.messages} message${report.messages === 1 ? "" : "s"}${t.sep}compacts above ${approx(reading.compactAt)}`));
|
|
56
56
|
// ── where the tokens are ──────────────────────────────────────────────────
|
|
57
57
|
const historyTokens = report.parts.reduce((sum, p) => sum + p.tokens, 0);
|
|
58
58
|
lines.push("");
|