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,99 @@
1
+ import { termSize } from "./terminal.ts";
2
+ import { IS_WINDOWS } from "./spawn.ts";
3
+
4
+ const NESTED_SESSION_ENV = /^(CLAUDECODE|CLAUDE_CODE_|CLAUDE_PID|CLAUDE_EFFORT|CODEX_SANDBOX|CODEX_THREAD_ID|CURSOR_AGENT_SESSION)/;
5
+
6
+ export interface AgentProcessOptions {
7
+ cmd: string[];
8
+ cwd: string;
9
+ env?: Record<string, string>;
10
+ /** Env vars removed from the child's environment. */
11
+ dropEnv?: string[];
12
+ /** Initial PTY size. Defaults to the host terminal size. */
13
+ cols?: number;
14
+ rows?: number;
15
+ onData: (chunk: Uint8Array) => void;
16
+ onExit: (code: number | null) => void;
17
+ }
18
+
19
+ /** One agent TUI running inside a Bun PTY. */
20
+ export class AgentProcess {
21
+ private term: Bun.Terminal;
22
+ private proc: Bun.Subprocess;
23
+ private closed = false;
24
+ readonly startedAt = Date.now();
25
+
26
+ constructor(private opts: AgentProcessOptions) {
27
+ if (IS_WINDOWS) throw new Error("The --tui and --raw modes need a Unix terminal. On Windows, run codemax without them.");
28
+ const { cols, rows } = opts.cols && opts.rows ? { cols: opts.cols, rows: opts.rows } : termSize();
29
+ this.term = new Bun.Terminal({
30
+ cols,
31
+ rows,
32
+ data: (_t, chunk) => {
33
+ if (!this.closed) opts.onData(chunk);
34
+ },
35
+ });
36
+ const env: Record<string, string | undefined> = { ...process.env, TERM: process.env.TERM ?? "xterm-256color", CODEMAX: "1", ...(opts.env ?? {}) };
37
+ for (const k of opts.dropEnv ?? []) delete env[k];
38
+ // Never let an agent think it is a child of another agent session
39
+ // (e.g. codemax started from inside Claude Code).
40
+ for (const k of Object.keys(env)) if (NESTED_SESSION_ENV.test(k)) delete env[k];
41
+ this.proc = Bun.spawn(opts.cmd, { cwd: opts.cwd, terminal: this.term, env });
42
+ this.proc.exited.then((code) => {
43
+ this.closed = true;
44
+ try {
45
+ this.term.close();
46
+ } catch {
47
+ /* already closed */
48
+ }
49
+ opts.onExit(code);
50
+ });
51
+ }
52
+
53
+ get pid() {
54
+ return this.proc.pid;
55
+ }
56
+
57
+ get exited() {
58
+ return this.closed;
59
+ }
60
+
61
+ write(data: string | Uint8Array) {
62
+ if (this.closed) return;
63
+ this.term.write(data);
64
+ }
65
+
66
+ resize(cols: number, rows: number) {
67
+ if (this.closed) return;
68
+ try {
69
+ this.term.resize(cols, rows);
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ }
74
+
75
+ /** Force a full repaint: most TUIs redraw on SIGWINCH. */
76
+ jiggle() {
77
+ const { cols, rows } = termSize();
78
+ this.resize(Math.max(20, cols - 1), rows);
79
+ setTimeout(() => this.resize(cols, rows), 30);
80
+ }
81
+
82
+ async kill(graceMs = 1500): Promise<void> {
83
+ if (this.closed) return;
84
+ try {
85
+ this.proc.kill("SIGTERM");
86
+ } catch {
87
+ return;
88
+ }
89
+ const timer = setTimeout(() => {
90
+ try {
91
+ if (!this.closed) this.proc.kill("SIGKILL");
92
+ } catch {
93
+ /* ignore */
94
+ }
95
+ }, graceMs);
96
+ await this.proc.exited;
97
+ clearTimeout(timer);
98
+ }
99
+ }
@@ -0,0 +1,55 @@
1
+ import type { AgentId, QuotaStatus } from "../adapters/types.ts";
2
+ import { stripAnsi } from "./terminal.ts";
3
+
4
+ /**
5
+ * Vendor-neutral phrases. Deliberately narrow: "rate limit" or "429" alone
6
+ * show up constantly in normal coding work and must not trip the detector.
7
+ */
8
+ export const GENERIC_QUOTA_PATTERNS = [/usage limit (reached|exceeded)/i, /you['’]ve (hit|reached) your (usage |weekly |session |daily )?limit/i, /insufficient[_ ]quota/i];
9
+
10
+ /** How long an output-text detection counts before it is forgotten. */
11
+ export const OUTPUT_DETECTION_TTL_MS = 30 * 60_000;
12
+
13
+ export function isExhausted(q?: QuotaStatus, now = Date.now()): boolean {
14
+ if (!q?.exhausted) return false;
15
+ return !q.expiresAt || q.expiresAt > now;
16
+ }
17
+
18
+ /**
19
+ * Scans the ANSI-stripped PTY output of one agent for quota exhaustion text.
20
+ * Keeps a small rolling buffer so matches spanning chunks are still found.
21
+ */
22
+ export class OutputScanner {
23
+ private buf = "";
24
+ private lastHit = 0;
25
+ constructor(
26
+ private agent: AgentId,
27
+ private patterns: RegExp[],
28
+ private onHit: (q: QuotaStatus) => void,
29
+ private debounceMs = 60_000,
30
+ ) {}
31
+
32
+ feed(chunk: string) {
33
+ this.buf = (this.buf + stripAnsi(chunk)).slice(-4096);
34
+ if (Date.now() - this.lastHit < this.debounceMs) return;
35
+ for (const re of [...this.patterns, ...GENERIC_QUOTA_PATTERNS]) {
36
+ const m = this.buf.match(re);
37
+ if (m) {
38
+ this.lastHit = Date.now();
39
+ this.onHit({ agent: this.agent, exhausted: true, source: "output", detail: `text match: "${m[0].slice(0, 80)}"`, at: Date.now(), expiresAt: Date.now() + OUTPUT_DETECTION_TTL_MS });
40
+ this.buf = "";
41
+ return;
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ export function quotaLabel(q?: QuotaStatus): string {
48
+ if (!q) return "";
49
+ if (isExhausted(q)) {
50
+ const reset = q.resetsAt ? ` until ${new Date(q.resetsAt).toLocaleTimeString()}` : "";
51
+ return q.source === "output" ? `exhausted? (text match)${reset}` : `EXHAUSTED${reset}`;
52
+ }
53
+ if (typeof q.usedPercent === "number") return `${Math.round(q.usedPercent)}% used`;
54
+ return "";
55
+ }
@@ -0,0 +1,67 @@
1
+ import type { AgentId } from "../adapters/types.ts";
2
+ import type { Step, StepOf } from "./journal.ts";
3
+ import { formatStep } from "./brief.ts";
4
+
5
+ /**
6
+ * The side agent: a second agent (any harness) that reviews the main agent's
7
+ * work after each turn and speaks up only when something deserves the user's
8
+ * attention: drift from the request or plan, claims without evidence, risky
9
+ * assumptions, missing tests. Its notes can be forwarded to the main agent so
10
+ * it stays aligned with the plan, whichever harness it runs on.
11
+ */
12
+ export const NO_NOTE = "NO_NOTE";
13
+
14
+ export function reviewInstructions(mainAgent: string): string {
15
+ return [
16
+ `You are a side agent reviewing the work of another coding agent (${mainAgent}) for the user. You do not do the work yourself.`,
17
+ "Do not use any tools and do not change any files. Read only what is below.",
18
+ "Check the latest work against the user's request and the plan. Look for: drift from what the user asked; conclusions or claims the steps do not actually support; decisions resting on thin evidence (one run, one sample, a check that barely passed); risky assumptions; tests or edge cases that were skipped; work that will be wasted if an untested assumption is wrong.",
19
+ `If nothing is worth interrupting the user for, reply with exactly ${NO_NOTE} and nothing else. Most turns deserve ${NO_NOTE}; do not nitpick style.`,
20
+ "Otherwise reply with one note in exactly this shape, under 130 words, plain language, concrete facts from the work:",
21
+ "",
22
+ "Heads up · <one sentence: what could go wrong>",
23
+ "",
24
+ "**<short title>**",
25
+ "- <fact from the work>",
26
+ "- <why it matters for the user's goal>",
27
+ "- **Consider <one concrete, cheap action>.**",
28
+ ].join("\n");
29
+ }
30
+
31
+ /** What the reviewer sees: the request, the plan, and the main agent's steps since its last review. */
32
+ export function buildReviewPrompt(o: { steps: Step[]; since: number; mainAgent: string; reviewer: AgentId; context?: string }): string | null {
33
+ // The reviewer's own turns are never journaled, so every step here is the main agent's,
34
+ // even when both run on the same harness (Claude reviewing Claude).
35
+ const recent = o.steps.filter((s) => s.ts > o.since && s.kind !== "handoff" && s.kind !== "thought");
36
+ const work = recent.filter((s) => s.kind === "tool" || s.kind === "text" || s.kind === "plan");
37
+ if (!work.length) return null;
38
+ const users = o.steps.filter((s): s is StepOf<"user"> => s.kind === "user");
39
+ const request = users.at(-1);
40
+ const plan = [...o.steps].reverse().find((s): s is StepOf<"plan"> => s.kind === "plan");
41
+ const out: string[] = [reviewInstructions(o.mainAgent), ""];
42
+ if (o.context) out.push("## Context (the task so far, recorded by codemax)", o.context, "");
43
+ if (request) out.push("## The user's latest request", request.text.trim().split("\n").map((l) => `> ${l}`).join("\n"), "");
44
+ if (plan) out.push("## Plan", ...plan.entries.map((e) => `${e.status === "completed" ? "[x]" : e.status === "in_progress" ? "[~]" : "[ ]"} ${e.content}`), "");
45
+ out.push("## Latest work to review (oldest first)", ...recent.map((s) => formatStep(s)).filter((l): l is string => !!l));
46
+ return out.join("\n");
47
+ }
48
+
49
+ /** The note, or null when the reviewer had nothing to say. */
50
+ export function parseNote(reply: string): string | null {
51
+ const t = reply.trim();
52
+ if (!t || t.includes(NO_NOTE) || t.length < 40) return null;
53
+ const at = t.indexOf("Heads up");
54
+ return (at > 0 ? t.slice(at) : t).trim();
55
+ }
56
+
57
+ /** Forwarded notes start with this; they are follow-ups, never a new task, and never reviewed. */
58
+ export const NOTE_PREFIX = "A side agent (";
59
+
60
+ export function isForwardedNote(text: string): boolean {
61
+ return text.startsWith(NOTE_PREFIX);
62
+ }
63
+
64
+ /** The message that hands a note to the main agent. */
65
+ export function forwardMessage(note: string, reviewerName: string): string {
66
+ return `${NOTE_PREFIX}${reviewerName}) reviewing your work left this note. Weigh it against the user's request and plan: say in a sentence whether you agree, and adjust your work if it is right.\n\n${note}`;
67
+ }
@@ -0,0 +1,20 @@
1
+ /*
2
+ * Cross-platform process arguments. On macOS and Linux these return argv
3
+ * unchanged. On Windows, CLIs installed through npm are `.cmd` shims that
4
+ * cannot be started directly, so they are resolved through PATH/PATHEXT and
5
+ * run via cmd.exe; shell commands go to cmd.exe instead of sh.
6
+ */
7
+ export const IS_WINDOWS = process.platform === "win32";
8
+
9
+ /** argv ready for Bun.spawn on this platform. */
10
+ export function platformArgv(argv: string[]): string[] {
11
+ if (!IS_WINDOWS || argv.length === 0) return argv;
12
+ const [bin, ...rest] = argv as [string, ...string[]];
13
+ const path = Bun.which(bin) ?? bin;
14
+ return /\.(cmd|bat)$/i.test(path) ? ["cmd.exe", "/d", "/s", "/c", path, ...rest] : [path, ...rest];
15
+ }
16
+
17
+ /** argv that runs one shell command line (`!cmd` in the chat). */
18
+ export function shellArgv(cmd: string): string[] {
19
+ return IS_WINDOWS ? ["cmd.exe", "/d", "/s", "/c", cmd] : ["sh", "-c", cmd];
20
+ }
@@ -0,0 +1,51 @@
1
+ export const ESC = "\x1b";
2
+ export const CSI = `${ESC}[`;
3
+
4
+ /**
5
+ * Put the host terminal back into a sane state after an agent TUI dies.
6
+ * Agents killed mid-run do not restore anything, so undo every mode a modern
7
+ * TUI may have switched on: alt screen, mouse, bracketed paste, focus events,
8
+ * synchronized output, the kitty keyboard protocol and xterm modifyOtherKeys.
9
+ */
10
+ export const RESET_SEQ = [
11
+ `${CSI}?2004l`, // bracketed paste off
12
+ `${CSI}?1000l${CSI}?1002l${CSI}?1003l${CSI}?1005l${CSI}?1006l${CSI}?1015l`, // mouse tracking off
13
+ `${CSI}?1004l`, // focus events off
14
+ `${CSI}?2026l`, // synchronized output off
15
+ `${CSI}<u${CSI}<u${CSI}<u${CSI}<u`, // pop kitty keyboard flags (no-op where unsupported)
16
+ `${CSI}=0;1u`, // kitty keyboard flags = 0
17
+ `${CSI}>4;0m`, // xterm modifyOtherKeys off
18
+ `${CSI}?1l`, // normal cursor keys
19
+ `${ESC}>`, // numeric keypad
20
+ `${CSI}?1049l`, // leave alt screen
21
+ `${CSI}!p`, // DECSTR soft reset (scroll region, origin mode, SGR, cursor visible)
22
+ `${CSI}r`, // scroll region = full screen
23
+ `${CSI}?25h`, // show cursor
24
+ `${CSI}?7h`, // autowrap on
25
+ `${CSI}0m`, // reset attrs
26
+ ].join("");
27
+
28
+ /** Clear the screen and home the cursor; used before a new agent draws. */
29
+ export const CLEAR_SEQ = `${CSI}2J${CSI}H`;
30
+
31
+ export const ALT_ON = `${CSI}?1049h${CSI}?25l${CSI}H${CSI}2J`;
32
+ export const ALT_OFF = `${CSI}?25h${CSI}?1049l`;
33
+
34
+ export const PASTE_START = `${CSI}200~`;
35
+ export const PASTE_END = `${CSI}201~`;
36
+
37
+ export function stripAnsi(s: string): string {
38
+ // CSI, OSC, and single-char escapes.
39
+ return s
40
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "")
41
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
42
+ .replace(/\x1b[@-Z\\-_]/g, "");
43
+ }
44
+
45
+ export function termSize(): { cols: number; rows: number } {
46
+ return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 };
47
+ }
48
+
49
+ export const out = (s: string) => {
50
+ process.stdout.write(s);
51
+ };
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { CODEMAX_HOME, ensureDir } from "./paths.ts";
4
+
5
+ /**
6
+ * Update checks and self-update. At most every 12 hours (3 s timeout, never
7
+ * blocking startup) codemax asks for the newest version from the place its own
8
+ * update comes from: npm for npm installs, GitHub releases for the installer,
9
+ * source checkouts and standalone builds. So a release is only offered where
10
+ * `/update` can actually fetch it. `/update` / `codemax update` then updates
11
+ * the way codemax was installed.
12
+ */
13
+ const REGISTRY = "https://registry.npmjs.org/codemax-cli/latest";
14
+ const RELEASES = "https://api.github.com/repos/SkidGod4444/codemax-cli/releases/latest";
15
+ const CHECK_EVERY_MS = 12 * 60 * 60 * 1000;
16
+ const CACHE = () => join(CODEMAX_HOME, "update-check.json");
17
+
18
+ export type InstallKind = "npm" | "script" | "source" | "binary";
19
+
20
+ /** How this codemax was installed, judged from where its code lives. */
21
+ export function installKind(root = resolve(import.meta.dir, "..", "..")): { kind: InstallKind; root: string } {
22
+ if (root.includes("$bunfs") || root.startsWith("/$bunfs") || root.startsWith("B:\\~BUN")) return { kind: "binary", root };
23
+ if (root.includes(`${sep}node_modules${sep}codemax-cli`) || root.includes("/node_modules/codemax-cli")) return { kind: "npm", root };
24
+ if (existsSync(join(root, ".git")) && /[\\/]\.codemax[\\/]cli$/.test(root)) return { kind: "script", root };
25
+ if (existsSync(join(root, ".git"))) return { kind: "source", root };
26
+ return { kind: "npm", root };
27
+ }
28
+
29
+ /** a > b for x.y.z versions (pre-release suffixes ignored). */
30
+ export function isNewer(a: string, b: string): boolean {
31
+ const p = (v: string) => v.replace(/^v/, "").split("-")[0]!.split(".").map((n) => Number(n) || 0);
32
+ const [x, y] = [p(a), p(b)];
33
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
34
+ return false;
35
+ }
36
+
37
+ export async function latestVersion(source: "npm" | "github" = "npm", fetchImpl: typeof fetch = fetch, timeoutMs = 3000): Promise<string | null> {
38
+ try {
39
+ const res = await fetchImpl(source === "npm" ? REGISTRY : RELEASES, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: source === "npm" ? "application/json" : "application/vnd.github+json" } });
40
+ if (!res.ok) return null;
41
+ const body = (await res.json()) as { version?: unknown; tag_name?: unknown };
42
+ const v = String((source === "npm" ? body.version : body.tag_name) ?? "").replace(/^v/, "");
43
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** Where this install's updates come from. */
50
+ export function updateSource(kind: InstallKind = installKind().kind): "npm" | "github" {
51
+ return kind === "npm" ? "npm" : "github";
52
+ }
53
+
54
+ /** The latest version when it is newer than `current`; null otherwise. Cached for 12 hours. */
55
+ export async function checkForUpdate(current: string, o: { force?: boolean; fetchImpl?: typeof fetch; source?: "npm" | "github" } = {}): Promise<string | null> {
56
+ const source = o.source ?? updateSource();
57
+ let cached: { at: number; latest: string | null; source?: string } | null = null;
58
+ try {
59
+ cached = existsSync(CACHE()) ? JSON.parse(readFileSync(CACHE(), "utf8")) : null;
60
+ } catch {
61
+ cached = null;
62
+ }
63
+ let latest = cached?.latest ?? null;
64
+ if (o.force || !cached || cached.source !== source || Date.now() - cached.at > CHECK_EVERY_MS) {
65
+ latest = await latestVersion(source, o.fetchImpl);
66
+ try {
67
+ ensureDir(CODEMAX_HOME);
68
+ writeFileSync(CACHE(), JSON.stringify({ at: Date.now(), latest, source }) + "\n");
69
+ } catch {
70
+ /* read-only home: check again next time */
71
+ }
72
+ }
73
+ return latest && isNewer(latest, current) ? latest : null;
74
+ }
75
+
76
+ /** The command that updates this install, or null with an explanation when it cannot update itself. */
77
+ export function updatePlan(install = installKind(), platform = process.platform): { cmd: string[][]; env?: Record<string, string>; manual?: string; label: string } {
78
+ switch (install.kind) {
79
+ case "npm":
80
+ return { cmd: [["npm", "install", "-g", "codemax-cli@latest"]], label: "npm install -g codemax-cli@latest" };
81
+ case "script":
82
+ return platform === "win32"
83
+ ? { cmd: [["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(install.root, "install.ps1")]], env: { CODEMAX_DIR: install.root }, label: "the codemax installer (newest release)" }
84
+ : { cmd: [["bash", join(install.root, "install.sh")]], env: { CODEMAX_DIR: install.root }, label: "the codemax installer (newest release)" };
85
+ case "source":
86
+ return { cmd: [["git", "-C", install.root, "pull", "--ff-only"], ["bun", "install", "--cwd", install.root]], label: `git pull in ${install.root}` };
87
+ case "binary":
88
+ return { cmd: [], manual: "This is a standalone build; download the new release from https://github.com/SkidGod4444/codemax-cli/releases or switch to `npm install -g codemax-cli`.", label: "manual download" };
89
+ }
90
+ }
@@ -0,0 +1,116 @@
1
+ import type { AgentId } from "../adapters/types.ts";
2
+ import { estimateCost, fmtTokens, fmtUsd, priceFor, priceProvenance } from "./pricing.ts";
3
+
4
+ /** One usage sample: a model call, a turn, or a delta reported by the agent. */
5
+ export interface UsageRecord {
6
+ ts: number;
7
+ agent: AgentId;
8
+ sessionId: string;
9
+ model?: string;
10
+ input: number;
11
+ output: number;
12
+ cacheRead: number;
13
+ cacheWrite: number;
14
+ /** Cost the agent itself reported (OpenCode, Grok do). */
15
+ agentCostUsd?: number;
16
+ /** USD frozen at record time so later price changes do not rewrite history. */
17
+ costUsd?: number;
18
+ /** Where that figure came from: "agent", "user", "openrouter@2026-10-18", "builtin@…", or "none". */
19
+ priceSource?: string;
20
+ }
21
+
22
+ /** Price a fresh record now and freeze the result on it. */
23
+ export function priceRecord(r: UsageRecord): UsageRecord {
24
+ if (typeof r.agentCostUsd === "number") return { ...r, costUsd: r.agentCostUsd, priceSource: "agent" };
25
+ const hit = priceFor(r.model);
26
+ const est = estimateCost(r.model, r);
27
+ if (hit && est !== null) {
28
+ const prov = priceProvenance();
29
+ return { ...r, costUsd: est, priceSource: hit.source === "user" ? "user" : hit.source === "builtin" ? `builtin@${prov.source === "builtin" ? prov.asOf : "2026-10-18"}` : `${hit.source}@${prov.asOf}` };
30
+ }
31
+ return { ...r, costUsd: undefined, priceSource: "none" };
32
+ }
33
+
34
+ export interface UsageTotals {
35
+ agent: AgentId;
36
+ models: Set<string>;
37
+ calls: number;
38
+ input: number;
39
+ output: number;
40
+ cacheRead: number;
41
+ cacheWrite: number;
42
+ /** API-equivalent USD: agent-reported where available, else estimated from list prices. */
43
+ costUsd: number;
44
+ /** True when some records had no price and were counted at $0. */
45
+ partial: boolean;
46
+ }
47
+
48
+ export function recordCost(r: UsageRecord): { usd: number; known: boolean } {
49
+ if (typeof r.agentCostUsd === "number") return { usd: r.agentCostUsd, known: true };
50
+ if (typeof r.costUsd === "number") return { usd: r.costUsd, known: true };
51
+ if (r.priceSource === "none") return { usd: 0, known: false };
52
+ const est = estimateCost(r.model, r); // legacy records without a frozen cost
53
+ return est === null ? { usd: 0, known: !(r.input || r.output || r.cacheRead || r.cacheWrite) } : { usd: est, known: true };
54
+ }
55
+
56
+ export function totalsByAgent(records: UsageRecord[]): UsageTotals[] {
57
+ const map = new Map<AgentId, UsageTotals>();
58
+ for (const r of records) {
59
+ let t = map.get(r.agent);
60
+ if (!t) {
61
+ t = { agent: r.agent, models: new Set(), calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0, partial: false };
62
+ map.set(r.agent, t);
63
+ }
64
+ if (r.model) t.models.add(r.model);
65
+ t.calls++;
66
+ t.input += r.input;
67
+ t.output += r.output;
68
+ t.cacheRead += r.cacheRead;
69
+ t.cacheWrite += r.cacheWrite;
70
+ const c = recordCost(r);
71
+ t.costUsd += c.usd;
72
+ if (!c.known) t.partial = true;
73
+ }
74
+ return [...map.values()].sort((a, b) => b.costUsd - a.costUsd);
75
+ }
76
+
77
+ export function sumCost(records: UsageRecord[]): number {
78
+ return records.reduce((s, r) => s + recordCost(r).usd, 0);
79
+ }
80
+
81
+ /** Plain-text table for /usage and `codemax usage`. */
82
+ export function renderUsageTable(records: UsageRecord[], opts: { title: string; sessionId?: string }): string[] {
83
+ const rows = totalsByAgent(records);
84
+ const lines: string[] = [];
85
+ lines.push(`${opts.title} · API-equivalent value at list prices (your subscriptions covered this)`);
86
+ if (!rows.length) {
87
+ lines.push(" no usage recorded yet");
88
+ return lines;
89
+ }
90
+ const head = ["agent", "calls", "input", "output", "cache read", "cache write", "est. cost"];
91
+ const data = rows.map((t) => [
92
+ t.agent + (t.models.size ? ` (${[...t.models].map(shortModel).join(", ")})` : ""),
93
+ String(t.calls),
94
+ fmtTokens(t.input),
95
+ fmtTokens(t.output),
96
+ fmtTokens(t.cacheRead),
97
+ fmtTokens(t.cacheWrite),
98
+ fmtUsd(t.costUsd) + (t.partial ? "*" : ""),
99
+ ]);
100
+ const total = ["total", String(rows.reduce((s, t) => s + t.calls, 0)), fmtTokens(rows.reduce((s, t) => s + t.input, 0)), fmtTokens(rows.reduce((s, t) => s + t.output, 0)), fmtTokens(rows.reduce((s, t) => s + t.cacheRead, 0)), fmtTokens(rows.reduce((s, t) => s + t.cacheWrite, 0)), fmtUsd(rows.reduce((s, t) => s + t.costUsd, 0))];
101
+ const all = [head, ...data, total];
102
+ const w = head.map((_, i) => Math.max(...all.map((r) => r[i]!.length)));
103
+ const fmt = (r: string[]) => " " + r.map((c, i) => (i === 0 ? c.padEnd(w[i]!) : c.padStart(w[i]!))).join(" ");
104
+ lines.push(fmt(head));
105
+ for (const r of data) lines.push(fmt(r));
106
+ lines.push(fmt(total));
107
+ if (rows.some((t) => t.partial)) lines.push(" * includes calls on a model with no known price; set it in ~/.codemax/prices.json");
108
+ const prov = priceProvenance();
109
+ const agentPriced = records.filter((r) => typeof r.agentCostUsd === "number").length;
110
+ lines.push(` prices: ${prov.source} list as of ${prov.asOf}${agentPriced ? ` · ${agentPriced} calls priced by the agent itself` : ""} · each call keeps the rate it was recorded at`);
111
+ return lines;
112
+ }
113
+
114
+ export function shortModel(m: string): string {
115
+ return m.replace(/^claude-/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
116
+ }