codemax-cli 0.0.0-stage → 0.1.3

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 (56) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +27 -0
  3. package/README.md +163 -2
  4. package/TRADEMARKS.md +27 -0
  5. package/bin/codemax +2 -0
  6. package/bin/codemax.cjs +78 -0
  7. package/package.json +72 -4
  8. package/src/adapters/claude.ts +43 -0
  9. package/src/adapters/codex.ts +101 -0
  10. package/src/adapters/detect.ts +16 -0
  11. package/src/adapters/index.ts +21 -0
  12. package/src/adapters/simple.ts +89 -0
  13. package/src/adapters/types.ts +87 -0
  14. package/src/chat/app.ts +1934 -0
  15. package/src/chat/logo.ts +37 -0
  16. package/src/chat/markdown.ts +77 -0
  17. package/src/chat/palette.ts +179 -0
  18. package/src/chat/permission.ts +111 -0
  19. package/src/chat/theme.ts +158 -0
  20. package/src/chat/transcript.ts +342 -0
  21. package/src/cli.ts +254 -0
  22. package/src/config.ts +114 -0
  23. package/src/core/awake.ts +345 -0
  24. package/src/core/brief.ts +314 -0
  25. package/src/core/budget.ts +42 -0
  26. package/src/core/checkpoint.ts +139 -0
  27. package/src/core/doctor.ts +67 -0
  28. package/src/core/files.ts +62 -0
  29. package/src/core/git.ts +36 -0
  30. package/src/core/hotkey.ts +75 -0
  31. package/src/core/journal.ts +215 -0
  32. package/src/core/ledger.ts +153 -0
  33. package/src/core/menu.ts +134 -0
  34. package/src/core/orchestrator.ts +352 -0
  35. package/src/core/paths.ts +30 -0
  36. package/src/core/prices-live.ts +123 -0
  37. package/src/core/pricing.ts +162 -0
  38. package/src/core/pty.ts +99 -0
  39. package/src/core/quota.ts +55 -0
  40. package/src/core/review.ts +67 -0
  41. package/src/core/spawn.ts +20 -0
  42. package/src/core/terminal.ts +51 -0
  43. package/src/core/update.ts +90 -0
  44. package/src/core/usage.ts +116 -0
  45. package/src/engines/acp.ts +419 -0
  46. package/src/engines/codex.ts +330 -0
  47. package/src/engines/env.ts +17 -0
  48. package/src/engines/errors.ts +47 -0
  49. package/src/engines/index.ts +154 -0
  50. package/src/engines/types.ts +131 -0
  51. package/src/transcripts/claude-usage.ts +63 -0
  52. package/src/transcripts/claude.ts +69 -0
  53. package/src/transcripts/codex.ts +74 -0
  54. package/src/tui/app.ts +377 -0
  55. package/src/tui/menu.ts +126 -0
  56. package/src/tui/pane.ts +79 -0
