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,342 @@
1
+ import { BoxRenderable, ScrollBoxRenderable, StyledText, TextRenderable, fg, italic, strikethrough, stringToStyledText, type CliRenderer, type TextChunk } from "@opentui/core";
2
+ import { C, G } from "./theme.ts";
3
+ import { styleMarkdown, styleToolOutput } from "./markdown.ts";
4
+
5
+ export interface PlanEntry {
6
+ content: string;
7
+ status: string;
8
+ }
9
+
10
+ export interface AgentBlock {
11
+ /** Stream assistant text. A new `messageId` (or a tool row in between) starts a new `⏺` paragraph. */
12
+ append(text: string, messageId?: string): void;
13
+ /** Stream reasoning into a collapsed, dim "Thinking" block. */
14
+ thought(text: string, messageId?: string): void;
15
+ /** Show or update the agent's plan as a checklist. */
16
+ plan(entries: PlanEntry[]): void;
17
+ tool(id: string, title: string, status: string, detail?: string): void;
18
+ toolUpdate(id: string, status?: string, title?: string, detail?: string): void;
19
+ /** Closing dim line, e.g. "✻ Worked for 13s · done 7:25 PM". */
20
+ footer(text: string): void;
21
+ /** The reply as plain text: paragraphs joined by blank lines. */
22
+ text(): string;
23
+ toolCount(): number;
24
+ }
25
+
26
+ /** Something that can be shown cut down or in full (Ctrl+O toggles all of them). */
27
+ interface Collapsible {
28
+ node: TextRenderable;
29
+ full: string;
30
+ render: (text: string, expanded: boolean) => string | StyledText;
31
+ }
32
+
33
+ /**
34
+ * Conversation view in Claude Code's idiom: `❯ prompt`, `⏺ text`, `⏺ Tool(args)`
35
+ * with ` ⎿ result` nested beneath, dim notes, and a divider on agent switches.
36
+ * Every line is a row box: a fixed glyph column and a wrapping text column, so
37
+ * wrapped continuation lines stay aligned under the text.
38
+ */
39
+ export class Transcript {
40
+ readonly box: ScrollBoxRenderable;
41
+ private n = 0;
42
+ private expanded = false;
43
+ private collapsibles: Collapsible[] = [];
44
+
45
+ constructor(private renderer: CliRenderer) {
46
+ this.box = new ScrollBoxRenderable(renderer, {
47
+ id: "transcript",
48
+ width: "100%",
49
+ flexGrow: 1,
50
+ flexShrink: 1,
51
+ stickyScroll: true,
52
+ stickyStart: "bottom",
53
+ scrollY: true,
54
+ scrollX: false,
55
+ contentOptions: { flexDirection: "column", paddingX: 1 },
56
+ verticalScrollbarOptions: { width: 0, showArrows: false },
57
+ });
58
+ }
59
+
60
+ get isExpanded() {
61
+ return this.expanded;
62
+ }
63
+
64
+ /** Ctrl+O: show every tool result and thinking block in full, or cut them back down. */
65
+ toggleExpanded(): boolean {
66
+ this.expanded = !this.expanded;
67
+ for (const c of this.collapsibles) c.node.content = c.render(c.full, this.expanded);
68
+ this.scrollToEnd();
69
+ return this.expanded;
70
+ }
71
+
72
+ /** Startup card: brand mark, what codemax is, the project, and any notes. */
73
+ header(lines: { title: string; subtitle: string; path: string; notes: string[]; logo?: string[] }) {
74
+ if (this.gone) return;
75
+ const card = new BoxRenderable(this.renderer, {
76
+ id: `header-${++this.n}`,
77
+ width: "100%",
78
+ flexDirection: "column",
79
+ border: true,
80
+ borderStyle: "rounded",
81
+ borderColor: C.accent,
82
+ paddingX: 1,
83
+ marginTop: 1,
84
+ });
85
+ if (lines.logo?.length) {
86
+ // The wordmark: one line per two rows of the website's halftone grid, never wrapped.
87
+ for (const [i, l] of lines.logo.entries()) card.add(new TextRenderable(this.renderer, { id: `h-l-${this.n}-${i}`, content: l, fg: C.accent, wrapMode: "none" } as any));
88
+ card.add(new TextRenderable(this.renderer, { id: `h-s-${this.n}`, content: `${lines.title} · ${lines.subtitle}`, fg: C.dim }));
89
+ } else {
90
+ const title = new StyledText([fg(C.accent)("✻ "), fg(C.accent)(lines.title)]);
91
+ card.add(new TextRenderable(this.renderer, { id: `h-t-${this.n}`, content: title, attributes: 1 } as any));
92
+ card.add(new TextRenderable(this.renderer, { id: `h-s-${this.n}`, content: lines.subtitle, fg: C.dim }));
93
+ }
94
+ card.add(new TextRenderable(this.renderer, { id: `h-gap-${this.n}`, content: "", height: 1 }));
95
+ card.add(new TextRenderable(this.renderer, { id: `h-p-${this.n}`, content: `cwd: ${lines.path}`, fg: C.dim, wrapMode: "char" } as any));
96
+ for (const [i, note] of lines.notes.entries()) card.add(new TextRenderable(this.renderer, { id: `h-n-${this.n}-${i}`, content: note, fg: C.dim, wrapMode: "word" } as any));
97
+ this.box.add(card);
98
+ }
99
+
100
+ /** A side agent's note: a bordered card that stands apart from the conversation. */
101
+ noteCard(title: string, body: string, hint: string) {
102
+ if (this.gone) return;
103
+ this.gap();
104
+ const card = new BoxRenderable(this.renderer, { id: `note-${++this.n}`, width: "100%", flexDirection: "column", border: true, borderStyle: "rounded", borderColor: C.warn, paddingX: 1 });
105
+ card.add(new TextRenderable(this.renderer, { id: `nt-${this.n}`, content: title, fg: C.warn, attributes: 1 } as any));
106
+ card.add(new TextRenderable(this.renderer, { id: `nb-${this.n}`, content: styleMarkdown(body), wrapMode: "word", width: "100%" } as any));
107
+ card.add(new TextRenderable(this.renderer, { id: `nh-${this.n}`, content: hint, fg: C.dim, wrapMode: "word" } as any));
108
+ this.box.add(card);
109
+ this.scrollToEnd();
110
+ }
111
+
112
+ user(text: string) {
113
+ this.gap();
114
+ this.line(`${G.prompt} `, text, { fg: undefined, glyphFg: C.accent });
115
+ }
116
+
117
+ note(text: string, fg_: string = C.dim) {
118
+ this.nested(text, fg_);
119
+ }
120
+
121
+ error(text: string) {
122
+ this.nested(text, C.error);
123
+ }
124
+
125
+ divider(text: string) {
126
+ this.gap();
127
+ const w = Math.max(20, this.renderer.width - 4);
128
+ const label = ` ${text} `;
129
+ const side = Math.max(3, Math.floor((w - label.length) / 2));
130
+ const content = new StyledText([fg(C.dimmer)(G.rule.repeat(side)), fg(C.accent)(label), fg(C.dimmer)(G.rule.repeat(side))]);
131
+ this.line("", "", {}).content = content;
132
+ this.gap();
133
+ }
134
+
135
+ agent(): AgentBlock {
136
+ const self = this;
137
+ let para: { node: TextRenderable; text: string; id?: string } | null = null;
138
+ let thinking: { node: TextRenderable; c: Collapsible; id?: string } | null = null;
139
+ let planNode: TextRenderable | null = null;
140
+ let styleTimer: ReturnType<typeof setTimeout> | null = null;
141
+ const tools = new Map<string, { glyph: TextRenderable; title: TextRenderable; result?: Collapsible; name: string; status: string }>();
142
+ const paragraphs: string[] = [];
143
+
144
+ const settle = () => {
145
+ if (para) {
146
+ if (styleTimer) clearTimeout(styleTimer);
147
+ styleTimer = null;
148
+ para.node.content = styleMarkdown(para.text);
149
+ }
150
+ };
151
+ const toolResult = (detail: string, failed: boolean): Collapsible => {
152
+ const c: Collapsible = {
153
+ node: null as unknown as TextRenderable,
154
+ full: detail,
155
+ render: (t, expanded) => styleToolOutput(expanded ? t.replace(/\s+$/, "") : summarize(t)),
156
+ };
157
+ c.node = self.nestedNode("", failed ? C.error : C.dim);
158
+ c.node.content = c.render(detail, self.expanded);
159
+ self.collapsibles.push(c);
160
+ return c;
161
+ };
162
+
163
+ return {
164
+ append(t, messageId) {
165
+ if (para && messageId && para.id && messageId !== para.id) {
166
+ settle();
167
+ para = null;
168
+ }
169
+ thinking = null;
170
+ if (!para) {
171
+ const start = t.replace(/^\n+/, "");
172
+ const node = self.line(`${G.bullet} `, start, { glyphFg: C.assistant });
173
+ para = { node, text: start, id: messageId };
174
+ paragraphs.push(start);
175
+ } else {
176
+ para.text += t;
177
+ paragraphs[paragraphs.length - 1] = para.text;
178
+ }
179
+ const p = para;
180
+ // Restyle at most every 80ms while streaming; the final append is always styled.
181
+ if (!styleTimer) styleTimer = setTimeout(() => { styleTimer = null; p.node.content = styleMarkdown(p.text); }, 80);
182
+ self.scrollToEnd();
183
+ },
184
+ thought(t, messageId) {
185
+ if (thinking && messageId && thinking.id && messageId !== thinking.id) thinking = null;
186
+ if (!thinking) {
187
+ settle();
188
+ para = null;
189
+ const c: Collapsible = {
190
+ node: null as unknown as TextRenderable,
191
+ full: "",
192
+ render: (text, expanded) => {
193
+ const body = text.trim();
194
+ const shown = expanded ? body : clip(body, 2, 160);
195
+ return new StyledText([fg(C.thought)(italic(`Thinking${shown ? "… " : "…"}`)), fg(C.thought)(italic(shown))]);
196
+ },
197
+ };
198
+ c.node = self.line(`${G.thought} `, "", { glyphFg: C.thought });
199
+ self.collapsibles.push(c);
200
+ thinking = { node: c.node, c, id: messageId };
201
+ }
202
+ thinking.c.full += t;
203
+ thinking.node.content = thinking.c.render(thinking.c.full, self.expanded);
204
+ self.scrollToEnd();
205
+ },
206
+ plan(entries) {
207
+ if (!entries.length) return;
208
+ settle();
209
+ para = null;
210
+ const chunks: TextChunk[] = [fg(C.assistant)("Plan")];
211
+ for (const e of entries) {
212
+ const done = e.status === "completed";
213
+ const doing = e.status === "in_progress";
214
+ const box = done ? G.done_ : doing ? G.doing : G.todo;
215
+ // A line break inside a coloured chunk is not laid out; it must be its own plain chunk.
216
+ chunks.push(...stringToStyledText("\n").chunks, fg(C.dimmer)(`${G.nest} `));
217
+ if (done) chunks.push(fg(C.dim)(`${box} `), fg(C.dim)(strikethrough(e.content)));
218
+ else if (doing) chunks.push(fg(C.accent)(`${box} ${e.content}`));
219
+ else chunks.push(fg(C.text ?? C.dim)(`${box} ${e.content}`));
220
+ }
221
+ const content = new StyledText(chunks);
222
+ // One live checklist per turn: later updates redraw it in place.
223
+ if (planNode) planNode.content = content;
224
+ else {
225
+ planNode = self.line(`${G.bullet} `, "", { glyphFg: C.tool });
226
+ planNode.content = content;
227
+ }
228
+ self.scrollToEnd();
229
+ },
230
+ tool(id, title, status, detail) {
231
+ settle();
232
+ para = null;
233
+ thinking = null;
234
+ if (self.gone) return;
235
+ const glyph = new TextRenderable(self.renderer, { id: `tg-${++self.n}`, content: `${G.bullet} `, width: 2, fg: toolColor(status), wrapMode: "none" });
236
+ const title_ = self.lineWithGlyph(glyph, title);
237
+ const entry = { glyph, title: title_, name: title, status, result: undefined as Collapsible | undefined };
238
+ tools.set(id, entry);
239
+ if (detail) entry.result = toolResult(detail, status === "failed");
240
+ self.scrollToEnd();
241
+ },
242
+ toolUpdate(id, status, title, detail) {
243
+ const e = tools.get(id);
244
+ if (!e) return;
245
+ if (title) {
246
+ e.name = title;
247
+ e.title.content = title;
248
+ }
249
+ if (status) {
250
+ e.status = status;
251
+ e.glyph.fg = toolColor(status);
252
+ }
253
+ if (status === "failed") e.title.fg = C.error;
254
+ if (detail) {
255
+ if (e.result) {
256
+ e.result.full = detail;
257
+ e.result.node.content = e.result.render(detail, self.expanded);
258
+ } else e.result = toolResult(detail, status === "failed");
259
+ }
260
+ },
261
+ footer(text) {
262
+ settle();
263
+ para = null;
264
+ thinking = null;
265
+ self.line(`${G.done} `, text, { fg: C.dim, glyphFg: C.dim });
266
+ self.scrollToEnd();
267
+ },
268
+ text: () => paragraphs.map((p) => p.trim()).filter(Boolean).join("\n\n"),
269
+ toolCount: () => tools.size,
270
+ };
271
+ }
272
+
273
+ // ---------- primitives ----------
274
+
275
+ /** After quit the native scene is gone; late async writes (a finishing handoff) are dropped. */
276
+ private get gone() {
277
+ return (this.renderer as { isDestroyed?: boolean }).isDestroyed === true;
278
+ }
279
+
280
+ private gap() {
281
+ if (this.gone) return;
282
+ this.box.add(new TextRenderable(this.renderer, { id: `gap-${++this.n}`, content: "", height: 1 }));
283
+ }
284
+
285
+ private line(glyph: string, text: string, o: { fg?: string; glyphFg?: string; bold?: boolean } = {}): TextRenderable {
286
+ if (this.gone) return { content: "" } as unknown as TextRenderable;
287
+ const g = glyph ? new TextRenderable(this.renderer, { id: `g-${++this.n}`, content: glyph, width: glyph.length, fg: o.glyphFg ?? o.fg, wrapMode: "none" }) : null;
288
+ return this.lineWithGlyph(g, text, o);
289
+ }
290
+
291
+ private lineWithGlyph(glyph: TextRenderable | null, text: string, o: { fg?: string; bold?: boolean } = {}): TextRenderable {
292
+ if (this.gone) return { content: "" } as unknown as TextRenderable;
293
+ const row = new BoxRenderable(this.renderer, { id: `row-${++this.n}`, width: "100%", flexDirection: "row" });
294
+ if (glyph) row.add(glyph);
295
+ const body = new TextRenderable(this.renderer, { id: `t-${this.n}`, content: text, fg: o.fg, flexGrow: 1, flexShrink: 1, wrapMode: "word", attributes: o.bold ? 1 : undefined } as any);
296
+ row.add(body);
297
+ this.box.add(row);
298
+ return body;
299
+ }
300
+
301
+ private nested(text: string, fg_: string) {
302
+ this.nestedNode(text, fg_);
303
+ this.scrollToEnd();
304
+ }
305
+
306
+ private nestedNode(text: string, fg_: string, toolOutput = false): TextRenderable {
307
+ const node = this.line(` ${G.nest} `, text, { fg: fg_, glyphFg: C.dimmer });
308
+ if (toolOutput) node.content = styleToolOutput(text);
309
+ return node;
310
+ }
311
+
312
+ scrollToEnd() {
313
+ queueMicrotask(() => {
314
+ try {
315
+ this.box.scrollTo({ x: 0, y: this.box.scrollHeight });
316
+ } catch {
317
+ /* not laid out yet */
318
+ }
319
+ });
320
+ }
321
+ }
322
+
323
+ function toolColor(status: string): string {
324
+ if (status === "completed") return C.ok;
325
+ if (status === "failed") return C.error;
326
+ return C.dim;
327
+ }
328
+
329
+ /** First `lines` lines of text, each clipped, with an ellipsis when anything was cut. */
330
+ function clip(text: string, lines: number, width: number): string {
331
+ const all = text.split("\n").filter((l) => l.trim());
332
+ const kept = all.slice(0, lines).map((l) => (l.length > width ? l.slice(0, width - 1) + "…" : l));
333
+ const cut = all.length > lines || kept.some((l, i) => l !== all[i]);
334
+ return kept.join(" ") + (cut ? " (ctrl+o)" : "");
335
+ }
336
+
337
+ /** Tool output: first lines, then "… +N lines" like Claude Code's collapsed results. */
338
+ export function summarize(detail: string, keep = 3): string {
339
+ const lines = detail.replace(/\s+$/, "").split("\n");
340
+ if (lines.length <= keep) return lines.join("\n");
341
+ return lines.slice(0, keep).join("\n") + `\n… +${lines.length - keep} lines (ctrl+o to expand)`;
342
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,254 @@
1
+ import { loadConfig, CONFIG_PATH, hotkeyLabel, saveConfigKey } from "./config.ts";
2
+ import { defaultAwakeDeps, recoverAwake } from "./core/awake.ts";
3
+ import { checkForUpdate, installKind, updatePlan } from "./core/update.ts";
4
+ import { platformArgv } from "./core/spawn.ts";
5
+ import pkg from "../package.json" with { type: "json" };
6
+ import { canonicalCwd } from "./core/paths.ts";
7
+ import { Ledger } from "./core/ledger.ts";
8
+ import { Orchestrator } from "./core/orchestrator.ts";
9
+ import { runApp } from "./tui/app.ts";
10
+ import { runChat } from "./chat/app.ts";
11
+ import { hasEngine } from "./engines/index.ts";
12
+ import { listAdapters, getAdapter } from "./adapters/index.ts";
13
+ import { buildBrief } from "./core/brief.ts";
14
+ import { quotaLabel } from "./core/quota.ts";
15
+ import type { AgentId } from "./adapters/types.ts";
16
+ import { describeBytes, findHotkey, parseHotkeys } from "./core/hotkey.ts";
17
+ import { diagnose } from "./core/doctor.ts";
18
+ import { renderUsageTable, sumCost } from "./core/usage.ts";
19
+ import { fmtUsd, priceFor, priceProvenance, resetPriceCache } from "./core/pricing.ts";
20
+ import { fetchLivePrices, loadLivePrices, LIVE_PRICES_PATH } from "./core/prices-live.ts";
21
+
22
+ const HELP = `codemax — one terminal for all your coding agents
23
+
24
+ usage:
25
+ codemax [start] [--agent <id>] [--fresh] codemax chat UI driving the agent over its protocol (default)
26
+ [--tui] show the agent's own TUI inside the codemax screen instead
27
+ [--raw] legacy passthrough mode without the codemax screen
28
+ codemax agents show detected agents, saved sessions, quota
29
+ codemax status project ledger summary
30
+ codemax history [-n <count>] print recorded turns for this project
31
+ codemax brief [--to <id>] print the handoff brief that would be injected
32
+ codemax config print config path and values
33
+ codemax keys show the raw bytes your terminal sends per keypress (hotkey debugging)
34
+ codemax update [--check] update codemax the way it was installed (--check only reports)
35
+ codemax awake [on|lid|off|status] keep the machine awake while codemax runs (lid: also with the lid closed)
36
+ codemax quota [clear [<id>]] show or clear remembered quota state for this project
37
+ codemax doctor [--fix] check agent auth/permission setup (API keys that would be billed, silent modes)
38
+ codemax usage [--all] tokens and API-equivalent cost per agent for this project (or every project)
39
+ codemax prices [update] [<model>] show price source and age, refresh the public list, or look up one model
40
+
41
+ inside an agent, press the hotkey (default Ctrl+/ or Ctrl+^) to open the codemax menu.
42
+ set "hotkeys": ["ctrl+<key>", ...] in the config to change it.
43
+ `;
44
+
45
+ function flag(args: string[], name: string): string | undefined {
46
+ const i = args.indexOf(name);
47
+ return i >= 0 ? args[i + 1] : undefined;
48
+ }
49
+
50
+ async function main(argv: string[]) {
51
+ const config = loadConfig();
52
+ const cwd = canonicalCwd();
53
+ const [cmdRaw, ...rest] = argv;
54
+ const cmd = cmdRaw && !cmdRaw.startsWith("-") ? cmdRaw : "start";
55
+ const args = cmdRaw && !cmdRaw.startsWith("-") ? rest : argv;
56
+
57
+ if (args.includes("--help") || args.includes("-h") || cmd === "help") {
58
+ process.stdout.write(HELP);
59
+ return;
60
+ }
61
+
62
+ switch (cmd) {
63
+ case "start": {
64
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
65
+ console.error("codemax start needs a TTY");
66
+ process.exit(2);
67
+ }
68
+ const ledger = new Ledger(cwd);
69
+ const agentFlag = flag(args, "--agent") ?? flag(args, "-a");
70
+ const agent = (agentFlag ?? config.order[0] ?? "claude") as AgentId;
71
+ getAdapter(agent); // validate
72
+ if (args.includes("--raw")) {
73
+ const orch = new Orchestrator(config, ledger, cwd);
74
+ await orch.start(agent, args.includes("--fresh"));
75
+ } else if (args.includes("--tui") || !hasEngine(agent)) {
76
+ await runApp(config, ledger, cwd, agent, args.includes("--fresh"));
77
+ } else {
78
+ await runChat(config, ledger, cwd, agent, args.includes("--fresh"), !agentFlag);
79
+ }
80
+ process.exit(0);
81
+ }
82
+ case "agents": {
83
+ const ledger = new Ledger(cwd);
84
+ const sessions = ledger.sessions();
85
+ const quota = ledger.quota();
86
+ for (const a of listAdapters(config.order)) {
87
+ const d = await a.detect();
88
+ const s = sessions[a.id];
89
+ const q = quota[a.id];
90
+ console.log(
91
+ `${a.id.padEnd(9)} ${a.name.padEnd(13)} ${d.installed ? `✓ ${d.version ?? ""}`.padEnd(14) : "✗ not found".padEnd(14)} ` +
92
+ `${s ? `session ${s.id.slice(0, 8)}…` : "no session".padEnd(16)} ${q ? quotaLabel(q) : ""}`,
93
+ );
94
+ }
95
+ return;
96
+ }
97
+ case "status": {
98
+ const ledger = new Ledger(cwd);
99
+ const turns = ledger.turns();
100
+ const events = ledger.events();
101
+ console.log(`project: ${cwd}\nledger: ${ledger.dir}\nturns: ${turns.length}\nevents: ${events.length}`);
102
+ const byAgent = new Map<string, number>();
103
+ for (const t of turns) byAgent.set(t.agent, (byAgent.get(t.agent) ?? 0) + 1);
104
+ for (const [a, n] of byAgent) console.log(` ${a}: ${n} turns`);
105
+ const last = events.slice(-5);
106
+ if (last.length) {
107
+ console.log("recent events:");
108
+ for (const e of last) console.log(` ${new Date(e.ts).toLocaleString()} ${e.kind} ${e.agent ?? ""}${e.to ? ` → ${e.to}` : ""} ${e.detail ?? ""}`);
109
+ }
110
+ return;
111
+ }
112
+ case "history": {
113
+ const n = Number(flag(args, "-n") ?? 20);
114
+ const turns = new Ledger(cwd).turns().slice(-n);
115
+ for (const t of turns) {
116
+ const who = t.role === "user" ? "USER" : `${t.agent.toUpperCase()}`;
117
+ console.log(`\n[${new Date(t.ts).toLocaleTimeString()}] ${who}${t.tools?.length ? ` (tools: ${t.tools.join(", ")})` : ""}\n${t.text.slice(0, 600)}`);
118
+ }
119
+ return;
120
+ }
121
+ case "brief": {
122
+ const to = (flag(args, "--to") ?? "codex") as AgentId;
123
+ console.log(await buildBrief({ ledger: new Ledger(cwd), cwd, to, config }));
124
+ return;
125
+ }
126
+ case "config": {
127
+ console.log(`${CONFIG_PATH}\nhotkeys: ${hotkeyLabel(config.hotkeys)}\n${JSON.stringify(config, null, 2)}`);
128
+ return;
129
+ }
130
+ case "prices": {
131
+ if (args[0] === "update") {
132
+ const l = await fetchLivePrices();
133
+ if (!l) return console.log("could not reach openrouter.ai or the LiteLLM list; keeping the current table");
134
+ resetPriceCache();
135
+ console.log(`updated: ${Object.keys(l.prices).length} models from ${l.source} → ${LIVE_PRICES_PATH}`);
136
+ }
137
+ const prov = priceProvenance();
138
+ const cache = loadLivePrices();
139
+ console.log(`price source: ${prov.source} list as of ${prov.asOf}${cache ? ` (${Object.keys(cache.prices).length} models cached, refreshed weekly)` : " (built-in table; run codemax prices update to download the current list)"}`);
140
+ console.log(`overrides: ~/.codemax/prices.json`);
141
+ const model = args.find((a) => a !== "update");
142
+ if (model) {
143
+ const hit = priceFor(model);
144
+ console.log(hit ? `${model}: $${hit.price.input}/M in · $${hit.price.output}/M out · cache read $${hit.price.cacheRead ?? hit.price.input * 0.1}/M (matched "${hit.key}" from ${hit.source})` : `${model}: no price found`);
145
+ }
146
+ return;
147
+ }
148
+ case "usage": {
149
+ if (args.includes("--all")) {
150
+ const { readdirSync, existsSync } = await import("node:fs");
151
+ const { join } = await import("node:path");
152
+ const { CODEMAX_HOME } = await import("./core/paths.ts");
153
+ const root = join(CODEMAX_HOME, "projects");
154
+ const all = existsSync(root) ? readdirSync(root).flatMap((d) => new Ledger("/", join(root, d)).usage()) : [];
155
+ for (const l of renderUsageTable(all, { title: "Usage across all projects" })) console.log(l);
156
+ return;
157
+ }
158
+ const u = new Ledger(cwd).usage();
159
+ for (const l of renderUsageTable(u, { title: `Usage for ${cwd}` })) console.log(l);
160
+ console.log(` total ${fmtUsd(sumCost(u))} · prices: ~/.codemax/prices.json overrides the built-in list`);
161
+ return;
162
+ }
163
+ case "doctor": {
164
+ const findings = diagnose();
165
+ if (!findings.length) return console.log("all clear: no API-key billing or silent-permission issues found");
166
+ for (const f of findings) {
167
+ console.log(`${f.severity === "block" ? "BLOCK" : "warn "} [${f.agent}] ${f.message}`);
168
+ if (f.fix) console.log(` fix: ${f.fix.description}${args.includes("--fix") ? "" : " (run: codemax doctor --fix)"}`);
169
+ if (f.fix && args.includes("--fix")) console.log(` → ${f.fix.apply()}`);
170
+ }
171
+ return;
172
+ }
173
+ case "update": {
174
+ const latest = await checkForUpdate(pkg.version, { force: true });
175
+ console.log(latest ? `codemax ${latest} is available (you have ${pkg.version})` : `codemax ${pkg.version} is the latest`);
176
+ if (args.includes("--check") || !latest) return;
177
+ const plan = updatePlan();
178
+ if (plan.manual) return console.log(plan.manual);
179
+ console.log(`updating with ${plan.label} (${installKind().kind} install)\n`);
180
+ for (const cmd of plan.cmd) {
181
+ const p = Bun.spawn(platformArgv(cmd), { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: { ...process.env, ...(plan.env ?? {}) } });
182
+ if ((await p.exited) !== 0) {
183
+ console.error(`\nupdate failed · run it yourself: ${plan.cmd.map((c) => c.join(" ")).join(" && ")}`);
184
+ process.exit(1);
185
+ }
186
+ }
187
+ console.log("\ncodemax updated.");
188
+ return;
189
+ }
190
+ case "awake": {
191
+ const v = (args[0] ?? "status").toLowerCase();
192
+ const support = process.platform === "linux" ? "sleep, lid close and shutdown are blocked (systemd-inhibit)" : process.platform === "darwin" ? "sleep is blocked; lid close needs your admin password once; macOS cannot block shutdown" : process.platform === "win32" ? "sleep is blocked; lid close switches the lid action to 'do nothing' and back; Windows cannot block shutdown" : "not supported on this platform";
193
+ if (v === "status") {
194
+ console.log(`awake: ${config.awake ?? "off"} (applies while codemax runs)\n${process.platform}: ${support}`);
195
+ return;
196
+ }
197
+ if (v !== "on" && v !== "lid" && v !== "off") {
198
+ console.error("usage: codemax awake [on|lid|off|status]");
199
+ process.exit(2);
200
+ }
201
+ if (v === "off") for (const n of await recoverAwake({ ...defaultAwakeDeps(), pid: -1, interactive: (fn) => fn() })) console.log(n);
202
+ if (v === "lid" && process.platform === "darwin") {
203
+ console.log("macOS needs your admin password to keep working with the lid closed (pmset disablesleep).");
204
+ const ok = Bun.spawnSync(["sudo", "-v"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }).exitCode === 0;
205
+ if (!ok) console.log("no admin rights: lid-close will not be enabled; the Mac still will not idle-sleep.");
206
+ }
207
+ saveConfigKey("awake", v);
208
+ console.log(v === "off" ? "awake off: the machine sleeps normally" : `awake ${v}: applies whenever codemax is running · ${support}`);
209
+ if (v === "lid") console.log("With the lid closed, keep the laptop somewhere it can breathe: not in a bag.");
210
+ return;
211
+ }
212
+ case "quota": {
213
+ const ledger = new Ledger(cwd);
214
+ if (args[0] === "clear") {
215
+ ledger.clearQuota(args[1] as AgentId | undefined);
216
+ console.log(args[1] ? `cleared quota state for ${args[1]}` : "cleared all quota state");
217
+ return;
218
+ }
219
+ const all = ledger.quota();
220
+ if (!Object.keys(all).length) return console.log("no quota state recorded");
221
+ for (const q of Object.values(all)) console.log(`${q.agent.padEnd(9)} ${quotaLabel(q) || "ok"} (${q.source}, ${new Date(q.at).toLocaleString()}${q.detail ? `, ${q.detail}` : ""})`);
222
+ return;
223
+ }
224
+ case "keys": {
225
+ if (!process.stdin.isTTY) {
226
+ console.error("codemax keys needs a TTY");
227
+ process.exit(2);
228
+ }
229
+ const specs = parseHotkeys(config.hotkeys);
230
+ console.log(`press keys to see what your terminal sends. configured hotkeys: ${hotkeyLabel(config.hotkeys)}. q or Ctrl+C to quit.\n`);
231
+ process.stdin.setRawMode(true);
232
+ process.stdin.resume();
233
+ process.stdin.on("data", (chunk: Buffer) => {
234
+ const bytes = new Uint8Array(chunk);
235
+ const hit = findHotkey(bytes, specs);
236
+ process.stdout.write(`${describeBytes(bytes)}${hit ? ` ← matches hotkey ${hit.name}` : ""}\r\n`);
237
+ if (bytes.length === 1 && (bytes[0] === 0x03 || bytes[0] === 0x71)) {
238
+ process.stdin.setRawMode(false);
239
+ process.exit(0);
240
+ }
241
+ });
242
+ await new Promise(() => {});
243
+ return;
244
+ }
245
+ default:
246
+ console.error(`unknown command "${cmd}"\n\n${HELP}`);
247
+ process.exit(2);
248
+ }
249
+ }
250
+
251
+ main(process.argv.slice(2)).catch((e) => {
252
+ console.error(`codemax: ${e?.message ?? e}`);
253
+ process.exit(1);
254
+ });