@cspeach/cli 1.1.3 → 1.1.4

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.
@@ -20,6 +20,7 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
20
20
  import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
21
21
  import { appendCostLine, buildEntry as buildCostEntry } from '../cost/cost-log.js';
22
22
  import { computeSessionCost } from '../cost/session-cost.js';
23
+ import { refreshCreditsAfterTurn, creditsForCostLog } from '../cost/credits-wire.js';
23
24
  import { globalStore } from '../ui/sap-state-store.js';
24
25
  import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
25
26
  import { enrichUserMessage } from '../router/intent-extractor.js';
@@ -823,6 +824,11 @@ export async function runTurn(params) {
823
824
  // never crash a turn. Runs for both happy and interrupted paths so
824
825
  // the user's spend is recorded even when a turn dies mid-stream.
825
826
  {
827
+ // Credits (2026-09-16): sample the customer's balance BEFORE building
828
+ // the cost line so the JSONL records what the LEDGER charged alongside
829
+ // our COGS, and so the status footer the REPL prints next reads a fresh
830
+ // figure. No-op + no request outside credits mode; capped at 2.5 s.
831
+ await refreshCreditsAfterTurn();
826
832
  const turnTokens = {
827
833
  input: (session.usage?.input_tokens ?? 0) - turnStartTokens.input,
828
834
  output: (session.usage?.output_tokens ?? 0) - turnStartTokens.output,
@@ -838,6 +844,9 @@ export async function runTurn(params) {
838
844
  model: params.modelOverride ?? session.model,
839
845
  tokens: turnTokens,
840
846
  duration_ms: turnDurationMs,
847
+ // Additive: present only in credits mode (undefined in USD mode, so
848
+ // the emitted line is byte-identical to a pre-credits one).
849
+ credits: creditsForCostLog(),
841
850
  });
842
851
  void appendCostLine(session.id, entry);
843
852
  }
@@ -15,6 +15,7 @@ const MUTABLE_KEYS = new Set([
15
15
  'classifier.safe_mode',
16
16
  'local_build',
17
17
  'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
18
+ 'show_cogs',
18
19
  'write_mode',
19
20
  'plan_mode',
20
21
  'plan_audit',
@@ -502,6 +503,20 @@ export async function runConfigSet(args) {
502
503
  console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
503
504
  break;
504
505
  }
506
+ case 'show_cogs': {
507
+ // Operator escape hatch — render OUR Anthropic COGS in USD even on a
508
+ // managed, metered account (where the customer-facing unit is credits).
509
+ // Off/absent is the product default; see cost/display-mode.ts.
510
+ const b = coerceBool(value);
511
+ if (b === null) {
512
+ err(`${key} must be on/off (also accepts true/false, 1/0).`);
513
+ }
514
+ cfg.show_cogs = b;
515
+ console.log(chalk.dim(b
516
+ ? ' → cost figures will show USD (our Anthropic cost), not credits.'
517
+ : ' → cost figures will show credits on a managed, metered account.'));
518
+ break;
519
+ }
505
520
  case 'write_mode':
506
521
  if (!isValidWriteMode(value)) {
507
522
  err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
@@ -632,6 +647,7 @@ export function validateValue(key, value) {
632
647
  return value === 'true' || value === 'false';
633
648
  case 'local_build':
634
649
  case 'local_files': // deprecated alias — same on/off coercion
650
+ case 'show_cogs':
635
651
  return coerceBool(value) !== null;
636
652
  case 'write_mode':
637
653
  return isValidWriteMode(value);
@@ -11,7 +11,24 @@
11
11
  import chalk from 'chalk';
12
12
  import { readCostLog, summarise } from '../cost/cost-log.js';
13
13
  import { formatCost } from '../cost/pricing.js';
14
- export async function runCostCommand(sessionId) {
14
+ import { creditsActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
15
+ /**
16
+ * Sum the credits the proxy charged across the logged turns. Entries written
17
+ * before credits existed (or on turns whose balance fetch failed) carry no
18
+ * number — they are skipped, not counted as zero, and the caller says so.
19
+ */
20
+ function summariseCredits(entries) {
21
+ let total = 0;
22
+ let unknown = 0;
23
+ for (const e of entries) {
24
+ if (typeof e.credits_charged === 'number')
25
+ total += e.credits_charged;
26
+ else
27
+ unknown++;
28
+ }
29
+ return { total, unknown };
30
+ }
31
+ export async function runCostCommand(sessionId, credits = globalCredits.snapshot()) {
15
32
  const entries = await readCostLog(sessionId);
16
33
  if (entries.length === 0) {
17
34
  console.log('');
@@ -20,9 +37,27 @@ export async function runCostCommand(sessionId) {
20
37
  return;
21
38
  }
22
39
  const s = summarise(entries);
40
+ // Credits mode: the customer is billed in credits at a multiplier we do not
41
+ // disclose, so NOTHING derived from cost/pricing.ts may be printed here —
42
+ // not the total, not the per-model split, not the per-turn figures. Tokens
43
+ // stay: they are a usage fact, not a COGS leak.
44
+ const inCredits = creditsActive();
45
+ const perTurn = summariseCredits(entries);
23
46
  // Header
24
47
  console.log('');
25
- console.log(chalk.bold(`Session cost so far: ${formatCost(s.totalCost)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
48
+ if (inCredits) {
49
+ const unit = credits?.unitLabel ?? 'credits';
50
+ // Prefer the live session tracker (balance deltas observed this run);
51
+ // fall back to the logged per-turn charges when it has nothing yet.
52
+ const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : perTurn.total;
53
+ console.log(chalk.bold(`Session usage so far: ${formatCredits(total, unit)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
54
+ if (credits?.balance != null) {
55
+ console.log(chalk.dim(`Remaining balance: ${formatCredits(credits.balance, unit)}`));
56
+ }
57
+ }
58
+ else {
59
+ console.log(chalk.bold(`Session cost so far: ${formatCost(s.totalCost)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
60
+ }
26
61
  // Token breakdown
27
62
  console.log('');
28
63
  console.log(chalk.dim('Tokens:'));
@@ -35,7 +70,10 @@ export async function runCostCommand(sessionId) {
35
70
  console.log('');
36
71
  console.log(chalk.dim('By model:'));
37
72
  for (const m of s.byModel) {
38
- console.log(` ${m.model.padEnd(24)} ${m.turns} turn${m.turns === 1 ? '' : 's'} ${formatCost(m.cost)}`);
73
+ // Credits mode: turn counts only. A per-model dollar split is a direct
74
+ // read on our per-model COGS.
75
+ const tail = inCredits ? '' : ` ${formatCost(m.cost)}`;
76
+ console.log(` ${m.model.padEnd(24)} ${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`);
39
77
  }
40
78
  }
41
79
  else if (s.byModel[0]) {
@@ -48,7 +86,21 @@ export async function runCostCommand(sessionId) {
48
86
  console.log(chalk.dim(`Recent turns (last ${recent.length}):`));
49
87
  for (const e of recent) {
50
88
  const tok = (e.tokens.input + e.tokens.output).toLocaleString();
51
- console.log(` turn ${String(e.turn).padStart(3)} ${tok.padStart(10)} tok ${formatCost(e.cost)}`);
89
+ // Credits mode: the per-turn figure is what the LEDGER charged (a balance
90
+ // delta recorded at turn end), never our computed USD. A turn whose
91
+ // balance sample failed shows the pending marker rather than a number we
92
+ // would have to invent.
93
+ const tail = inCredits
94
+ ? (typeof e.credits_charged === 'number'
95
+ ? formatCredits(e.credits_charged, credits?.unitLabel ?? 'credits')
96
+ : CREDITS_PENDING)
97
+ : formatCost(e.cost);
98
+ console.log(` turn ${String(e.turn).padStart(3)} ${tok.padStart(10)} tok ${tail}`);
99
+ }
100
+ if (inCredits && perTurn.unknown > 0) {
101
+ console.log('');
102
+ console.log(chalk.dim(`${perTurn.unknown} turn${perTurn.unknown === 1 ? '' : 's'} had no balance reading — `
103
+ + `their usage is included in a later turn's figure.`));
52
104
  }
53
105
  console.log('');
54
106
  console.log(chalk.dim(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`));
@@ -15,6 +15,7 @@
15
15
  import fs from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { buildAuditReport } from '../session/audit-export.js';
18
+ import { globalCredits } from '../cost/credits.js';
18
19
  import { readCostLog } from '../cost/cost-log.js';
19
20
  /** Parse the text after `/export` into a subcommand + optional filename. */
20
21
  export function parseExportArgs(rest) {
@@ -30,7 +31,9 @@ export async function runExportAuditCommand(opts) {
30
31
  const { session, transport, emit } = opts;
31
32
  const cwd = opts.cwd ?? process.cwd();
32
33
  const costEntries = opts.costEntries ?? (await readCostLog(session.id));
33
- const report = buildAuditReport({ session, costEntries, transport });
34
+ // The exported .md is a file the customer opens and forwards, so it carries
35
+ // their credits (not our COGS) whenever credits mode is active.
36
+ const report = buildAuditReport({ session, costEntries, transport, credits: globalCredits.snapshot() });
34
37
  const name = opts.filename && opts.filename.trim().length > 0
35
38
  ? opts.filename.trim()
36
39
  : defaultAuditFilename(session);
@@ -30,6 +30,7 @@ import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPh
30
30
  import { appendCostLine, buildEntry } from '../cost/cost-log.js';
31
31
  import { startThinkingHeartbeat, AUDIT_HEARTBEAT_THRESHOLDS } from '../renderer/thinking-heartbeat.js';
32
32
  import { computeCost, formatCost } from '../cost/pricing.js';
33
+ import { creditsActive } from '../cost/credits.js';
33
34
  /** Two consecutive failed verdicts on the same phase force status 'blocked'. */
34
35
  export const MAX_CONSECUTIVE_AUDIT_FAILURES = 2;
35
36
  /** Infra error strings are model/HTTP noise — clamp when rendering (Task 5 review). */
@@ -659,7 +660,13 @@ function renderAuditCost(args, cost) {
659
660
  try {
660
661
  const tok = cost.tokens.input + cost.tokens.output;
661
662
  const usd = computeCost(cost.model, cost.tokens);
662
- args.log(chalk.dim(`[audit] ${tok.toLocaleString()} tok · ${formatCost(usd)} (${cost.model})`));
663
+ // Credits mode: the audit turn's USD is our COGS. There is no per-audit
664
+ // credit figure to substitute (the ledger charges the turn, not this
665
+ // sub-step), so the dim line drops the cost fragment and keeps tokens —
666
+ // never a dollar on a managed customer's screen.
667
+ args.log(chalk.dim(creditsActive()
668
+ ? `[audit] ${tok.toLocaleString()} tok (${cost.model})`
669
+ : `[audit] ${tok.toLocaleString()} tok · ${formatCost(usd)} (${cost.model})`));
663
670
  void appendCostLine(args.sessionId, {
664
671
  ...buildEntry({ turn: 0, model: cost.model, tokens: cost.tokens, duration_ms: cost.durationMs }),
665
672
  label: 'audit',
@@ -155,6 +155,11 @@ export async function loadConfig() {
155
155
  // standalone: whitelist-coerced profile table. An unrecognised platform
156
156
  // drops the whole table (undefined → key deleted below).
157
157
  standalone: sanitiseStandaloneProfile(parsed.standalone),
158
+ // show_cogs: plain boolean, DEFAULT off (absent). Preserve only a genuine
159
+ // boolean; a typo/wrong-type collapses to undefined, which reads as OFF —
160
+ // fail-safe in the customer's favour: a config typo can never leak our
161
+ // USD COGS onto a managed customer's screen.
162
+ show_cogs: typeof parsed.show_cogs === 'boolean' ? parsed.show_cogs : undefined,
158
163
  write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
159
164
  // plan_mode: whitelist coercion — anything but the two valid literals
160
165
  // (typos, wrong types) collapses to undefined, which callers treat as
@@ -232,6 +237,11 @@ export async function loadConfig() {
232
237
  delete merged.no_sap;
233
238
  if (merged.standalone === undefined)
234
239
  delete merged.standalone;
240
+ // Same for `show_cogs`: absent-or-invalid must not serialise as
241
+ // `show_cogs = undefined` — drop the key so `config show` stays clean and
242
+ // callers see a genuine "absent" shape (treated as OFF → credits).
243
+ if (merged.show_cogs === undefined)
244
+ delete merged.show_cogs;
235
245
  // Same for `plan_mode`: absent-or-invalid must not serialise as
236
246
  // `plan_mode = undefined` — drop the key so `config show` stays clean and
237
247
  // callers see a genuine "absent" shape (treated as 'step').
@@ -27,7 +27,7 @@ export function costLogPath(sessionId) {
27
27
  * `appendCostLine` or accumulate in memory for the `/cost` command.
28
28
  */
29
29
  export function buildEntry(opts) {
30
- return {
30
+ const entry = {
31
31
  ts: new Date().toISOString(),
32
32
  turn: opts.turn,
33
33
  model: opts.model,
@@ -35,6 +35,11 @@ export function buildEntry(opts) {
35
35
  cost: computeCost(opts.model, opts.tokens),
36
36
  duration_ms: opts.duration_ms,
37
37
  };
38
+ if (opts.credits) {
39
+ entry.credits_charged = opts.credits.charged;
40
+ entry.balance_credits = opts.credits.balance;
41
+ }
42
+ return entry;
38
43
  }
39
44
  /**
40
45
  * Append a single line to the session's cost log. Best-effort:
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The one place the credits meter is driven (2026-09-16).
3
+ *
4
+ * Two touch points, both best-effort and both no-ops outside credits mode:
5
+ *
6
+ * initCredits() — REPL / one-shot startup. ONE balance call,
7
+ * capped at BALANCE_TIMEOUT_MS. Resolves the
8
+ * display mode and seeds the tracker + store.
9
+ * refreshCreditsAfterTurn() — post-turn. One more capped call; the proxy
10
+ * commits a turn's charge BEFORE the SSE stream
11
+ * closes, so this sample already includes the
12
+ * turn that just ended.
13
+ *
14
+ * The post-turn refresh is awaited inside the agent loop's post-turn block
15
+ * rather than fired and forgotten, because two consumers need the fresh
16
+ * number synchronously: the JSONL cost line written moments later, and the
17
+ * post-turn status footer the REPL prints once runTurn returns. Awaiting a
18
+ * 2.5 s-capped call at the end of a multi-second turn is cheap; showing the
19
+ * previous turn's figure would be wrong.
20
+ *
21
+ * The cfg + bearer getter are captured at init so every later call site stays
22
+ * a one-liner and no module has to re-derive the display mode for itself.
23
+ */
24
+ import { fetchBalance, globalCredits, setCostDisplayMode, creditsActive, } from './credits.js';
25
+ import { costDisplayMode } from './display-mode.js';
26
+ import { globalStore } from '../ui/sap-state-store.js';
27
+ let wiredCfg = null;
28
+ let wiredBearer = null;
29
+ /** Mirror the tracker's state onto the Ink store so the chrome re-renders. */
30
+ function publish() {
31
+ const s = globalCredits.snapshot();
32
+ globalStore.update({
33
+ creditsMode: creditsActive(),
34
+ creditsSessionTotal: s.sessionTotal,
35
+ creditsBalance: s.balance,
36
+ creditsUnitLabel: s.unitLabel,
37
+ creditsPending: s.pending,
38
+ });
39
+ }
40
+ /**
41
+ * Startup: resolve the display mode and seed the opening balance.
42
+ * Never throws; on any failure the process stays in USD mode, which is the
43
+ * pre-credits behaviour.
44
+ */
45
+ export async function initCredits(cfg, getBearer) {
46
+ try {
47
+ // Non-managed modes can't be metered by us — skip the round-trip entirely.
48
+ if (cfg.llm?.mode !== 'managed') {
49
+ setCostDisplayMode('usd');
50
+ return;
51
+ }
52
+ const info = await fetchBalance(cfg, getBearer);
53
+ const mode = costDisplayMode(cfg, info);
54
+ setCostDisplayMode(mode);
55
+ if (mode !== 'credits')
56
+ return;
57
+ wiredCfg = cfg;
58
+ wiredBearer = getBearer;
59
+ globalCredits.start(info);
60
+ publish();
61
+ }
62
+ catch {
63
+ // Startup must never be blocked by the meter.
64
+ setCostDisplayMode('usd');
65
+ }
66
+ }
67
+ /**
68
+ * Post-turn: sample the balance again and fold the delta into the session
69
+ * total. Returns null (and makes no request) outside credits mode.
70
+ */
71
+ export async function refreshCreditsAfterTurn() {
72
+ if (!creditsActive() || !wiredCfg || !wiredBearer)
73
+ return null;
74
+ try {
75
+ const info = await fetchBalance(wiredCfg, wiredBearer);
76
+ const r = globalCredits.afterTurn(info);
77
+ publish();
78
+ return r;
79
+ }
80
+ catch {
81
+ // Treat an unexpected throw exactly like a failed fetch: unknown, not zero.
82
+ const r = globalCredits.afterTurn(null);
83
+ publish();
84
+ return r;
85
+ }
86
+ }
87
+ /**
88
+ * The credits payload for a cost-log line, or undefined in USD mode so the
89
+ * emitted JSONL keeps its pre-credits shape byte for byte.
90
+ */
91
+ export function creditsForCostLog() {
92
+ if (!creditsActive())
93
+ return undefined;
94
+ const s = globalCredits.snapshot();
95
+ return { charged: s.chargedThisTurn, balance: s.balance };
96
+ }
97
+ /** Test seam — drop the captured cfg/bearer between cases. */
98
+ export function resetCreditsWire() {
99
+ wiredCfg = null;
100
+ wiredBearer = null;
101
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Customer-facing spend — credits, not dollars (2026-09-16).
3
+ *
4
+ * Every figure in `cost/pricing.ts` is OUR Anthropic COGS. A managed
5
+ * customer is billed in CREDITS at a multiplier we do not disclose, so
6
+ * showing them a `$` number is both wrong (it is not what they pay) and a
7
+ * business leak (it reveals our margin). This module is the customer-facing
8
+ * half of the split:
9
+ *
10
+ * cost/pricing.ts → USD, our cost, telemetry + BYOK/local/ai-hub display
11
+ * cost/credits.ts → credits, what the customer is actually charged
12
+ *
13
+ * The proxy is the only source of truth for credits. It commits a turn's
14
+ * charge BEFORE the SSE stream closes, so a balance fetched after the turn
15
+ * ends already reflects that turn — which is why the per-turn charge here is
16
+ * a BALANCE DELTA, not a locally computed number. We never price a turn in
17
+ * credits ourselves; we only observe what the ledger did.
18
+ *
19
+ * Everything in here is best-effort. A balance fetch that fails, times out,
20
+ * or returns garbage must never crash or block a turn — it degrades to
21
+ * "unknown", which the UI renders as `credits: updating…` (never a dollar
22
+ * fallback; see display-mode.ts).
23
+ */
24
+ /** Hard cap on the balance round-trip — never block a turn on it. */
25
+ export const BALANCE_TIMEOUT_MS = 2_500;
26
+ const DEFAULT_UNIT_LABEL = 'credits';
27
+ function num(v) {
28
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
29
+ }
30
+ /**
31
+ * Fetch the customer's credit balance from the proxy.
32
+ *
33
+ * Uses the same bearer key the CLI already sends on every other proxy call.
34
+ * NEVER throws and NEVER hangs past BALANCE_TIMEOUT_MS — returns null on any
35
+ * failure (transport error, non-2xx, timeout, unparseable body, no key).
36
+ */
37
+ export async function fetchBalance(cfg, getBearer) {
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), BALANCE_TIMEOUT_MS);
40
+ try {
41
+ const key = await getBearer();
42
+ if (!key)
43
+ return null;
44
+ const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/me/balance`;
45
+ const r = await fetch(url, {
46
+ headers: { Authorization: `Bearer ${key}` },
47
+ signal: controller.signal,
48
+ });
49
+ if (!r.ok)
50
+ return null;
51
+ const body = await r.json();
52
+ if (typeof body !== 'object' || body === null)
53
+ return null;
54
+ const b = body;
55
+ const info = {
56
+ metered: b.metered === true,
57
+ unit_label: typeof b.unit_label === 'string' && b.unit_label ? b.unit_label : DEFAULT_UNIT_LABEL,
58
+ balance_credits: num(b.balance_credits),
59
+ subscription_credits: num(b.subscription_credits),
60
+ purchased_credits: num(b.purchased_credits),
61
+ };
62
+ if (b.unprovisioned === true)
63
+ info.unprovisioned = true;
64
+ return info;
65
+ }
66
+ catch {
67
+ // Timeout, offline, DNS, malformed JSON — all the same to the caller.
68
+ return null;
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+ /**
75
+ * In-memory, session-scoped credit meter.
76
+ *
77
+ * Honest-arithmetic contract:
78
+ * - A turn's charge is `previousBalance − currentBalance`, clamped at ≥ 0
79
+ * (a mid-session top-up must never subtract from the session total).
80
+ * - When a fetch fails we do NOT invent a charge and we do NOT move the
81
+ * baseline. The turn is reported as `null` (unknown) and the spend it
82
+ * incurred is folded into the next SUCCESSFUL fetch's delta — so the
83
+ * session total stays correct even across missed samples.
84
+ * - With no baseline at all (session opened offline) the first successful
85
+ * fetch only establishes the baseline; it cannot be a delta.
86
+ */
87
+ export class CreditsTracker {
88
+ baseline = null;
89
+ state = {
90
+ chargedThisTurn: null,
91
+ sessionTotal: 0,
92
+ balance: null,
93
+ unitLabel: DEFAULT_UNIT_LABEL,
94
+ pending: false,
95
+ };
96
+ /** Seed the session's opening balance. */
97
+ start(balance) {
98
+ if (balance)
99
+ this.state.unitLabel = balance.unit_label;
100
+ const opening = balance ? balance.balance_credits : null;
101
+ this.baseline = opening;
102
+ this.state = {
103
+ ...this.state,
104
+ chargedThisTurn: null,
105
+ sessionTotal: 0,
106
+ balance: opening,
107
+ pending: false,
108
+ };
109
+ }
110
+ /** Record a post-turn balance sample and return this turn's charge. */
111
+ afterTurn(balance) {
112
+ if (balance)
113
+ this.state.unitLabel = balance.unit_label;
114
+ const current = balance ? balance.balance_credits : null;
115
+ if (current === null) {
116
+ // Unknown — keep the baseline so the next good sample covers both turns.
117
+ this.state = { ...this.state, chargedThisTurn: null, pending: true };
118
+ return { chargedThisTurn: null, sessionTotal: this.state.sessionTotal, balance: this.state.balance };
119
+ }
120
+ if (this.baseline === null) {
121
+ // First balance we have ever seen — establish the baseline only.
122
+ this.baseline = current;
123
+ this.state = { ...this.state, chargedThisTurn: null, balance: current, pending: false };
124
+ return { chargedThisTurn: null, sessionTotal: this.state.sessionTotal, balance: current };
125
+ }
126
+ const charged = Math.max(0, this.baseline - current);
127
+ this.baseline = current;
128
+ const sessionTotal = this.state.sessionTotal + charged;
129
+ this.state = { ...this.state, chargedThisTurn: charged, sessionTotal, balance: current, pending: false };
130
+ return { chargedThisTurn: charged, sessionTotal, balance: current };
131
+ }
132
+ snapshot() {
133
+ return { ...this.state };
134
+ }
135
+ }
136
+ /**
137
+ * Format a credit count for display: thousands-separated, unit-labelled.
138
+ * Credits are whole units to the customer, so fractions round.
139
+ */
140
+ export function formatCredits(n, unitLabel = DEFAULT_UNIT_LABEL) {
141
+ return `${Math.round(n).toLocaleString('en-US')} ${unitLabel}`;
142
+ }
143
+ /**
144
+ * Shown instead of a number when the post-turn balance fetch failed.
145
+ * Deliberately NOT a dollar fallback: a managed customer must never see our
146
+ * COGS, and "updating" is the truth — the charge is committed server-side and
147
+ * will surface on the next successful sample.
148
+ */
149
+ export const CREDITS_PENDING = 'credits: updating…';
150
+ /**
151
+ * The customer-facing spend line, e.g.
152
+ * `▲ 52 credits this turn · 519 this session · balance 9,481`
153
+ * Degrades gracefully: an unknown turn charge renders CREDITS_PENDING, and a
154
+ * missing balance simply drops that segment.
155
+ */
156
+ export function formatCreditsLine(s) {
157
+ if (s.chargedThisTurn === null)
158
+ return CREDITS_PENDING;
159
+ const parts = [
160
+ `▲ ${formatCredits(s.chargedThisTurn, s.unitLabel)} this turn`,
161
+ `${Math.round(s.sessionTotal).toLocaleString('en-US')} this session`,
162
+ ];
163
+ if (s.balance !== null) {
164
+ parts.push(`balance ${Math.round(s.balance).toLocaleString('en-US')}`);
165
+ }
166
+ return parts.join(' · ');
167
+ }
168
+ /**
169
+ * Session-scoped singletons.
170
+ *
171
+ * The cost-render sites are scattered across chalk printers, Ink components,
172
+ * slash commands and the markdown audit exporter — threading config + balance
173
+ * through every one of them would be a far larger diff than the behaviour
174
+ * warrants, and would leave each site free to re-derive the mode differently
175
+ * (exactly how a leak happens). Instead the mode is decided ONCE at startup
176
+ * (see costDisplayMode) and published here; every render site asks.
177
+ *
178
+ * Tests inject explicitly via each site's optional parameter, so the singleton
179
+ * is a default, never a hard dependency.
180
+ */
181
+ export const globalCredits = new CreditsTracker();
182
+ let activeMode = 'usd';
183
+ /** Publish the resolved display mode for the whole process. */
184
+ export function setCostDisplayMode(mode) {
185
+ activeMode = mode;
186
+ }
187
+ /** The resolved display mode. Defaults to 'usd' until startup resolves it —
188
+ * fail-safe for BYOK/local/one-shot paths that never call the setter. */
189
+ export function activeCostDisplayMode() {
190
+ return activeMode;
191
+ }
192
+ /** Convenience predicate for render sites. */
193
+ export function creditsActive() {
194
+ return activeMode === 'credits';
195
+ }
196
+ /** Test seam — reset the process-wide mode between cases. */
197
+ export function resetCostDisplayMode() {
198
+ activeMode = 'usd';
199
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The one rule that decides whether a cost figure renders as credits or as
3
+ * dollars (2026-09-16).
4
+ *
5
+ * `'credits'` — a MANAGED customer on a METERED account. They pay us in
6
+ * credits; the USD number we compute locally is our Anthropic COGS and
7
+ * must never reach their screen.
8
+ *
9
+ * `'usd'` — everything else:
10
+ * - byok / ai-hub / local: the customer holds the provider relationship,
11
+ * so the dollar figure IS their bill. Showing it is the whole point.
12
+ * - managed but unmetered (or the balance fetch failed): we have no
13
+ * credit truth to show. Fall back to the existing dollar rendering so
14
+ * the meter is never blank.
15
+ * - `show_cogs = on`: our own operator escape hatch for reading real COGS
16
+ * on a managed account (margin analysis, pricing work). Never a default.
17
+ *
18
+ * Callers must treat this as the single source of truth — no site may
19
+ * re-derive the mode from `cfg.llm.mode` alone.
20
+ */
21
+ export function costDisplayMode(cfg, balanceInfo) {
22
+ if (cfg.llm?.mode !== 'managed')
23
+ return 'usd';
24
+ if (balanceInfo?.metered !== true)
25
+ return 'usd';
26
+ if (cfg.show_cogs === true)
27
+ return 'usd';
28
+ return 'credits';
29
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `cspeach doctor` — credits line (2026-09-16).
3
+ *
4
+ * Purely informational: whether this account is billed in credits and, if so,
5
+ * what is left. It NEVER fails the run — a customer with no balance reading
6
+ * still has a perfectly working CLI, and an unreachable balance endpoint is a
7
+ * proxy problem the proxy check already reports.
8
+ *
9
+ * Deliberately dollar-free: doctor output is the first thing a customer pastes
10
+ * into a support thread.
11
+ */
12
+ import { fetchBalance, formatCredits } from '../../cost/credits.js';
13
+ import { getStore } from '../../auth/api-key.js';
14
+ function defaultProbe(cfg) {
15
+ return () => fetchBalance(cfg, async () => {
16
+ const store = await getStore();
17
+ return (await store.get()) ?? '';
18
+ });
19
+ }
20
+ export async function checkCredits(cfg, probe = defaultProbe(cfg)) {
21
+ // BYOK / ai-hub / local never touch our ledger — no reason to call the proxy.
22
+ if (cfg.llm?.mode !== 'managed') {
23
+ return { name: 'credits', ok: true, message: 'not metered (BYOK)' };
24
+ }
25
+ const info = await probe().catch(() => null);
26
+ if (!info) {
27
+ return {
28
+ name: 'credits', ok: true, skipped: true, message: 'unavailable',
29
+ hint: 'Could not read your balance — check the proxy check above, then retry.',
30
+ };
31
+ }
32
+ if (!info.metered) {
33
+ return { name: 'credits', ok: true, message: 'not metered (BYOK)' };
34
+ }
35
+ if (info.balance_credits === null) {
36
+ return {
37
+ name: 'credits', ok: true, skipped: true, message: 'unavailable',
38
+ hint: info.unprovisioned
39
+ ? 'Your account is metered but has no credit ledger yet — visit cspeach.dev/portal.'
40
+ : 'The server reported no balance figure.',
41
+ };
42
+ }
43
+ return {
44
+ name: 'credits',
45
+ ok: true,
46
+ message: `balance ${formatCredits(info.balance_credits, info.unit_label)}`,
47
+ };
48
+ }
@@ -10,6 +10,7 @@ import { checkSkill } from './checks/skill.js';
10
10
  import { checkKeychainFallback } from './checks/keychain-fallback.js';
11
11
  import { checkWriteMode } from './checks/write-mode.js';
12
12
  import { checkLlmMode } from './checks/llm-mode.js';
13
+ import { checkCredits } from './checks/credits.js';
13
14
  import { checkForgeRules } from './checks/forge-rules.js';
14
15
  import { checkSystemRoles } from './checks/system-roles.js';
15
16
  import { checkStandards } from './checks/standards.js';
@@ -20,6 +21,7 @@ export async function runDoctor() {
20
21
  checks.push(await checkProxy());
21
22
  checks.push(await checkAuth());
22
23
  checks.push(await checkLlmMode(cfg));
24
+ checks.push(await checkCredits(cfg));
23
25
  checks.push(await checkWriteMode(cfg));
24
26
  checks.push(await checkSystemRoles(cfg));
25
27
  checks.push(await checkKeychain());
package/dist/one-shot.js CHANGED
@@ -19,6 +19,7 @@ import { createClassicOutputEmitter } from './renderer/notices.js';
19
19
  import { todoEmitter } from './ui/todo-emitter.js';
20
20
  import { applyLocalBuildConfig } from './tools/local-build.js';
21
21
  import { getEffectiveWriteMode } from './repl/mode-cycle.js';
22
+ import { initCredits } from './cost/credits-wire.js';
22
23
  // Side-effect imports for tool registration.
23
24
  import './tools/sap-read.js';
24
25
  import './tools/sap-write.js';
@@ -97,6 +98,12 @@ export async function runOneShot(prompt, opts) {
97
98
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
98
99
  return 1;
99
100
  }
101
+ // Credits (2026-09-16) — resolve the display mode before the turn runs, so
102
+ // the per-turn cost JSONL records the ledger charge and nothing downstream
103
+ // can print a dollar to a managed customer. One-shot prints no cost figure
104
+ // of its own today, but the agent loop's cost-log path is shared with the
105
+ // REPL and must not diverge. Capped + fail-safe; BYOK/local skip the call.
106
+ await initCredits(cfg, async () => (await store.get()) ?? '');
100
107
  const aliases = Object.keys(cfg.sap);
101
108
  let alias;
102
109
  let adt;
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { formatCost } from '../cost/pricing.js';
3
+ import { creditsActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
3
4
  const RULE_WIDTH = 72;
4
5
  const RULE = chalk.dim('─'.repeat(RULE_WIDTH));
5
6
  /**
@@ -40,15 +41,24 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
40
41
  * direct stdout (which would corrupt the live Ink frame).
41
42
  */
42
43
  export function formatStatusFooter(params) {
43
- const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant } = params;
44
+ const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant, credits } = params;
44
45
  const shortSession = sessionId.slice(0, 8);
45
46
  const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
46
47
  const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
47
48
  const tokStr = tokens.toLocaleString() + ' tok this turn';
48
49
  const cachedStr = cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
49
50
  const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
50
- const costTurnStr = costThisTurn != null ? formatCost(costThisTurn) : '';
51
- const costSessStr = costSession != null ? `session: ${formatCost(costSession)}` : '';
51
+ // Credits mode: the whole spend story collapses into ONE customer-facing
52
+ // segment (turn · session · balance) in place of the two dollar segments.
53
+ // Both variants read `costTurnStr`, so putting the combined line there
54
+ // covers the slim Ink receipt and the full classic receipt alike.
55
+ const inCredits = creditsActive();
56
+ const costTurnStr = inCredits
57
+ ? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
58
+ : (costThisTurn != null ? formatCost(costThisTurn) : '');
59
+ const costSessStr = inCredits
60
+ ? ''
61
+ : (costSession != null ? `session: ${formatCost(costSession)}` : '');
52
62
  // Ink turn-receipt (see the variant doc on StatusFooterParams):
53
63
  // turn-only facts — everything session-scoped lives in <StatusLine />.
54
64
  const parts = variant === 'turn'
@@ -20,6 +20,7 @@
20
20
  import { computeCost } from '../cost/pricing.js';
21
21
  import { computeSessionCost } from '../cost/session-cost.js';
22
22
  import { globalStore } from '../ui/sap-state-store.js';
23
+ import { globalCredits } from '../cost/credits.js';
23
24
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
24
25
  import { shouldUseInk } from '../renderer/tty.js';
25
26
  export function snapshotTurnStart(session) {
@@ -71,6 +72,10 @@ export function pushPostTurnStatus(params) {
71
72
  costThisTurn: turnCost,
72
73
  costSession: sessionCost,
73
74
  sessionInputTokens: session.usage.input_tokens,
75
+ // Credits (2026-09-16): the agent loop already sampled the post-turn
76
+ // balance, so this snapshot is fresh. formatStatusFooter ignores it
77
+ // entirely in USD mode — the dollar receipt stays byte-identical.
78
+ credits: globalCredits.snapshot(),
74
79
  };
75
80
  if (shouldUseInk() && chunkEmitter) {
76
81
  // Push through chunkEmitter so the line lands in Body's <Static>
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The `spend:` row of the /exit session summary (2026-09-16).
3
+ *
4
+ * Extracted from repl.tsx's printGoodbye so the credits/USD switch has one
5
+ * testable home. USD output is byte-identical to the line printed before
6
+ * credits existed: ` spend: $1.37`.
7
+ */
8
+ import { formatCost } from '../cost/pricing.js';
9
+ import { creditsActive, formatCredits, CREDITS_PENDING } from '../cost/credits.js';
10
+ export function formatSessionSpendLine(sessionCostUsd, credits) {
11
+ if (!creditsActive())
12
+ return ` spend: ${formatCost(sessionCostUsd)}`;
13
+ if (!credits)
14
+ return ` spend: ${CREDITS_PENDING}`;
15
+ const value = formatCredits(credits.sessionTotal, credits.unitLabel);
16
+ const balance = credits.balance !== null
17
+ ? ` · balance ${Math.round(credits.balance).toLocaleString('en-US')}`
18
+ : '';
19
+ return ` spend: ${value}${balance}`;
20
+ }
package/dist/repl.js CHANGED
@@ -22,7 +22,10 @@ import { makeSessionId, saveSession } from './session/store.js';
22
22
  import { newSession } from './session/schema.js';
23
23
  import { resolveModelRole } from './models/resolve.js';
24
24
  import { loadServerModelConfig } from './models/server-config.js';
25
- import { computeCost, formatCost } from './cost/pricing.js';
25
+ import { computeCost } from './cost/pricing.js';
26
+ import { formatSessionSpendLine } from './repl/session-spend-line.js';
27
+ import { globalCredits } from './cost/credits.js';
28
+ import { initCredits } from './cost/credits-wire.js';
26
29
  import { createProviderForMode } from './agent/providers/factory.js';
27
30
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
28
31
  import { runTurn } from './agent/loop.js';
@@ -242,7 +245,7 @@ function printGoodbye(session) {
242
245
  console.log(chalk.dim(` turns: ${userTurns}`));
243
246
  console.log(chalk.dim(` last skill: ${session.skill ?? '(none)'}`));
244
247
  console.log(chalk.dim(` tokens: ${totalTokens.toLocaleString()} (input+output+cache)`));
245
- console.log(chalk.dim(` spend: ${formatCost(sessionCost)}`));
248
+ console.log(chalk.dim(formatSessionSpendLine(sessionCost, globalCredits.snapshot())));
246
249
  console.log('');
247
250
  console.log(chalk.cyan(`Resume with: cspeach --resume ${session.id}`));
248
251
  console.log(chalk.dim(`Full log: ~/.cspeach/streams/${session.id}.md`));
@@ -404,6 +407,13 @@ export async function runRepl(opts) {
404
407
  // fetch lands simply uses the built-ins (fail-safe). Precedence when it does
405
408
  // land: env > local config > server > built-in.
406
409
  void loadServerModelConfig(cfg);
410
+ // Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
411
+ // A managed customer is billed in credits at a multiplier we do not
412
+ // disclose, so the dollar figures (our Anthropic COGS) must never reach
413
+ // their screen. Awaited, but hard-capped at 2.5s inside fetchBalance and
414
+ // fail-safe: any failure leaves the process in USD mode, i.e. exactly the
415
+ // pre-credits behaviour. BYOK/ai-hub/local skip the round-trip entirely.
416
+ await initCredits(cfg, async () => apiKey ?? '');
407
417
  // Task C.10 — surface the current write_mode prominently. advisory-only gets
408
418
  // a bold yellow banner (no-writes is a surprising-enough state that the user
409
419
  // should not miss it). Other modes get a quiet one-liner so the state is
@@ -31,6 +31,7 @@
31
31
  // never an invented approval.
32
32
  import { summarise } from '../cost/cost-log.js';
33
33
  import { CLI_VERSION } from '../lib/version.js';
34
+ import { creditsActive, formatCredits } from '../cost/credits.js';
34
35
  /**
35
36
  * Object-mutating tools — one row per completed call in the Writes table.
36
37
  * Kept as a literal name set (not the tool registry) so this pure builder
@@ -319,7 +320,7 @@ function transportsSeen(toolCalls, sessionTransport) {
319
320
  }
320
321
  // ── Main builder ──────────────────────────────────────────────────────────
321
322
  export function buildAuditReport(input) {
322
- const { session, costEntries, transport } = input;
323
+ const { session, costEntries, transport, credits } = input;
323
324
  const calls = session.toolCalls;
324
325
  const completed = calls.filter((c) => c.completed === true);
325
326
  const approvals = parseApprovals(calls);
@@ -437,15 +438,33 @@ export function buildAuditReport(input) {
437
438
  const cost = summarise(costEntries);
438
439
  const auditEntries = costEntries.filter((e) => e.label);
439
440
  const auditCost = Math.round(auditEntries.reduce((s, e) => s + e.cost, 0) * 10000) / 10000;
440
- L(`- **Total:** $${cost.totalCost.toFixed(4)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
441
+ const inCredits = creditsActive();
442
+ if (inCredits) {
443
+ const unit = credits?.unitLabel ?? 'credits';
444
+ const logged = costEntries.reduce((s, e) => s + (typeof e.credits_charged === 'number' ? e.credits_charged : 0), 0);
445
+ const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : logged;
446
+ L(`- **Total:** ${formatCredits(total, unit)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
447
+ }
448
+ else {
449
+ L(`- **Total:** $${cost.totalCost.toFixed(4)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
450
+ }
441
451
  L(`- **Tokens:** ${cost.totalTokens.input.toLocaleString('en-US')} in · ${cost.totalTokens.output.toLocaleString('en-US')} out · ${cost.totalTokens.cacheRead.toLocaleString('en-US')} cache-read · ${cost.totalTokens.cacheCreate.toLocaleString('en-US')} cache-write`);
452
+ if (inCredits && credits?.balance != null) {
453
+ L(`- **Remaining:** balance ${Math.round(credits.balance).toLocaleString('en-US')}`);
454
+ }
442
455
  if (auditEntries.length > 0) {
443
- L(`- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}: $${auditCost.toFixed(4)}`);
456
+ // Credits mode: the labelled-audit turn COUNT is still useful provenance;
457
+ // its USD cost is not ours to publish to a customer.
458
+ L(inCredits
459
+ ? `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}`
460
+ : `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}: $${auditCost.toFixed(4)}`);
444
461
  }
445
462
  if (cost.byModel.length > 1) {
446
463
  L('- **By model:**');
447
464
  for (const m of cost.byModel) {
448
- L(` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'} · $${m.cost.toFixed(4)}`);
465
+ L(inCredits
466
+ ? ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'}`
467
+ : ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'} · $${m.cost.toFixed(4)}`);
449
468
  }
450
469
  }
451
470
  else if (cost.byModel[0]) {
package/dist/ui/header.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { useSapState } from './sap-state-store.js';
4
+ import { formatSpendSegment } from './status-line.js';
4
5
  import { CLI_VERSION } from '../lib/version.js';
5
6
  const PEACH = '#FFCBA4';
6
7
  /**
@@ -34,6 +35,7 @@ export function Header({ writeMode } = {}) {
34
35
  const tokDisplay = s.tokenUsageToday >= 1000
35
36
  ? `${(s.tokenUsageToday / 1000).toFixed(1)}k tok`
36
37
  : `${s.tokenUsageToday} tok`;
37
- const spendDisplay = `$${s.dollarSpendToday.toFixed(2)}`;
38
+ // Shared with <StatusLine /> so the credits/dollar switch has one home.
39
+ const spendDisplay = formatSpendSegment(s);
38
40
  return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, color: PEACH, children: ["\uD83C\uDF51 CSPeach ", CLI_VERSION] }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "cyan", children: s.system || 'no system' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { color: "gray", children: " \u2502 tr " }), _jsx(Text, { color: "yellow", children: s.transport ?? 'none' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: tokDisplay }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay }), modeLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: modeColor, bold: isAdvisory, children: modeLabel })] }))] }));
39
41
  }
@@ -15,6 +15,10 @@ const DEFAULT_STATE = {
15
15
  system: '', user: '', transport: null, locksHeld: [],
16
16
  tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 100, recentTurns: [],
17
17
  writeMode: null, lastWriteTransport: null,
18
+ // Credits default OFF: BYOK/local/ai-hub and any pre-resolution frame keep
19
+ // the dollar rendering. Only an explicit managed+metered resolution flips it.
20
+ creditsMode: false, creditsSessionTotal: 0, creditsBalance: null,
21
+ creditsUnitLabel: 'credits', creditsPending: false,
18
22
  };
19
23
  /**
20
24
  * Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
@@ -1,6 +1,21 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text } from 'ink';
3
3
  import { useSapState } from './sap-state-store.js';
4
+ import { formatCredits, CREDITS_PENDING } from '../cost/credits.js';
5
+ /**
6
+ * The spend segment shared by <StatusLine /> and <Header />.
7
+ *
8
+ * Single home for the credits/dollar switch so the two chrome widgets can
9
+ * never drift apart — a drift here is exactly how our COGS would leak onto a
10
+ * managed customer's screen from one of them.
11
+ */
12
+ export function formatSpendSegment(s) {
13
+ if (!s.creditsMode)
14
+ return `$${s.dollarSpendToday.toFixed(2)}`;
15
+ if (s.creditsPending && s.creditsSessionTotal === 0)
16
+ return CREDITS_PENDING;
17
+ return formatCredits(s.creditsSessionTotal, s.creditsUnitLabel);
18
+ }
4
19
  /**
5
20
  * Persistent SAP statusline (UX Wave 1, Task 4) — Header reborn,
6
21
  * bottom-anchored. Single borderless line rendered between <TurnStatus />
@@ -25,7 +40,11 @@ const MODE_COLORS = {
25
40
  };
26
41
  export function StatusLine() {
27
42
  const s = useSapState();
28
- const spendDisplay = `$${s.dollarSpendToday.toFixed(2)}`;
43
+ // Credits mode (2026-09-16): a managed customer sees what THEY are charged.
44
+ // The segment keeps its meaning — "what this session has cost me" — so it
45
+ // carries the session credit total, not the balance (the balance has its
46
+ // own home in the post-turn receipt and `cspeach doctor`).
47
+ const spendDisplay = formatSpendSegment(s);
29
48
  // Wave1 live-fix (Bug 1) — one TRUNCATING <Text> instead of a <Box> of
30
49
  // sibling Texts. The statusline lives in the dynamic Ink frame, where a
31
50
  // row that soft-wraps in the terminal breaks log-update's logical-line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",