@@ -0,0 +1,352 @@
1
+ import type { Config } from "../config.ts";
2
+ import type { Adapter, AgentId, QuotaStatus, SessionRef } from "../adapters/types.ts";
3
+ import { listAdapters, getAdapter } from "../adapters/index.ts";
4
+ import { Ledger } from "./ledger.ts";
5
+ import { AgentProcess } from "./pty.ts";
6
+ import { Menu, type MenuRow } from "./menu.ts";
7
+ import { OutputScanner, isExhausted } from "./quota.ts";
8
+ import { buildBrief } from "./brief.ts";
9
+ import { diffStat } from "./git.ts";
10
+ import { CLEAR_SEQ, PASTE_END, PASTE_START, RESET_SEQ, out, termSize } from "./terminal.ts";
11
+ import { findHotkey, parseHotkeys, type HotkeySpec } from "./hotkey.ts";
12
+
13
+ interface Running {
14
+ adapter: Adapter;
15
+ /** True while the agent has the alternate screen active (tracked from its output). */
16
+ altScreen: boolean;
17
+ proc: AgentProcess;
18
+ ref: SessionRef | null;
19
+ scanner: OutputScanner;
20
+ sessionId?: string;
21
+ pendingPrompt?: string;
22
+ lastOutputAt: number;
23
+ firstOutputAt: number;
24
+ timers: ReturnType<typeof setInterval>[];
25
+ }
26
+
27
+ /**
28
+ * Owns the host terminal. Runs one agent TUI at a time inside a PTY, passes
29
+ * bytes through, opens the codemax menu on the hotkey, and performs switches.
30
+ */
31
+ export class Orchestrator {
32
+ private running: Running | null = null;
33
+ private menu: Menu | null = null;
34
+ private detect = new Map<AgentId, { installed: boolean; version?: string }>();
35
+ private quota = new Map<AgentId, QuotaStatus>();
36
+ private switching = false;
37
+ private resolveDone!: () => void;
38
+ readonly done: Promise<void>;
39
+ private decoder = new TextDecoder();
40
+ private hotkeys: HotkeySpec[];
41
+
42
+ constructor(
43
+ private config: Config,
44
+ private ledger: Ledger,
45
+ private cwd: string,
46
+ ) {
47
+ this.done = new Promise((r) => (this.resolveDone = r));
48
+ this.hotkeys = parseHotkeys(config.hotkeys);
49
+ for (const [id, q] of Object.entries(ledger.quota())) this.quota.set(id as AgentId, q);
50
+ }
51
+
52
+ async start(agent: AgentId, fresh: boolean) {
53
+ this.attachHost();
54
+ await this.refreshDetect();
55
+ const first = this.detect.get(agent)?.installed ? agent : this.firstInstalled();
56
+ if (!first) {
57
+ this.teardownHost();
58
+ throw new Error("no supported agent CLI found on PATH");
59
+ }
60
+ if (first !== agent) out(`codemax: ${agent} not found, starting ${first}\r\n`);
61
+ await this.launch(getAdapter(first), { fresh, reason: undefined });
62
+ return this.done;
63
+ }
64
+
65
+ // ---------- host terminal ----------
66
+
67
+ private onStdin = (chunk: Buffer) => {
68
+ const bytes = new Uint8Array(chunk);
69
+ if (this.menu) {
70
+ const action = this.menu.handleKey(bytes);
71
+ if (action) void this.onMenuAction(action);
72
+ return;
73
+ }
74
+ // The hotkey may share a chunk with other keys when typed quickly: forward
75
+ // what came before it, open the menu, and let the menu see what followed.
76
+ const hit = findHotkey(bytes, this.hotkeys);
77
+ if (hit) {
78
+ if (hit.index > 0) this.running?.proc.write(bytes.subarray(0, hit.index));
79
+ this.openMenu("switch agent", true);
80
+ const rest = bytes.subarray(hit.index + hit.length);
81
+ const menu = this.menu as Menu | null; // openMenu() just set it; TS keeps the stale null narrowing
82
+ if (rest.length && menu) {
83
+ const action = menu.handleKey(rest);
84
+ if (action) void this.onMenuAction(action);
85
+ }
86
+ return;
87
+ }
88
+ this.running?.proc.write(bytes);
89
+ };
90
+
91
+ private onResize = () => {
92
+ const { cols, rows } = termSize();
93
+ this.running?.proc.resize(cols, rows);
94
+ this.menu?.render();
95
+ };
96
+
97
+ private attachHost() {
98
+ process.stdin.setRawMode?.(true);
99
+ process.stdin.resume();
100
+ process.stdin.on("data", this.onStdin);
101
+ process.on("SIGWINCH", this.onResize);
102
+ process.on("SIGINT", () => {}); // Ctrl+C is forwarded as a byte in raw mode
103
+ }
104
+
105
+ private teardownHost() {
106
+ process.stdin.off("data", this.onStdin);
107
+ process.off("SIGWINCH", this.onResize);
108
+ process.stdin.setRawMode?.(false);
109
+ process.stdin.pause();
110
+ out(RESET_SEQ);
111
+ }
112
+
113
+ // ---------- agent lifecycle ----------
114
+
115
+ private async launch(adapter: Adapter, o: { fresh: boolean; reason?: string; from?: AgentId }) {
116
+ const stale = this.quota.get(adapter.id);
117
+ if (stale?.source === "output") {
118
+ this.quota.delete(adapter.id);
119
+ this.ledger.clearQuota(adapter.id);
120
+ }
121
+ const saved = this.ledger.sessions()[adapter.id];
122
+ const resume = !o.fresh && saved ? saved : undefined;
123
+ const sessionId = resume ? undefined : adapter.newSessionId?.();
124
+ const hasHistory = this.ledger.turns().length > 0 || !!o.from;
125
+ const brief = hasHistory ? await buildBrief({ ledger: this.ledger, cwd: this.cwd, to: adapter.id, from: o.from, reason: o.reason, config: this.config }) : undefined;
126
+
127
+ const spec = adapter.launch({ cwd: this.cwd, resume, sessionId, prompt: brief, extraArgs: this.config.agentArgs[adapter.id] });
128
+ out(RESET_SEQ + CLEAR_SEQ);
129
+
130
+ const scanner = new OutputScanner(adapter.id, adapter.quotaPatterns, (q) => this.onQuota(q));
131
+ const run: Running = {
132
+ adapter,
133
+ ref: resume ?? null,
134
+ scanner,
135
+ sessionId,
136
+ pendingPrompt: spec.promptConsumed ? undefined : brief,
137
+ lastOutputAt: 0,
138
+ firstOutputAt: 0,
139
+ altScreen: false,
140
+ timers: [],
141
+ proc: null as unknown as AgentProcess,
142
+ };
143
+ // Subscriptions only: an API key in the environment is never passed to an agent.
144
+ const dropEnv = adapter.apiKeyEnv.filter((k) => process.env[k]);
145
+ if (dropEnv.length) out(`codemax: unset ${dropEnv.join(", ")} for ${adapter.name} — codemax only uses subscription logins, never API keys\r\n`);
146
+ run.proc = new AgentProcess({
147
+ cmd: spec.cmd,
148
+ cwd: this.cwd,
149
+ env: spec.env,
150
+ dropEnv,
151
+ onData: (chunk) => {
152
+ const now = Date.now();
153
+ if (!run.firstOutputAt) run.firstOutputAt = now;
154
+ run.lastOutputAt = now;
155
+ if (!this.menu) process.stdout.write(chunk);
156
+ const text = this.decoder.decode(chunk, { stream: true });
157
+ trackAltScreen(run, text);
158
+ scanner.feed(text);
159
+ },
160
+ onExit: (code) => void this.onAgentExit(run, code),
161
+ });
162
+ this.running = run;
163
+ this.ledger.addEvent({ kind: o.from ? "switch" : "start", agent: o.from, to: adapter.id, detail: o.reason });
164
+
165
+ run.timers.push(setInterval(() => void this.sync(run), 2000));
166
+ if (run.pendingPrompt) run.timers.push(setInterval(() => this.maybeTypePrompt(run), 250));
167
+ void this.locate(run);
168
+ }
169
+
170
+ /** Find the vendor session (transcript) for a freshly launched agent. Polls until the agent exits. */
171
+ private async locate(run: Running) {
172
+ if (run.ref) return;
173
+ while (this.running === run && !run.proc.exited) {
174
+ const ref = await run.adapter.locateSession({ cwd: this.cwd, startedAt: run.proc.startedAt, sessionId: run.sessionId });
175
+ if (ref) {
176
+ run.ref = ref;
177
+ this.ledger.saveSession(ref);
178
+ return;
179
+ }
180
+ await Bun.sleep(1000);
181
+ }
182
+ }
183
+
184
+ /** Pull new turns + quota from the vendor transcript into the ledger. */
185
+ private async sync(run: Running) {
186
+ if (!run.ref) return;
187
+ try {
188
+ if (run.adapter.readTranscript) {
189
+ const turns = await run.adapter.readTranscript(run.ref);
190
+ if (this.ledger.addTurns(turns) > 0) {
191
+ run.ref.lastUsedAt = Date.now();
192
+ this.ledger.saveSession(run.ref);
193
+ }
194
+ }
195
+ if (run.adapter.probeQuota) {
196
+ const q = await run.adapter.probeQuota(run.ref);
197
+ if (q) this.onQuota(q, true);
198
+ }
199
+ } catch {
200
+ /* transcript mid-write; retry next tick */
201
+ }
202
+ }
203
+
204
+ /** Type the handoff brief into TUIs that do not take a prompt argument. */
205
+ private maybeTypePrompt(run: Running) {
206
+ if (!run.pendingPrompt || !run.firstOutputAt) return;
207
+ const now = Date.now();
208
+ const quiet = now - run.lastOutputAt > 700;
209
+ const old = now - run.proc.startedAt > 1500;
210
+ const timeout = now - run.proc.startedAt > 15_000;
211
+ if ((quiet && old) || timeout) {
212
+ const text = run.pendingPrompt;
213
+ run.pendingPrompt = undefined;
214
+ run.proc.write(PASTE_START + text + PASTE_END);
215
+ setTimeout(() => run.proc.write("\r"), 200);
216
+ }
217
+ }
218
+
219
+ private onQuota(q: QuotaStatus, silent = false) {
220
+ const prev = this.quota.get(q.agent);
221
+ this.quota.set(q.agent, q);
222
+ this.ledger.saveQuota(q);
223
+ if (isExhausted(q) && !isExhausted(prev)) {
224
+ this.ledger.addEvent({ kind: "quota", agent: q.agent, detail: q.detail });
225
+ if (this.config.autoSwitch && !silent) this.offerSwitchWhenIdle(q);
226
+ }
227
+ }
228
+
229
+ /** Open the menu for a quota hit only once the agent has stopped printing, so a stream is never cut. */
230
+ private offerSwitchWhenIdle(q: QuotaStatus) {
231
+ const run = this.running;
232
+ const tick = setInterval(() => {
233
+ if (this.running !== run || this.menu) return clearInterval(tick);
234
+ if (Date.now() - (run?.lastOutputAt ?? 0) < 2000) return;
235
+ clearInterval(tick);
236
+ const guess = q.source === "output";
237
+ this.openMenu(
238
+ guess ? `${q.agent} may be out of quota — pick the next agent, or Esc if this is a false alarm` : `${q.agent} is out of quota — pick the next agent`,
239
+ true,
240
+ `${q.agent}: ${q.detail ?? "limit reached"}${guess ? " (screen text, not confirmed; forgotten in 30 min or when you relaunch it)" : ""}`,
241
+ );
242
+ }, 500);
243
+ }
244
+
245
+ private async onAgentExit(run: Running, code: number | null) {
246
+ run.timers.forEach(clearInterval);
247
+ if (this.running !== run) return; // killed by a switch; switch handles the rest
248
+ await this.sync(run);
249
+ this.ledger.addEvent({ kind: "exit", agent: run.adapter.id, detail: `exit ${code}`, diffStat: await diffStat(this.cwd) });
250
+ this.running = null;
251
+ out(RESET_SEQ);
252
+ if (this.switching) return;
253
+ this.openMenu(`${run.adapter.name} exited (${code}) — switch agent or quit`, false);
254
+ }
255
+
256
+ // ---------- menu ----------
257
+
258
+ private async refreshDetect() {
259
+ await Promise.all(
260
+ listAdapters(this.config.order).map(async (a) => {
261
+ const d = await a.detect();
262
+ this.detect.set(a.id, { installed: d.installed, version: d.version });
263
+ }),
264
+ );
265
+ }
266
+
267
+ private firstInstalled(): AgentId | undefined {
268
+ return listAdapters(this.config.order).find((a) => this.detect.get(a.id)?.installed)?.id;
269
+ }
270
+
271
+ private rows(): MenuRow[] {
272
+ const sessions = this.ledger.sessions();
273
+ return listAdapters(this.config.order).map((a) => ({
274
+ adapter: a,
275
+ installed: this.detect.get(a.id)?.installed ?? false,
276
+ version: this.detect.get(a.id)?.version,
277
+ session: sessions[a.id],
278
+ quota: this.quota.get(a.id),
279
+ }));
280
+ }
281
+
282
+ private openMenu(title: string, canClose: boolean, notice?: string) {
283
+ if (this.menu) return;
284
+ this.menu = new Menu({ title, rows: this.rows(), current: this.running?.adapter.id, notice, canClose, hotkeys: this.config.hotkeys });
285
+ // If the agent already owns the alternate screen, draw over it instead of
286
+ // nesting another alt-screen switch the terminal cannot unwind cleanly.
287
+ this.menu.open(!this.running?.altScreen);
288
+ void this.refreshDetect().then(() => this.menu?.update({ rows: this.rows() }));
289
+ }
290
+
291
+ private closeMenu() {
292
+ this.menu?.close(!this.running?.altScreen);
293
+ this.menu = null;
294
+ this.running?.proc.jiggle();
295
+ }
296
+
297
+ private async onMenuAction(action: { kind: string; agent?: AgentId; fresh?: boolean }) {
298
+ if (action.kind === "close") return this.closeMenu();
299
+ if (action.kind === "quit") return this.quit();
300
+ if (action.kind === "switch" && action.agent) {
301
+ const target = getAdapter(action.agent);
302
+ const current = this.running;
303
+ if (current && current.adapter.id === target.id && !action.fresh) return this.closeMenu();
304
+ this.menu?.update({ notice: `switching to ${target.name}…` });
305
+ await this.switchTo(target, !!action.fresh);
306
+ }
307
+ }
308
+
309
+ private async switchTo(target: Adapter, fresh: boolean) {
310
+ this.switching = true;
311
+ const prev = this.running;
312
+ let from: AgentId | undefined;
313
+ if (prev) {
314
+ from = prev.adapter.id;
315
+ prev.timers.forEach(clearInterval);
316
+ this.running = null;
317
+ await prev.proc.kill();
318
+ await this.sync(prev);
319
+ this.ledger.addEvent({ kind: "note", agent: from, detail: "stopped for switch", diffStat: await diffStat(this.cwd) });
320
+ }
321
+ this.menu?.close(false);
322
+ this.menu = null;
323
+ out(RESET_SEQ + CLEAR_SEQ + `codemax: handing off to ${target.name}…\r\n`);
324
+ const fromQ = from ? this.quota.get(from) : undefined;
325
+ const reason = fromQ && isExhausted(fromQ) ? (fromQ.source === "output" ? `${from} appeared to hit its usage limit` : `${from} ran out of quota`) : undefined;
326
+ await this.launch(target, { fresh, from, reason });
327
+ this.switching = false;
328
+ }
329
+
330
+ private async quit() {
331
+ this.switching = true;
332
+ const run = this.running;
333
+ this.menu?.close(false);
334
+ this.menu = null;
335
+ if (run) {
336
+ run.timers.forEach(clearInterval);
337
+ this.running = null;
338
+ await run.proc.kill();
339
+ await this.sync(run);
340
+ this.ledger.addEvent({ kind: "exit", agent: run.adapter.id, detail: "codemax quit", diffStat: await diffStat(this.cwd) });
341
+ }
342
+ this.teardownHost();
343
+ out(`codemax: bye. ledger at ${this.ledger.dir}\n`);
344
+ this.resolveDone();
345
+ }
346
+ }
347
+
348
+ const ALT_RE = /\x1b\[\?(?:1049|1047|47)([hl])/g;
349
+ function trackAltScreen(run: Running, text: string) {
350
+ let m: RegExpExecArray | null;
351
+ while ((m = ALT_RE.exec(text))) run.altScreen = m[1] === "h";
352
+ }
@@ -0,0 +1,30 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { realpathSync, mkdirSync, existsSync } from "node:fs";
4
+
5
+ export const HOME = homedir();
6
+ export const CODEMAX_HOME = process.env.CODEMAX_HOME ?? join(HOME, ".codemax");
7
+
8
+ /** Claude Code's project-dir rule: every non-alphanumeric char becomes '-'. */
9
+ export function escapeCwd(cwd: string): string {
10
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
11
+ }
12
+
13
+ export function canonicalCwd(cwd: string = process.cwd()): string {
14
+ try {
15
+ return realpathSync(cwd);
16
+ } catch {
17
+ return cwd;
18
+ }
19
+ }
20
+
21
+ export function projectDir(cwd: string): string {
22
+ const dir = join(CODEMAX_HOME, "projects", escapeCwd(cwd));
23
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
24
+ return dir;
25
+ }
26
+
27
+ export function ensureDir(dir: string): string {
28
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
29
+ return dir;
30
+ }
@@ -0,0 +1,123 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { CODEMAX_HOME, ensureDir } from "./paths.ts";
4
+ import type { Price } from "./pricing.ts";
5
+
6
+ export interface LivePrices {
7
+ fetchedAt: number;
8
+ source: "openrouter" | "litellm";
9
+ /** Keyed by normalized model id: lowercase, provider prefix and :suffix removed, dots as dashes. */
10
+ prices: Record<string, Price>;
11
+ }
12
+
13
+ export const LIVE_PRICES_PATH = join(CODEMAX_HOME, "prices-live.json");
14
+ const OPENROUTER = "https://openrouter.ai/api/v1/models";
15
+ const LITELLM = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
16
+ const MAX_AGE_MS = 7 * 86_400_000;
17
+
18
+ /** "anthropic/claude-opus-5.5:batch" → "claude-opus-5-5"; "xai/grok-4.7" → "grok-4-7". */
19
+ export function normalizeModelId(id: string): string {
20
+ let s = id.toLowerCase().trim();
21
+ s = s.replace(/:(batch|free|extended|thinking|online)$/, "");
22
+ s = s.replace(/^(global\.|us\.|eu\.|apac\.)/, "");
23
+ s = s.replace(/^[a-z0-9_-]+[\/.](?=[a-z])/, ""); // provider prefix: "anthropic/", "anthropic.", "azure/us/" (loop below)
24
+ while (/^(us|eu|global|apac)\//.test(s)) s = s.replace(/^[a-z]+\//, "");
25
+ return s.replace(/\./g, "-");
26
+ }
27
+
28
+ const perM = (perToken: unknown): number | undefined => {
29
+ const n = Number(perToken);
30
+ return Number.isFinite(n) ? Math.round(n * 1e6 * 1e6) / 1e6 : undefined;
31
+ };
32
+
33
+ export function parseOpenRouter(json: any): Record<string, Price> {
34
+ const out: Record<string, Price> = {};
35
+ for (const m of json?.data ?? []) {
36
+ const p = m.pricing ?? {};
37
+ const input = perM(p.prompt);
38
+ const output = perM(p.completion);
39
+ if (input === undefined || output === undefined) continue;
40
+ const key = normalizeModelId(String(m.id));
41
+ if (out[key] && /:batch|:free/.test(String(m.id))) continue; // keep the standard variant
42
+ out[key] = {
43
+ input,
44
+ output,
45
+ cacheRead: p.input_cache_read ? perM(p.input_cache_read) : undefined,
46
+ cacheWrite: p.input_cache_write ? perM(p.input_cache_write) : undefined,
47
+ };
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export function parseLiteLlm(json: any): Record<string, Price> {
53
+ const out: Record<string, Price> = {};
54
+ for (const [id, v] of Object.entries<any>(json ?? {})) {
55
+ if (id === "sample_spec" || typeof v?.input_cost_per_token !== "number" || typeof v?.output_cost_per_token !== "number") continue;
56
+ if (/^(azure|bedrock|vertex|databricks|perplexity|openrouter|fireworks|together)/.test(id)) continue; // partner-priced variants
57
+ const key = normalizeModelId(id);
58
+ if (out[key]) continue;
59
+ out[key] = {
60
+ input: perM(v.input_cost_per_token)!,
61
+ output: perM(v.output_cost_per_token)!,
62
+ cacheRead: typeof v.cache_read_input_token_cost === "number" ? perM(v.cache_read_input_token_cost) : undefined,
63
+ cacheWrite: typeof v.cache_creation_input_token_cost === "number" ? perM(v.cache_creation_input_token_cost) : undefined,
64
+ };
65
+ }
66
+ return out;
67
+ }
68
+
69
+ export function loadLivePrices(): LivePrices | null {
70
+ if (!existsSync(LIVE_PRICES_PATH)) return null;
71
+ try {
72
+ const j = JSON.parse(readFileSync(LIVE_PRICES_PATH, "utf8"));
73
+ return j && typeof j.fetchedAt === "number" && j.prices ? (j as LivePrices) : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ export function livePricesStale(l: LivePrices | null, now = Date.now()): boolean {
80
+ return !l || now - l.fetchedAt > MAX_AGE_MS;
81
+ }
82
+
83
+ /** Download the current list (OpenRouter, then LiteLLM) and cache it. Returns null when offline. */
84
+ export async function fetchLivePrices(timeoutMs = 8000): Promise<LivePrices | null> {
85
+ const get = async (url: string) => {
86
+ const ctl = new AbortController();
87
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
88
+ try {
89
+ const r = await fetch(url, { signal: ctl.signal, headers: { "user-agent": "codemax-cli" } });
90
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
91
+ return await r.json();
92
+ } finally {
93
+ clearTimeout(t);
94
+ }
95
+ };
96
+ let result: LivePrices | null = null;
97
+ try {
98
+ const prices = parseOpenRouter(await get(OPENROUTER));
99
+ if (Object.keys(prices).length > 50) result = { fetchedAt: Date.now(), source: "openrouter", prices };
100
+ } catch {
101
+ /* try the next source */
102
+ }
103
+ if (!result) {
104
+ try {
105
+ const prices = parseLiteLlm(await get(LITELLM));
106
+ if (Object.keys(prices).length > 50) result = { fetchedAt: Date.now(), source: "litellm", prices };
107
+ } catch {
108
+ /* offline */
109
+ }
110
+ }
111
+ if (result) {
112
+ ensureDir(CODEMAX_HOME);
113
+ writeFileSync(LIVE_PRICES_PATH, JSON.stringify(result));
114
+ }
115
+ return result;
116
+ }
117
+
118
+ /** Refresh in the background when the cache is older than a week. Never throws. */
119
+ export async function refreshLivePricesIfStale(): Promise<LivePrices | null> {
120
+ const cur = loadLivePrices();
121
+ if (!livePricesStale(cur)) return cur;
122
+ return (await fetchLivePrices().catch(() => null)) ?? cur;
123
+ }
@@ -0,0 +1,162 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { CODEMAX_HOME } from "./paths.ts";
4
+ import { loadLivePrices, normalizeModelId, type LivePrices } from "./prices-live.ts";
5
+
6
+ /** USD per million tokens. cacheWrite defaults to 1.25 × input when omitted. */
7
+ export interface Price {
8
+ input: number;
9
+ output: number;
10
+ cacheRead?: number;
11
+ cacheWrite?: number;
12
+ }
13
+
14
+ /**
15
+ * List prices (USD per 1M tokens) as published on 2026-10-18. Matched by the
16
+ * first entry whose key is a substring of the model id, so order matters
17
+ * (more specific first). Override or extend in ~/.codemax/prices.json.
18
+ */
19
+ export const DEFAULT_PRICES: [string, Price][] = [
20
+ // Anthropic
21
+ ["claude-fable-5-1", { input: 10, output: 50, cacheRead: 0.25 }],
22
+ ["claude-fable-5", { input: 10, output: 50, cacheRead: 1.0 }],
23
+ ["claude-mythos", { input: 10, output: 50, cacheRead: 0.25 }],
24
+ ["claude-opus-5-5", { input: 4, output: 20, cacheRead: 0.2 }],
25
+ ["claude-opus-5", { input: 5, output: 25, cacheRead: 0.5 }],
26
+ ["claude-opus-4", { input: 5, output: 25, cacheRead: 0.5 }],
27
+ ["claude-sonnet-5-5", { input: 2, output: 10, cacheRead: 0.2 }],
28
+ ["claude-sonnet-5", { input: 2, output: 10, cacheRead: 0.2 }],
29
+ ["claude-sonnet-4-6", { input: 3, output: 15, cacheRead: 0.3 }],
30
+ ["claude-sonnet-4", { input: 3, output: 15, cacheRead: 0.3 }],
31
+ ["claude-haiku-5-5", { input: 0.1, output: 0.5, cacheRead: 0.01 }],
32
+ ["claude-haiku-4-5", { input: 1, output: 5, cacheRead: 0.1 }],
33
+ ["claude-haiku", { input: 1, output: 5, cacheRead: 0.1 }],
34
+ ["fable", { input: 10, output: 50, cacheRead: 0.25 }],
35
+ ["opus", { input: 4, output: 20, cacheRead: 0.2 }],
36
+ ["sonnet", { input: 2, output: 10, cacheRead: 0.2 }],
37
+ ["haiku", { input: 0.1, output: 0.5, cacheRead: 0.01 }],
38
+ // OpenAI
39
+ ["gpt-6.1-sol", { input: 10, output: 50, cacheRead: 0.5 }],
40
+ ["gpt-6-astra", { input: 10, output: 50, cacheRead: 1.0 }],
41
+ ["gpt-5.6-sol", { input: 4, output: 20, cacheRead: 0.4 }],
42
+ ["gpt-5.6-terra", { input: 2, output: 12, cacheRead: 0.2 }],
43
+ ["gpt-5.6-luna", { input: 0.2, output: 1.2, cacheRead: 0.02 }],
44
+ ["gpt-5.5-pro", { input: 30, output: 180 }],
45
+ ["gpt-5.5", { input: 5, output: 30, cacheRead: 0.5 }],
46
+ ["gpt-5.4-mini", { input: 0.75, output: 4.5, cacheRead: 0.075 }],
47
+ ["gpt-5.4", { input: 2.5, output: 15, cacheRead: 0.25 }],
48
+ ["gpt-5", { input: 2, output: 12, cacheRead: 0.2 }],
49
+ // xAI
50
+ ["grok-4.7", { input: 2, output: 6, cacheRead: 0.5 }],
51
+ ["grok-4.6", { input: 2, output: 6, cacheRead: 0.5 }],
52
+ ["grok-4.5", { input: 2, output: 6, cacheRead: 0.5 }],
53
+ ["grok-4.20", { input: 1.25, output: 2.5, cacheRead: 0.2 }],
54
+ ["grok-4.3", { input: 1.25, output: 2.5, cacheRead: 0.2 }],
55
+ ["grok", { input: 2, output: 6, cacheRead: 0.5 }],
56
+ // Google
57
+ ["gemini-3", { input: 2, output: 12, cacheRead: 0.2 }],
58
+ ["gemini-2.5-pro", { input: 1.25, output: 10, cacheRead: 0.31 }],
59
+ ["gemini-2.5-flash", { input: 0.3, output: 2.5, cacheRead: 0.03 }],
60
+ ["gemini", { input: 1.25, output: 10, cacheRead: 0.31 }],
61
+ // OpenCode's bundled free models
62
+ ["big-pickle", { input: 0, output: 0, cacheRead: 0 }],
63
+ ["opencode/", { input: 0, output: 0, cacheRead: 0 }],
64
+ ];
65
+
66
+ let cached: [string, Price][] | null = null;
67
+ let userCached: [string, Price][] | null = null;
68
+ let liveCached: LivePrices | null | undefined;
69
+
70
+ /** Forget cached tables (after `codemax prices update` or in tests). */
71
+ export function resetPriceCache() {
72
+ cached = null;
73
+ userCached = null;
74
+ liveCached = undefined;
75
+ }
76
+
77
+ function userPrices(): [string, Price][] {
78
+ if (userCached) return userCached;
79
+ const user: [string, Price][] = [];
80
+ const p = join(CODEMAX_HOME, "prices.json");
81
+ if (existsSync(p)) {
82
+ try {
83
+ const j = JSON.parse(readFileSync(p, "utf8")) as Record<string, Price>;
84
+ for (const [k, v] of Object.entries(j)) if (typeof v?.input === "number" && typeof v?.output === "number") user.push([k.toLowerCase(), v]);
85
+ } catch {
86
+ /* ignore malformed overrides */
87
+ }
88
+ }
89
+ userCached = user;
90
+ return user;
91
+ }
92
+
93
+ function live(): LivePrices | null {
94
+ if (liveCached === undefined) liveCached = loadLivePrices();
95
+ return liveCached;
96
+ }
97
+
98
+ /** Built-in table with the user's ~/.codemax/prices.json entries placed first (no live data). */
99
+ export function priceTable(): [string, Price][] {
100
+ if (cached) return cached;
101
+ cached = [...userPrices(), ...DEFAULT_PRICES];
102
+ return cached;
103
+ }
104
+
105
+ export type PriceSource = "user" | "openrouter" | "litellm" | "builtin";
106
+
107
+ /**
108
+ * Resolve a price: user overrides first, then the downloaded list (exact id,
109
+ * then the longest id contained in the model name), then the built-in table.
110
+ */
111
+ export function priceFor(model: string | undefined): { key: string; price: Price; source: PriceSource } | null {
112
+ if (!model) return null;
113
+ const m = model.toLowerCase();
114
+ for (const [key, price] of userPrices()) if (m.includes(key)) return { key, price, source: "user" };
115
+ const l = live();
116
+ if (l) {
117
+ const norm = normalizeModelId(m);
118
+ if (l.prices[norm]) return { key: norm, price: l.prices[norm]!, source: l.source };
119
+ let best: string | null = null;
120
+ for (const k of Object.keys(l.prices)) if (k.length >= 6 && norm.includes(k) && (!best || k.length > best.length)) best = k;
121
+ if (best) return { key: best, price: l.prices[best]!, source: l.source };
122
+ }
123
+ for (const [key, price] of DEFAULT_PRICES) if (m.includes(key)) return { key, price, source: "builtin" };
124
+ return null;
125
+ }
126
+
127
+ /** Human description of where prices come from, for /usage and `codemax prices`. */
128
+ export function priceProvenance(): { source: PriceSource; asOf: string } {
129
+ const l = live();
130
+ if (l) return { source: l.source, asOf: new Date(l.fetchedAt).toISOString().slice(0, 10) };
131
+ return { source: "builtin", asOf: "2026-10-18" };
132
+ }
133
+
134
+ export interface TokenCounts {
135
+ input: number;
136
+ output: number;
137
+ cacheRead: number;
138
+ cacheWrite: number;
139
+ }
140
+
141
+ /** API-equivalent USD for the given tokens, or null when the model is unknown. */
142
+ export function estimateCost(model: string | undefined, t: TokenCounts): number | null {
143
+ const hit = priceFor(model);
144
+ if (!hit) return null;
145
+ const { price } = hit;
146
+ const cacheRead = price.cacheRead ?? price.input * 0.1;
147
+ const cacheWrite = price.cacheWrite ?? price.input * 1.25;
148
+ return (t.input * price.input + t.output * price.output + t.cacheRead * cacheRead + t.cacheWrite * cacheWrite) / 1_000_000;
149
+ }
150
+
151
+ export function fmtUsd(n: number | null | undefined): string {
152
+ if (n === null || n === undefined) return "n/a";
153
+ if (n === 0) return "$0.00";
154
+ if (n < 0.01) return `$${n.toFixed(4)}`;
155
+ return `$${n.toFixed(2)}`;
156
+ }
157
+
158
+ export function fmtTokens(n: number): string {
159
+ if (n < 1000) return String(n);
160
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
161
+ return `${(n / 1_000_000).toFixed(2)}M`;
162
+ }