@cruxy/cli 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +6 -6
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/summary.js +106 -17
  72. package/dist/usage/types.js +5 -2
  73. package/dist/usage/weighted.js +77 -0
  74. package/dist/utils/git.js +50 -4
  75. package/package.json +1 -1
  76. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whether the environment can host the full-viewport TUI.
3
+ *
4
+ * Three requirements, and they are exactly the axes capability detection now
5
+ * resolves — no re-probing of `process.stdin` or `TERM` here:
6
+ *
7
+ * - `interactive` (stdin is a terminal AND the output takes cursor control):
8
+ * the TUI both reads keys and repaints, so it needs both halves. This is the
9
+ * flag that makes `echo hi | cruxy` fall through to the plain path.
10
+ * - not `screenReader`: a live full-screen region is precisely what
11
+ * screen-reader mode exists to avoid (U.11). Announce-friendly linear output
12
+ * wins over the shell, always.
13
+ *
14
+ * Capability only. WHETHER a given invocation wants a TUI is an entry-mode
15
+ * decision (bare `cruxy` does; `cruxy run "task"` does not) and lives with the
16
+ * caller — see `createRenderer`'s `tui` option.
17
+ */
18
+ export function supportsTui(caps) {
19
+ return caps.interactive && !caps.screenReader;
20
+ }
@@ -0,0 +1,129 @@
1
+ import { execFile } from "node:child_process";
2
+ /**
3
+ * Host tool versions for the rail (P4 track 5).
4
+ *
5
+ * THE COST IS THE DESIGN. Measured warm on a fast Mac: pnpm 584ms, docker
6
+ * 130ms, tsc 108ms, git 34ms — ~911ms serially, and still ~584ms in parallel
7
+ * because pnpm alone dominates. That is half a second of dead terminal to
8
+ * populate a decorative panel, and it is the optimistic case: Windows process
9
+ * creation is far slower, which this repo has already paid for in CI.
10
+ *
11
+ * So nothing here runs at startup, nothing is awaited, and nothing blocks a
12
+ * paint. {@link ToolVersions.start} fires the probes and returns immediately;
13
+ * every row renders "…" until its OWN probe lands, independently of the others.
14
+ * A slow pnpm cannot delay a fast git.
15
+ *
16
+ * The shape is `sandbox/detect.ts`'s, which already solved this correctly:
17
+ * memoized for the process, a hard timeout, ENOENT treated as an answer rather
18
+ * than a throw, and never rejecting. One deliberate difference — that module
19
+ * probes `docker version --format {{.Server.Version}}`, which requires a LIVE
20
+ * DAEMON and reports a stopped Docker as absent. This panel is reporting what
21
+ * is INSTALLED, so it asks the client (`docker --version`), which answers with
22
+ * the daemon down.
23
+ */
24
+ /** Hard ceiling on one probe; a hung binary must never wedge the panel. */
25
+ const PROBE_TIMEOUT_MS = 5000;
26
+ /**
27
+ * Pull a semantic version out of a tool's `--version` line.
28
+ *
29
+ * Every one of these prints something different — "git version 2.50.1 (Apple
30
+ * Git-155)", "Docker version 29.6.2, build dfc4efb", "Version 5.9.3", a bare
31
+ * "10.33.2" — so the first `x.y.z` in the output is both the simplest rule and
32
+ * the most robust one. `null` when there is no version-shaped thing at all,
33
+ * which is honest: something answered, but not with a version.
34
+ */
35
+ export function parseVersion(output) {
36
+ return /\d+\.\d+\.\d+(?:[-+][\w.]+)?/.exec(output)?.[0] ?? null;
37
+ }
38
+ /** Default probe: spawn, capture, and treat any failure as "not installed". */
39
+ const spawnProbe = (bin, args) => new Promise((resolve) => {
40
+ try {
41
+ execFile(bin, [...args], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS, windowsHide: true }, (err, stdout, stderr) => {
42
+ // Some tools print their version to stderr; a non-zero exit with a
43
+ // parseable version still tells us the tool is there.
44
+ const text = `${stdout ?? ""}${stderr ?? ""}`;
45
+ if (err && text.trim() === "")
46
+ resolve(null);
47
+ else
48
+ resolve(text);
49
+ });
50
+ }
51
+ catch {
52
+ // execFile can throw synchronously on a malformed binary path.
53
+ resolve(null);
54
+ }
55
+ });
56
+ /**
57
+ * The tools reported, in display order.
58
+ *
59
+ * Node is NOT here: `process.versions.node` is the running interpreter's own
60
+ * version, already in memory. Spawning `node --version` to learn something the
61
+ * process knows about itself would be the most obviously wasteful 55ms in the
62
+ * list — and it would answer for whichever node is on PATH, which need not be
63
+ * the one executing this code.
64
+ */
65
+ const TOOLS = [
66
+ { name: "tsc", bin: "tsc", args: ["--version"] },
67
+ { name: "pnpm", bin: "pnpm", args: ["--version"] },
68
+ { name: "git", bin: "git", args: ["--version"] },
69
+ // The CLIENT, not `docker version --format {{.Server.Version}}`: this panel
70
+ // reports what is installed, and a stopped daemon does not uninstall Docker.
71
+ { name: "docker", bin: "docker", args: ["--version"] },
72
+ ];
73
+ export class ToolVersions {
74
+ probe;
75
+ versions = new Map();
76
+ started = false;
77
+ onSettled;
78
+ constructor(probe = spawnProbe) {
79
+ this.probe = probe;
80
+ }
81
+ /**
82
+ * Every row in display order — a pure map read, safe on the paint path.
83
+ *
84
+ * Node is resolved here rather than probed: free, exact, and it means the
85
+ * panel is never completely empty even on the very first frame.
86
+ */
87
+ current() {
88
+ return [
89
+ { name: "node", version: process.versions.node },
90
+ ...TOOLS.map((t) => ({
91
+ name: t.name,
92
+ version: this.versions.get(t.name),
93
+ })),
94
+ ];
95
+ }
96
+ /**
97
+ * Begin probing. Idempotent and memoized: the first call starts every probe,
98
+ * later calls do nothing, so a panel that repaints 30 times a second cannot
99
+ * re-spawn anything.
100
+ *
101
+ * Returns immediately. Each probe settles on its own and calls `onSettled`,
102
+ * so a fast git appears while a slow pnpm is still running — the rows are
103
+ * independent, and the panel fills in rather than arriving all at once.
104
+ */
105
+ start(onSettled) {
106
+ if (onSettled)
107
+ this.onSettled = onSettled;
108
+ if (this.started)
109
+ return;
110
+ this.started = true;
111
+ for (const tool of TOOLS) {
112
+ void this.run(tool);
113
+ }
114
+ }
115
+ async run(tool) {
116
+ let version = null;
117
+ try {
118
+ const output = await this.probe(tool.bin, tool.args);
119
+ version = output === null ? null : parseVersion(output);
120
+ }
121
+ catch {
122
+ // A probe that rejects is "not installed" for display purposes; it must
123
+ // never surface as an unhandled rejection from a decorative panel.
124
+ version = null;
125
+ }
126
+ this.versions.set(tool.name, version);
127
+ this.onSettled?.();
128
+ }
129
+ }
@@ -60,12 +60,12 @@ export class UsageCollector {
60
60
  ...(u?.billable_input_tokens !== undefined
61
61
  ? { billableInputTokens: u.billable_input_tokens }
62
62
  : {}),
63
- // The server's own cost, persisted verbatim and NOT yet surfaced (the
64
- // summary's cost line still comes from the user's configured price table
65
- // reconciling the two is a deliberate follow-up, not a mechanical merge).
66
- // Amount and currency travel together: a figure without its unit is not a
67
- // cost. A recorded 0 is a real 0; see UsageEntry.costAmount for why that
68
- // must never be read as "unpriced".
63
+ // The server's own cost, persisted verbatim and now the ONLY source of
64
+ // the cost `cruxy usage` shows; the user-configured price table it used to
65
+ // compete with is retired. Amount and currency travel together: a figure
66
+ // without its unit is not a cost, and the summary refuses to add two
67
+ // currencies rather than invent a rate. A recorded 0 is a real 0; see
68
+ // UsageEntry.costAmount for why that must never be read as "unpriced".
69
69
  ...(u?.cost !== undefined
70
70
  ? { costAmount: u.cost.amount, costCurrency: u.cost.currency }
71
71
  : {}),
@@ -7,9 +7,17 @@
7
7
  * `@cruxy/sdk` import below is a TYPE-only import (`Usage`), erased at build. A
8
8
  * future opt-in remote report would be a new, clearly-named seam — this build
9
9
  * ships nothing that sends. Asserted by the runtime + static no-phone-home tests.
10
+ *
11
+ * The guarantee survived this module gaining SERVER-SOURCED figures (the
12
+ * gateway's cost, and weighted tokens in the gateway's own metering unit)
13
+ * because none of them is fetched here: cost rides in on the chat response the
14
+ * agent loop was already making, and the weighted multipliers are a compiled-in
15
+ * constant. Reading a number the server sent is not the same as asking it for
16
+ * one — the second would be the thing this promise forbids.
10
17
  */
11
18
  export * from "./types.js";
12
19
  export { UsageCollector, accumulateCacheTokens, } from "./collect.js";
13
- export { costFor, priceForTier } from "./cost.js";
20
+ export { TIER_MULTIPLIERS, multiplierForTier, weightedFor, } from "./weighted.js";
14
21
  export { loadUsage, appendRun, usageStorePath } from "./store.js";
15
- export { summarizeRuns, renderSummary, formatCost, } from "./summary.js";
22
+ export { summarizeRuns, renderSummary, formatCost } from "./summary.js";
23
+ export { runCountLabel, selectRuns, usageReport, } from "./report.js";
@@ -0,0 +1,76 @@
1
+ import { renderSummary, summarizeRuns } from "./summary.js";
2
+ /** Narrow the retained runs to `scope`, newest-last order preserved. */
3
+ export function selectRuns(runs, scope = {}) {
4
+ let selected = [...runs];
5
+ if (scope.sessionId !== undefined) {
6
+ selected = selected.filter((r) => r.sessionId === scope.sessionId);
7
+ }
8
+ if (scope.last !== undefined) {
9
+ // Guarded, not `slice(-last)` alone: `slice(-0)` is `slice(0)` and returns
10
+ // EVERYTHING, so a zero would quietly widen the scope to the whole store —
11
+ // the exact opposite of what it asks for.
12
+ selected = scope.last <= 0 ? [] : selected.slice(-scope.last);
13
+ }
14
+ return selected;
15
+ }
16
+ /**
17
+ * Say what the weighted figure IS, every time it is shown.
18
+ *
19
+ * The number is meaningless-to-dangerous without its scope: a reader who assumes
20
+ * it covers their whole account, or reads it as a remaining balance, has been
21
+ * misled by a figure that is itself correct. These lines are the only thing
22
+ * standing between the two readings, which is why they are not optional and not
23
+ * abbreviated for the in-session surface.
24
+ */
25
+ function weightedNote(summary, t) {
26
+ if (summary.totalWeightedTokens !== undefined) {
27
+ return [
28
+ t.muted("weighted = (billable input + output) × tier weight — weighted tokens used by\n" +
29
+ "this CLI, over the runs shown. Not a balance or a limit: it cannot see your\n" +
30
+ "plan, or what cruxy on the web or desktop used from the same account."),
31
+ ];
32
+ }
33
+ // Absent rather than zero, and the reasons are unguessable from a blank line
34
+ // — so name them instead of leaving the figure silently missing.
35
+ return [
36
+ t.muted([
37
+ "weighted tokens unavailable for these runs — a request can only be weighed",
38
+ "when it records a tier and the gateway's billable input count. Runs from an",
39
+ "older cruxy, or against a non-cruxy provider, carry neither.",
40
+ ].join("\n")),
41
+ ];
42
+ }
43
+ /**
44
+ * The full usage report for `runs`, as lines to print.
45
+ *
46
+ * Empty `runs` is a first-class outcome, not an early-return the caller has to
47
+ * remember: it renders the "nothing yet" answer plus, when tracking is off, the
48
+ * reason — because "you have run nothing" and "nothing is being recorded" are
49
+ * different facts with the same blank output.
50
+ */
51
+ export function usageReport(runs, t, opts) {
52
+ if (runs.length === 0) {
53
+ const lines = [t.muted("no usage recorded yet")];
54
+ if (!opts.trackingEnabled) {
55
+ lines.push(t.muted("usage tracking is off (usage.enabled = false)"));
56
+ }
57
+ return lines;
58
+ }
59
+ const summary = summarizeRuns(runs);
60
+ const lines = [
61
+ t.heading(`usage — ${opts.scopeLabel}`),
62
+ renderSummary(summary, t),
63
+ ...weightedNote(summary, t),
64
+ ];
65
+ if (opts.legacyPriceConfig) {
66
+ lines.push(t.muted([
67
+ "note: usage.prices / usage.currency are no longer used. Cost is now the",
68
+ "gateway's own figure. You can remove them from your config.",
69
+ ].join("\n")));
70
+ }
71
+ return lines;
72
+ }
73
+ /** `3 runs` / `1 run` — the countable half of a scope label. */
74
+ export function runCountLabel(n) {
75
+ return `${n} run${n === 1 ? "" : "s"}`;
76
+ }
@@ -1,14 +1,38 @@
1
1
  import { formatTokens } from "../render/state.js";
2
- import { costFor } from "./cost.js";
2
+ import { weightedFor } from "./weighted.js";
3
+ /**
4
+ * Usage aggregation + rendering (C.22). Aggregation sums only KNOWN figures and
5
+ * counts the requests that reported none separately, so a total is never
6
+ * inflated by a fabricated zero — and the renderer ALWAYS surfaces those counts,
7
+ * so a total can never be misread as complete while requests are silently
8
+ * excluded. That rule now applies on three independent axes, because a request
9
+ * can report tokens but no cost, or a cost but nothing weighable:
10
+ * `requestsWithoutUsage`, `requestsWithoutCost`, `requestsWithoutWeight`.
11
+ *
12
+ * Both derived figures come from the server: cost is the gateway's own quote,
13
+ * weighted tokens are the gateway's own metering formula. Neither is derived
14
+ * from a user-configured price — there is no longer such a thing here.
15
+ *
16
+ * Only tier names ever reach the output (U.8 gag). Pure — no I/O, no network.
17
+ */
3
18
  /** Aggregate one or more run records into a {@link UsageSummary}. */
4
- export function summarizeRuns(runs, opts) {
19
+ export function summarizeRuns(runs) {
5
20
  const byTier = new Map();
6
21
  let totalInputTokens;
7
22
  let totalOutputTokens;
8
23
  let totalCacheReadTokens;
9
24
  let totalCacheCreationTokens;
25
+ let totalBillableInputTokens;
26
+ let totalWeightedTokens;
27
+ let totalCost;
10
28
  let requests = 0;
11
29
  let requestsWithoutUsage = 0;
30
+ let requestsWithoutWeight = 0;
31
+ let requestsWithoutCost = 0;
32
+ // Every distinct currency the gateway stated. Summing across two of them would
33
+ // require an exchange rate we don't have and would never be told, so the set
34
+ // is kept and a >1 outcome withholds the total rather than guessing.
35
+ const currencies = new Set();
12
36
  const addKnown = (acc, v) => (v === undefined ? acc : (acc ?? 0) + v);
13
37
  for (const run of runs) {
14
38
  for (const e of run.entries) {
@@ -16,10 +40,24 @@ export function summarizeRuns(runs, opts) {
16
40
  const known = e.inputTokens !== undefined || e.outputTokens !== undefined;
17
41
  if (!known)
18
42
  requestsWithoutUsage++;
43
+ // Weighed per REQUEST, never from the tier's summed totals: the multiplier
44
+ // is per-tier, but a tier's bucket can mix weighable and unweighable
45
+ // requests, and multiplying a sum that includes the latter would silently
46
+ // weigh tokens that were never eligible.
47
+ const weighted = weightedFor(e.tier, e.billableInputTokens, e.outputTokens);
48
+ if (weighted === undefined)
49
+ requestsWithoutWeight++;
50
+ if (e.costAmount === undefined)
51
+ requestsWithoutCost++;
52
+ else if (e.costCurrency !== undefined)
53
+ currencies.add(e.costCurrency);
19
54
  totalInputTokens = addKnown(totalInputTokens, e.inputTokens);
20
55
  totalOutputTokens = addKnown(totalOutputTokens, e.outputTokens);
21
56
  totalCacheReadTokens = addKnown(totalCacheReadTokens, e.cacheReadTokens);
22
57
  totalCacheCreationTokens = addKnown(totalCacheCreationTokens, e.cacheCreationTokens);
58
+ totalBillableInputTokens = addKnown(totalBillableInputTokens, e.billableInputTokens);
59
+ totalWeightedTokens = addKnown(totalWeightedTokens, weighted);
60
+ totalCost = addKnown(totalCost, e.costAmount);
23
61
  // Per-tier attribution only for entries that carry a tier. Untiered
24
62
  // requests (routing inert) still count toward totals — the total stays
25
63
  // honest — but there is no tier label to bucket them under.
@@ -27,42 +65,53 @@ export function summarizeRuns(runs, opts) {
27
65
  const b = byTier.get(e.tier) ?? {
28
66
  requests: 0,
29
67
  requestsWithoutUsage: 0,
68
+ requestsWithoutWeight: 0,
30
69
  };
31
70
  b.requests++;
32
71
  if (!known)
33
72
  b.requestsWithoutUsage++;
73
+ if (weighted === undefined)
74
+ b.requestsWithoutWeight++;
34
75
  b.inputTokens = addKnown(b.inputTokens, e.inputTokens);
35
76
  b.outputTokens = addKnown(b.outputTokens, e.outputTokens);
36
77
  b.cacheReadTokens = addKnown(b.cacheReadTokens, e.cacheReadTokens);
37
78
  b.cacheCreationTokens = addKnown(b.cacheCreationTokens, e.cacheCreationTokens);
79
+ b.billableInputTokens = addKnown(b.billableInputTokens, e.billableInputTokens);
80
+ b.weightedTokens = addKnown(b.weightedTokens, weighted);
81
+ b.cost = addKnown(b.cost, e.costAmount);
38
82
  byTier.set(e.tier, b);
39
83
  }
40
84
  }
41
85
  }
86
+ const costMixedCurrency = currencies.size > 1;
42
87
  const perTier = [...byTier.entries()].map(([tier, b]) => ({
43
88
  tier,
44
89
  inputTokens: b.inputTokens,
45
90
  outputTokens: b.outputTokens,
46
91
  cacheReadTokens: b.cacheReadTokens,
47
92
  cacheCreationTokens: b.cacheCreationTokens,
93
+ billableInputTokens: b.billableInputTokens,
94
+ weightedTokens: b.weightedTokens,
48
95
  requests: b.requests,
49
96
  requestsWithoutUsage: b.requestsWithoutUsage,
50
- cost: costFor(tier, b.inputTokens, b.outputTokens, opts.prices),
97
+ requestsWithoutWeight: b.requestsWithoutWeight,
98
+ // Withheld under mixed currencies for the same reason the total is: a tier's
99
+ // requests are not guaranteed to share a currency either.
100
+ cost: costMixedCurrency ? undefined : b.cost,
51
101
  }));
52
- const costs = perTier
53
- .map((p) => p.cost)
54
- .filter((c) => c !== undefined);
55
- const priced = costs.length > 0;
56
- const totalCost = priced ? costs.reduce((a, c) => a + c, 0) : undefined;
57
102
  return {
58
103
  perTier,
59
104
  totalInputTokens,
60
105
  totalOutputTokens,
61
106
  totalCacheReadTokens,
62
107
  totalCacheCreationTokens,
63
- totalCost,
64
- priced,
65
- currency: opts.currency,
108
+ totalBillableInputTokens,
109
+ totalWeightedTokens,
110
+ requestsWithoutWeight,
111
+ totalCost: costMixedCurrency ? undefined : totalCost,
112
+ costCurrency: costMixedCurrency ? undefined : [...currencies][0],
113
+ costMixedCurrency,
114
+ requestsWithoutCost,
66
115
  requestsWithoutUsage,
67
116
  requests,
68
117
  runCount: runs.length,
@@ -70,15 +119,27 @@ export function summarizeRuns(runs, opts) {
70
119
  }
71
120
  /**
72
121
  * Format a cost figure honestly: enough precision for small per-run costs,
73
- * trailing zeros trimmed. Prefixed by the currency label only when one is set
74
- * (never an assumed symbol). Callers pass a cost only when it was actually
75
- * computed (priced + known tokens).
122
+ * trailing zeros trimmed. The currency is the gateway's own code (e.g. "usd"),
123
+ * so it is appended as a UNIT (`0.0021 USD`) rather than prefixed as a symbol
124
+ * a code reads as a suffix, and cruxy no longer has a user-configured symbol to
125
+ * prefix with. An absent currency renders the bare number rather than assuming
126
+ * one. Callers pass a cost only when the gateway actually quoted it.
76
127
  */
77
128
  export function formatCost(n, currency) {
78
129
  const abs = Math.abs(n);
79
130
  const decimals = abs >= 1 ? 2 : abs >= 0.01 ? 4 : 6;
80
131
  const trimmed = n.toFixed(decimals).replace(/\.?0+$/, "");
81
- return `${currency}${trimmed === "" || trimmed === "-0" ? "0" : trimmed}`;
132
+ const amount = trimmed === "" || trimmed === "-0" ? "0" : trimmed;
133
+ return currency ? `${amount} ${currency.toUpperCase()}` : amount;
134
+ }
135
+ /**
136
+ * Weighted tokens at status-line width. Rounded to a whole token because the
137
+ * multipliers are fractional (3.25×, 3.8×) and a `812.5` would imply the meter
138
+ * counts half-tokens; the unrounded value stays on the summary for anything that
139
+ * needs the exact arithmetic.
140
+ */
141
+ function formatWeighted(n) {
142
+ return formatTokens(Math.round(n));
82
143
  }
83
144
  /** `↑1.2k ↓340` (worded `up 1.2k down 340` under a screen reader). */
84
145
  function tokenText(input, output, t) {
@@ -98,7 +159,9 @@ export function renderSummary(summary, t) {
98
159
  for (const p of summary.perTier) {
99
160
  const known = p.inputTokens !== undefined || p.outputTokens !== undefined;
100
161
  if (known) {
101
- const cost = p.cost !== undefined ? ` ${formatCost(p.cost, summary.currency)}` : "";
162
+ const cost = p.cost !== undefined
163
+ ? ` ${formatCost(p.cost, summary.costCurrency)}`
164
+ : "";
102
165
  parts.push(`${p.tier} ${tokenText(p.inputTokens, p.outputTokens, t)}${t.muted(cost)}`);
103
166
  }
104
167
  else {
@@ -111,11 +174,19 @@ export function renderSummary(summary, t) {
111
174
  const totalKnown = summary.totalInputTokens !== undefined ||
112
175
  summary.totalOutputTokens !== undefined;
113
176
  const totalCost = summary.totalCost !== undefined
114
- ? ` ${formatCost(summary.totalCost, summary.currency)}`
177
+ ? ` ${formatCost(summary.totalCost, summary.costCurrency)}`
115
178
  : "";
116
179
  parts.push(totalKnown
117
180
  ? `${t.strong("total")} ${tokenText(summary.totalInputTokens, summary.totalOutputTokens, t)}${t.muted(totalCost)}`
118
181
  : `${t.strong("total")} —`);
182
+ // Weighted tokens — the gateway's own metering unit, computed locally from
183
+ // billable input + output. Shown only when at least one request could actually
184
+ // be weighed; the label stays "weighted", never anything implying a balance
185
+ // (`cruxy usage` carries the fuller wording). A reported 0 IS shown, same rule
186
+ // as `cached ↻0`: it is a measurement, not an absence.
187
+ if (summary.totalWeightedTokens !== undefined) {
188
+ parts.push(t.muted(`weighted ${formatWeighted(summary.totalWeightedTokens)}`));
189
+ }
119
190
  // Cache reads (phase-A caching, Anthropic dev path): shown ONLY when the
120
191
  // provider actually reported cache usage — `undefined` on the cruxy gateway
121
192
  // and every OpenAI-compat path, so normal users never see this segment. A
@@ -130,6 +201,24 @@ export function renderSummary(summary, t) {
130
201
  if (summary.requestsWithoutUsage > 0) {
131
202
  parts.push(t.warning(unreported(summary.requestsWithoutUsage)));
132
203
  }
204
+ // Coverage for the two derived figures. Each is emitted only when it says
205
+ // something the line doesn't already: `requestsWithoutUsage` above already
206
+ // explains requests that reported nothing at all, so these fire only for the
207
+ // EXTRA shortfall — requests that did report tokens but still couldn't be
208
+ // weighed (unknown tier / no billable input) or weren't quoted a cost.
209
+ const weightShortfall = summary.requestsWithoutWeight - summary.requestsWithoutUsage;
210
+ if (summary.totalWeightedTokens !== undefined && weightShortfall > 0) {
211
+ parts.push(t.muted(`${weightShortfall} not weighted`));
212
+ }
213
+ const costShortfall = summary.requestsWithoutCost - summary.requestsWithoutUsage;
214
+ if (summary.totalCost !== undefined && costShortfall > 0) {
215
+ parts.push(t.muted(`${costShortfall} without cost`));
216
+ }
217
+ // Two gateways quoting two currencies: refuse to add them, and say why rather
218
+ // than just dropping the cost silently.
219
+ if (summary.costMixedCurrency) {
220
+ parts.push(t.warning("cost omitted: mixed currencies"));
221
+ }
133
222
  return parts.join(sep);
134
223
  }
135
224
  /** `2 requests: usage not reported`. */
@@ -65,8 +65,11 @@ export const UsageEntrySchema = z
65
65
  cacheCreationTokens: z.number().int().nonnegative().optional(),
66
66
  /**
67
67
  * Tokens the gateway actually METERED as fresh input — cache-read tokens
68
- * excluded, which is what makes the caching win legible (the weighted pool
69
- * meters `(billable_input + output) × multiplier`). Reported by the cruxy v2
68
+ * excluded, which is what makes the caching win legible. This is the input
69
+ * term of the weighted-pool formula `(billable_input + output) × multiplier`
70
+ * that weighted.ts computes, and `inputTokens` is NEVER an acceptable
71
+ * stand-in for it: substituting a count that includes cache reads overstates
72
+ * precisely the requests caching made cheaper. Reported by the cruxy v2
70
73
  * wire on every request INCLUDING as a literal `0`, so `undefined` here means
71
74
  * a gateway too old to send it or a provider that never does — unknown, not
72
75
  * zero. Never zero-filled.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Weighted tokens (C.22) — the unit the cruxy gateway actually meters a
3
+ * subscriber in.
4
+ *
5
+ * A subscriber is not billed per token and not billed in dollars: the gateway
6
+ * counts `(billable_input + output) × multiplier` per request and checks that
7
+ * against two windows. This module computes THAT number, locally, from figures
8
+ * already on disk — so `cruxy usage` can state consumption in the same unit the
9
+ * meter uses instead of a currency nobody is charged in.
10
+ *
11
+ * WHAT THIS IS NOT: it is not headroom, and it must never be rendered as such.
12
+ * A cap needs the user's plan, and how much of that plan every OTHER surface
13
+ * (web chat, desktop, phone) has already spent — neither of which is on this
14
+ * machine. All this module can honestly say is "this is what the runs in this
15
+ * CLI's own store weigh". Anything phrased as remaining/left/available would be
16
+ * a number this process cannot possibly know.
17
+ *
18
+ * No network: the multipliers are a compiled-in constant, not a fetched one.
19
+ */
20
+ /**
21
+ * Weighted-token multipliers, keyed by tier — mirroring `multipliers` in the
22
+ * gateway's `internal/budget/config.go`, which is the source of truth.
23
+ *
24
+ * MIRRORED, THEREFORE DRIFTABLE. Nothing checks these against the server at
25
+ * runtime (that would be a network call this module is forbidden to make), so a
26
+ * gateway-side reweighting silently makes an old CLI's arithmetic stale. That
27
+ * is the accepted cost of computing offline, and it is why every rendering of
28
+ * this figure is framed as what THIS CLI reckons it used — never as an
29
+ * authoritative balance. If they drift, the server is right and this is wrong.
30
+ *
31
+ * Only the chat tiers are listed. The gateway also weighs its embed/guard/rerank
32
+ * models at 0.1×, but the CLI's chat path cannot produce a request on one, so
33
+ * including them would model traffic that never reaches this store.
34
+ */
35
+ export const TIER_MULTIPLIERS = {
36
+ mira: 1.0,
37
+ vaani: 3.25,
38
+ kavi: 3.8,
39
+ };
40
+ /** The multiplier for a tier, or `undefined` when the tier isn't a known one. */
41
+ export function multiplierForTier(tier) {
42
+ return TIER_MULTIPLIERS[tier];
43
+ }
44
+ /**
45
+ * Weighted tokens for one request's figures, or `undefined` when the request
46
+ * cannot be weighed honestly.
47
+ *
48
+ * BOTH terms are required, and this is the whole discipline of the function.
49
+ * The meter's formula has two addends; substituting 0 for an unknown one does
50
+ * not produce an approximate answer, it produces a confidently understated one —
51
+ * and understating consumption is the direction that flatters, so it is exactly
52
+ * the error that would go unnoticed. `costFor` could price one known side
53
+ * because the raw token counts stayed visible beside it; here the weighted
54
+ * figure IS the deliverable, so a partial one is worse than none. Callers count
55
+ * the `undefined`s and surface them instead.
56
+ *
57
+ * Three ways a request is unweighable, all of them real and all treated alike:
58
+ * - no tier, or a tier this build doesn't know (a non-cruxy provider — those
59
+ * tokens never touched the weighted pool, so weighing them would be fiction);
60
+ * - no `billable_input_tokens` (a gateway too old to send it). Note that
61
+ * `inputTokens` is NOT a fallback: it includes cache-read tokens, which the
62
+ * meter excludes, so it would overstate every cached request — the precise
63
+ * inverse of the caching win the field exists to show;
64
+ * - no output count.
65
+ *
66
+ * A reported `0` on either side is a real measurement and weighs in as 0.
67
+ */
68
+ export function weightedFor(tier, billableInputTokens, outputTokens) {
69
+ if (tier === undefined)
70
+ return undefined;
71
+ const multiplier = multiplierForTier(tier);
72
+ if (multiplier === undefined)
73
+ return undefined;
74
+ if (billableInputTokens === undefined || outputTokens === undefined)
75
+ return undefined;
76
+ return (billableInputTokens + outputTokens) * multiplier;
77
+ }
package/dist/utils/git.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { execFile, spawnSync } from "node:child_process";
2
2
  /** Hard ceiling on a git invocation; a hung git must never stall startup. */
3
3
  const GIT_TIMEOUT_MS = 5000;
4
4
  /**
@@ -17,6 +17,35 @@ function runGit(args, cwd) {
17
17
  }
18
18
  return res.stdout;
19
19
  }
20
+ /**
21
+ * {@link runGit} without blocking the event loop. Same contract in every other
22
+ * respect: `null` on any failure, never throws, same timeout.
23
+ *
24
+ * This exists because `spawnSync` is measurably expensive — around 45ms warm for
25
+ * the branch+status pair — and the TUI needs this data while a model response is
26
+ * streaming. Blocking the loop for 45ms mid-stream stutters the paint; blocking
27
+ * it from the paint path itself would do so on every frame.
28
+ */
29
+ function runGitAsync(args, cwd) {
30
+ return new Promise((resolve) => {
31
+ execFile("git", args, { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => resolve(err ? null : stdout));
32
+ });
33
+ }
34
+ /**
35
+ * Number of changed paths in `git status --porcelain` output.
36
+ *
37
+ * One line is one path, INCLUDING a rename (`R old -> new`), which is one
38
+ * change and not two. Blank lines are ignored so a trailing newline — always
39
+ * present on non-empty output — cannot inflate the count by one.
40
+ */
41
+ export function countChanges(status) {
42
+ return status.split("\n").filter((line) => line.trim() !== "").length;
43
+ }
44
+ /** Shared shaping so the sync and async paths cannot disagree. */
45
+ function toGitInfo(branch, status) {
46
+ const changed = countChanges(status);
47
+ return { branch: branch.trim(), dirty: changed > 0, changed };
48
+ }
20
49
  /**
21
50
  * Branch name plus the raw `git status --porcelain` text for `cwd`, or `null`
22
51
  * when it isn't a git repository (or git is unavailable). Backs the `git_status`
@@ -32,12 +61,29 @@ export function getGitStatus(cwd) {
32
61
  return { branch: branch.trim(), status };
33
62
  }
34
63
  /**
35
- * Compact git context for the system prompt: current branch and whether the
36
- * working tree has uncommitted changes. `null` when not a repo / git missing.
64
+ * Compact git context for the system prompt: current branch, whether the working
65
+ * tree has uncommitted changes, and how many paths changed. `null` when not a
66
+ * repo / git missing.
67
+ *
68
+ * The change count costs no extra subprocess — it is derived from the porcelain
69
+ * output {@link getGitStatus} already fetched.
37
70
  */
38
71
  export function getGitInfo(cwd) {
39
72
  const info = getGitStatus(cwd);
40
73
  if (info === null)
41
74
  return null;
42
- return { branch: info.branch, dirty: info.status.trim().length > 0 };
75
+ return toGitInfo(info.branch, info.status);
76
+ }
77
+ /**
78
+ * {@link getGitInfo} without blocking the event loop — what the TUI rail uses.
79
+ * Identical result, identical `null` semantics.
80
+ */
81
+ export async function getGitInfoAsync(cwd) {
82
+ const branch = await runGitAsync(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
83
+ if (branch === null)
84
+ return null;
85
+ const status = await runGitAsync(["status", "--porcelain"], cwd);
86
+ if (status === null)
87
+ return null;
88
+ return toGitInfo(branch, status);
43
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {