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,314 @@
1
+ import type { Config } from "../config.ts";
2
+ import type { AgentId, Turn } from "../adapters/types.ts";
3
+ import { diffStat, headLine, recentCommits } from "./git.ts";
4
+ import { checkpointDiff } from "./checkpoint.ts";
5
+ import { BRIEF_MARKER, type Ledger } from "./ledger.ts";
6
+ import type { Step, StepOf } from "./journal.ts";
7
+
8
+ export interface BriefInput {
9
+ ledger: Ledger;
10
+ cwd: string;
11
+ to: AgentId;
12
+ from?: AgentId;
13
+ reason?: string;
14
+ config: Config;
15
+ /** "takeover" (default): the agent continues the task. "context": a one-off question (/ask, routes) that must not take the task over. */
16
+ purpose?: "takeover" | "context";
17
+ }
18
+
19
+ /**
20
+ * The handoff prompt the next agent starts with. With a step journal this is a
21
+ * resume packet: the task, where and why the last agent stopped (including the
22
+ * step in flight), the plan, every step taken, and the real code diff since the
23
+ * task began. Without one (an older ledger) it falls back to a turn summary.
24
+ */
25
+ export async function buildBrief(input: BriefInput): Promise<string> {
26
+ const steps = input.ledger.journal.steps();
27
+ if (steps.some((s) => s.kind === "user")) return buildResumePacket(input, steps);
28
+ return buildTurnBrief(input);
29
+ }
30
+
31
+ // ---------------------------------------------------------------- resume packet
32
+
33
+ export async function buildResumePacket(input: BriefInput, steps: Step[]): Promise<string> {
34
+ const { ledger, cwd, to, from, reason, config } = input;
35
+ const budget = config.brief.resumeChars;
36
+ const task = ledger.journal.task(steps);
37
+ const workSteps = task.steps.filter((s) => s.kind !== "user" || s !== task.request);
38
+ const lastEnd = [...task.steps].reverse().find((s): s is StepOf<"end"> => s.kind === "end");
39
+ const plan = [...task.steps].reverse().find((s): s is StepOf<"plan"> => s.kind === "plan");
40
+ const unfinishedPlan = plan?.entries.some((e) => e.status !== "completed");
41
+ const stoppedAgent = lastEnd?.agent ?? from;
42
+ const inProgress = !lastEnd || lastEnd.outcome !== "done" || !!unfinishedPlan;
43
+ const context = input.purpose === "context";
44
+
45
+ const head: string[] = [BRIEF_MARKER];
46
+ if (context)
47
+ head.push(
48
+ "Context only: this is the current state of a coding task in this repository, recorded by codemax while other agents worked on it. " +
49
+ "Do not take the task over, and do not change files unless the message at the end asks you to. Answer only that message.",
50
+ );
51
+ else head.push(
52
+ `You are taking over a coding task in this repository${stoppedAgent ? ` from ${stoppedAgent}` : ""}${reason ? ` (${reason})` : ""}. ` +
53
+ "Same user, same repo; only the agent changed. " +
54
+ (inProgress
55
+ ? "Resume exactly where it stopped: do not restart and do not redo finished steps. First check the step that was in flight (if any) actually happened, then continue the plan."
56
+ : "The last task looks finished. Confirm that briefly and ask the user what is next; do not redo the work."),
57
+ );
58
+ head.push("", `Repo: ${await headLine(cwd)}`);
59
+
60
+ head.push("", "## The task", quoteBlock(task.request?.text ?? "(no request recorded)"));
61
+ for (const f of task.followUps) head.push("", "Follow-up from the user:", quoteBlock(f.text));
62
+
63
+ if (lastEnd || stoppedAgent) head.push("", "## Where it stopped", ...whereItStopped(task.steps, lastEnd));
64
+
65
+ if (plan) {
66
+ head.push("", "## Plan (latest)");
67
+ let marked = false;
68
+ for (const e of plan.entries) {
69
+ const box = e.status === "completed" ? "[x]" : e.status === "in_progress" ? "[~]" : "[ ]";
70
+ const next = !context && !marked && e.status !== "completed" ? " ← continue here" : "";
71
+ if (next) marked = true;
72
+ head.push(`${box} ${e.content}${next}`);
73
+ }
74
+ }
75
+
76
+ const fixed = head.join("\n");
77
+ let room = Math.max(2000, budget - fixed.length);
78
+
79
+ // The real code change since the task began: what the next agent most needs to see.
80
+ const diff = await taskDiff(cwd, task.request?.checkpoint);
81
+ const diffBudget = Math.floor(room * 0.45);
82
+ const diffPart = diff.text ? ["", `## Code changes since the task started${diff.vs ? ` (vs snapshot ${diff.vs.slice(0, 8)})` : ""}`, "```diff", cut(diff.text, diffBudget, "diff"), "```"] : ["", "## Code changes since the task started", "(none)"];
83
+ room -= diffPart.join("\n").length;
84
+
85
+ // Steps, newest kept first when space is short.
86
+ const stepLines = workSteps.map((s) => formatStep(s)).filter((l): l is string => !!l);
87
+ const stepBudget = Math.floor(room * 0.8);
88
+ const kept: string[] = [];
89
+ let used = 0;
90
+ for (let i = stepLines.length - 1; i >= 0; i--) {
91
+ const l = stepLines[i]!;
92
+ if (used + l.length + 1 > stepBudget) {
93
+ kept.unshift(`… ${i + 1} earlier step${i ? "s" : ""} on this task omitted for space`);
94
+ break;
95
+ }
96
+ kept.unshift(l);
97
+ used += l.length + 1;
98
+ }
99
+ room -= used;
100
+ const stepsPart = kept.length ? ["", `## Steps taken on this task (oldest first)`, ...kept] : [];
101
+
102
+ // Earlier conversation, condensed: previous requests and final answers.
103
+ // Earlier tasks from the journal; the old turn list only for history recorded before it existed.
104
+ const preJournal = steps[0] ? ledger.turns().filter((t) => t.ts < steps[0]!.ts) : [];
105
+ const earlier = condensedHistory(task.earlier, preJournal, Math.max(0, Math.min(room, Math.floor(budget * 0.15))));
106
+ const summary = ledger.summary();
107
+
108
+ const tail: string[] = [];
109
+ if (earlier) tail.push("", "## Earlier conversation (condensed)", earlier);
110
+ if (summary) tail.push("", "## Project summary", cut(summary, 1500, "summary"));
111
+ const commits = await recentCommits(cwd, 3);
112
+ if (commits) tail.push("", "## Recent commits", commits);
113
+ if (to && !context) tail.push("", `You are ${to}. Continue now.`);
114
+
115
+ return [fixed, ...stepsPart, ...diffPart, ...tail].join("\n");
116
+ }
117
+
118
+ /**
119
+ * For an agent whose own session resumes: only what happened while it was away.
120
+ * Returns null when nobody else did anything since its last turn.
121
+ */
122
+ export async function buildCatchup(input: BriefInput): Promise<string | null> {
123
+ const { ledger, cwd, to, config } = input;
124
+ const steps = ledger.journal.steps();
125
+ const since = ledger.journal.lastSeen(to, steps);
126
+ if (since === undefined) return null;
127
+ const after = steps.filter((s) => s.ts > since && s.kind !== "handoff");
128
+ const others = after.filter((s) => s.agent !== to && s.kind !== "user");
129
+ if (!others.length) return null;
130
+ const users = after.filter((s): s is StepOf<"user"> => s.kind === "user");
131
+ const plan = [...after].reverse().find((s): s is StepOf<"plan"> => s.kind === "plan");
132
+ const lastEnd = [...after].reverse().find((s): s is StepOf<"end"> => s.kind === "end");
133
+ const agents = [...new Set(others.map((s) => s.agent))].join(", ");
134
+
135
+ const out: string[] = [BRIEF_MARKER];
136
+ out.push(
137
+ `Welcome back. Your own session history is intact. While you were away, ${agents} worked in this repo. ` +
138
+ "Here is only what changed since your last turn. Continue from the current state; do not undo their work unless it is wrong.",
139
+ );
140
+ if (users.length) {
141
+ out.push("", "## New requests from the user");
142
+ for (const u of users) out.push(quoteBlock(u.text));
143
+ }
144
+ if (lastEnd) out.push("", "## Where it stopped", ...whereItStopped(after, lastEnd));
145
+ if (plan) {
146
+ out.push("", "## Plan (latest)");
147
+ for (const e of plan.entries) out.push(`${e.status === "completed" ? "[x]" : e.status === "in_progress" ? "[~]" : "[ ]"} ${e.content}`);
148
+ }
149
+ const budget = Math.floor(config.brief.resumeChars * 0.6);
150
+ const lines = after.filter((s) => s.agent !== to || s.kind === "user").map((s) => formatStep(s)).filter((l): l is string => !!l);
151
+ out.push("", "## What happened (oldest first)", ...lastLines(lines, Math.floor(budget * 0.5)));
152
+ // Diff from the first snapshot taken after this agent stopped: before a user message or a
153
+ // handoff turn (an agent that took over and edited files snapshots first).
154
+ const firstSnapshot = steps.find((s) => s.ts > since && (s.kind === "user" || s.kind === "handoff") && s.checkpoint) as { checkpoint?: string } | undefined;
155
+ const diff = firstSnapshot?.checkpoint ? await checkpointDiff(cwd, firstSnapshot.checkpoint).catch(() => "") : "";
156
+ if (diff) out.push("", "## Code changes while you were away", "```diff", cut(diff, Math.floor(budget * 0.5), "diff"), "```");
157
+ else out.push("", "## Uncommitted changes now", await diffStat(cwd));
158
+ return out.join("\n");
159
+ }
160
+
161
+ function whereItStopped(steps: Step[], end: StepOf<"end"> | undefined): string[] {
162
+ const out: string[] = [];
163
+ if (!end) return ["The last agent had not finished its first turn."];
164
+ const when = new Date(end.ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
165
+ const why =
166
+ end.outcome === "limit" ? `ran out of its usage limit${end.detail ? ` ("${oneLine(end.detail, 140)}")` : ""}`
167
+ : end.outcome === "cancelled" ? "was interrupted by the user"
168
+ : end.outcome === "error" ? `stopped on an error${end.detail ? `: ${oneLine(end.detail, 200)}` : ""}`
169
+ : "finished its turn";
170
+ out.push(`${end.agent} ${why} at ${when}.`);
171
+ const turnSteps = steps.filter((s) => s.turn === end.turn);
172
+ const tools = new Map(turnSteps.filter((s): s is StepOf<"tool"> => s.kind === "tool").map((t) => [t.id, t]));
173
+ for (const id of end.inFlight ?? []) {
174
+ const t = tools.get(id);
175
+ if (!t) continue;
176
+ out.push(`In flight when it stopped: ${t.title}${t.input ? ` with input ${oneLine(t.input, 400)}` : ""}. No result was recorded, so check whether it ran before continuing.`);
177
+ }
178
+ const lastText = [...turnSteps].reverse().find((s): s is StepOf<"text"> => s.kind === "text");
179
+ if (lastText) out.push(`Its last words: "${oneLine(lastText.text, 500)}"`);
180
+ const lastThought = [...turnSteps].reverse().find((s): s is StepOf<"thought"> => s.kind === "thought");
181
+ if (lastThought && end.outcome !== "done") out.push(`What it was thinking: "${oneLine(lastThought.text, 500)}"`);
182
+ return out;
183
+ }
184
+
185
+ export function formatStep(s: Step): string | null {
186
+ switch (s.kind) {
187
+ case "user":
188
+ return `- USER: ${oneLine(s.text, 600)}`;
189
+ case "text":
190
+ return `- [${s.agent}] said: ${oneLine(s.text, 600)}`;
191
+ case "tool": {
192
+ const status = s.status === "completed" ? "" : ` (${s.status === "failed" ? "FAILED" : s.status === "in_progress" || s.status === "pending" ? "NOT FINISHED" : s.status})`;
193
+ const lines = [`- [${s.agent}] ${s.title}${status}`];
194
+ if (s.input) lines.push(` input: ${oneLine(s.input, 300)}`);
195
+ if (s.output) lines.push(` result: ${resultSnippet(s.output)}`);
196
+ return lines.join("\n");
197
+ }
198
+ case "plan":
199
+ return `- [${s.agent}] updated the plan: ${s.entries.map((e) => `${e.status === "completed" ? "[x]" : e.status === "in_progress" ? "[~]" : "[ ]"} ${e.content}`).join("; ")}`;
200
+ case "end":
201
+ return s.outcome === "done" ? null : `- [${s.agent}] turn ended: ${s.outcome}${s.detail ? ` (${oneLine(s.detail, 160)})` : ""}`;
202
+ case "handoff":
203
+ return `- handed over to ${s.agent}${s.reason ? ` (${s.reason})` : ""}`;
204
+ case "thought":
205
+ return null;
206
+ }
207
+ }
208
+
209
+ function condensedHistory(earlier: Step[], turns: Turn[], budget: number): string {
210
+ if (budget < 200) return "";
211
+ let lines: string[];
212
+ if (earlier.some((s) => s.kind === "user")) {
213
+ lines = [];
214
+ for (const s of earlier) {
215
+ if (s.kind === "user") lines.push(`USER: ${oneLine(s.text, 400)}`);
216
+ else if (s.kind === "text") {
217
+ // keep only each turn's final words
218
+ const nextSameTurn = earlier.slice(earlier.indexOf(s) + 1).some((x) => x.turn === s.turn && x.kind === "text");
219
+ if (!nextSameTurn) lines.push(`ASSISTANT (${s.agent}): ${oneLine(s.text, 400)}`);
220
+ }
221
+ }
222
+ } else {
223
+ lines = turns.slice(-8).map((t) => `${t.role === "user" ? "USER" : `ASSISTANT (${t.agent})`}: ${oneLine(t.text, 400)}`);
224
+ }
225
+ return lastLines(lines, budget).join("\n");
226
+ }
227
+
228
+ async function taskDiff(cwd: string, checkpoint?: string): Promise<{ text: string; vs?: string }> {
229
+ if (checkpoint) {
230
+ const text = await checkpointDiff(cwd, checkpoint).catch(() => "");
231
+ return { text, vs: checkpoint };
232
+ }
233
+ const p = Bun.spawn(["git", "diff", "--no-color", "HEAD"], { cwd, stdout: "pipe", stderr: "ignore" });
234
+ const text = (await new Response(p.stdout).text()).trimEnd();
235
+ await p.exited;
236
+ return { text };
237
+ }
238
+
239
+ function resultSnippet(output: string): string {
240
+ const lines = output.trim().split("\n");
241
+ if (lines.length <= 6) return lines.join("\n ");
242
+ return [...lines.slice(0, 3), `… ${lines.length - 6} lines …`, ...lines.slice(-3)].join("\n ");
243
+ }
244
+
245
+ function lastLines(lines: string[], budget: number): string[] {
246
+ const out: string[] = [];
247
+ let used = 0;
248
+ for (let i = lines.length - 1; i >= 0; i--) {
249
+ if (used + lines[i]!.length + 1 > budget) {
250
+ out.unshift(`… ${i + 1} earlier line${i ? "s" : ""} omitted`);
251
+ break;
252
+ }
253
+ out.unshift(lines[i]!);
254
+ used += lines[i]!.length + 1;
255
+ }
256
+ return out;
257
+ }
258
+
259
+ function quoteBlock(s: string): string {
260
+ return s.trim().split("\n").map((l) => `> ${l}`).join("\n");
261
+ }
262
+
263
+ function oneLine(s: string, max: number): string {
264
+ const t = s.replace(/\s+/g, " ").trim();
265
+ return t.length <= max ? t : t.slice(0, max - 1) + "…";
266
+ }
267
+
268
+ function cut(s: string, max: number, what: string): string {
269
+ if (s.length <= max) return s;
270
+ return s.slice(0, max) + `\n… ${what} cut: ${s.length - max} more characters (run git diff to see all)`;
271
+ }
272
+
273
+ // ---------------------------------------------------------------- legacy brief (no journal)
274
+
275
+ async function buildTurnBrief(input: BriefInput): Promise<string> {
276
+ const { ledger, cwd, to, from, reason, config } = input;
277
+ const { turns: maxTurns, maxChars, turnChars } = config.brief;
278
+ const all = ledger.turns();
279
+ const recent = all.slice(-maxTurns);
280
+ const agentsUsed = [...new Set(all.map((t) => t.agent))].filter((a) => a !== to);
281
+ const lastUser = [...all].reverse().find((t) => t.role === "user");
282
+
283
+ const lines: string[] = [];
284
+ lines.push(BRIEF_MARKER);
285
+ lines.push(
286
+ `You are continuing an existing coding session in this repository${from ? ` that was being handled by ${from}` : ""}${reason ? ` (${reason})` : ""}. ` +
287
+ "The user has not changed; only the agent has. Read the context below, then continue from where it left off. " +
288
+ "Do not redo completed work. If the last task looks finished, briefly confirm and ask what is next.",
289
+ );
290
+ lines.push("", `Repo: ${await headLine(cwd)}`);
291
+ if (agentsUsed.length) lines.push(`Previous agents on this project: ${agentsUsed.join(", ")}`);
292
+ const summary = ledger.summary();
293
+ if (summary) lines.push("", "## Project summary", summary);
294
+ if (recent.length) {
295
+ lines.push("", `## Recent conversation (oldest first, last ${recent.length} turns)`);
296
+ for (const t of recent) lines.push(`${t.role === "user" ? "USER" : `ASSISTANT (${t.agent})`}${t.tools?.length ? ` [tools: ${t.tools.join(", ")}]` : ""}: ${truncate(t.text.replace(/\s+/g, " ").trim(), turnChars)}`);
297
+ } else lines.push("", "## Recent conversation", "(no prior turns recorded)");
298
+ const commits = await recentCommits(cwd, 5);
299
+ if (commits) lines.push("", "## Recent commits", commits);
300
+ lines.push("", "## Uncommitted changes", await diffStat(cwd));
301
+ if (lastUser) lines.push("", "## Last user request", truncate(lastUser.text.trim(), turnChars));
302
+ return clamp(lines.join("\n"), maxChars);
303
+ }
304
+
305
+ function truncate(s: string, max: number): string {
306
+ return s.length <= max ? s : s.slice(0, max - 1) + "…";
307
+ }
308
+
309
+ function clamp(s: string, max: number): string {
310
+ if (s.length <= max) return s;
311
+ const head = Math.floor(max * 0.55);
312
+ const tail = max - head - 20;
313
+ return s.slice(0, head) + "\n…[truncated]…\n" + s.slice(-tail);
314
+ }
@@ -0,0 +1,42 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import type { AgentId } from "../adapters/types.ts";
4
+ import { CODEMAX_HOME } from "./paths.ts";
5
+ import { Ledger } from "./ledger.ts";
6
+ import { recordCost, type UsageRecord } from "./usage.ts";
7
+
8
+ export type BudgetKey = AgentId | "all";
9
+
10
+ export function startOfDay(now = Date.now()): number {
11
+ const d = new Date(now);
12
+ d.setHours(0, 0, 0, 0);
13
+ return d.getTime();
14
+ }
15
+
16
+ /** Today's API-equivalent spend per agent, across every project codemax has seen. */
17
+ export function spendToday(now = Date.now()): Map<BudgetKey, number> {
18
+ const since = startOfDay(now);
19
+ const out = new Map<BudgetKey, number>();
20
+ const root = join(CODEMAX_HOME, "projects");
21
+ if (!existsSync(root)) return out;
22
+ for (const d of readdirSync(root)) {
23
+ for (const r of new Ledger("/", join(root, d)).usage()) addSpend(out, r, since);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function addSpend(map: Map<BudgetKey, number>, r: UsageRecord, since = startOfDay()) {
29
+ if (r.ts < since) return;
30
+ const usd = recordCost(r).usd;
31
+ map.set(r.agent, (map.get(r.agent) ?? 0) + usd);
32
+ map.set("all", (map.get("all") ?? 0) + usd);
33
+ }
34
+
35
+ export type BudgetLevel = "ok" | "warn" | "over";
36
+
37
+ export function budgetLevel(spent: number, limit: number | undefined): BudgetLevel {
38
+ if (!limit || limit <= 0) return "ok";
39
+ if (spent >= limit) return "over";
40
+ if (spent >= limit * 0.8) return "warn";
41
+ return "ok";
42
+ }
@@ -0,0 +1,139 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, mkdirSync, rmSync, rmdirSync, readdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ /**
6
+ * Working-tree checkpoints that never touch the user's index, branches or
7
+ * stash: a private index file snapshots everything git would add (`.gitignore`
8
+ * respected) into a commit stored under refs/codemax/checkpoints/.
9
+ */
10
+
11
+ async function run(cwd: string, args: string[], env: Record<string, string> = {}): Promise<{ code: number; out: string; err: string }> {
12
+ const p = Bun.spawn(["git", ...args], { cwd, env: { ...process.env, ...env }, stdout: "pipe", stderr: "pipe" });
13
+ const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
14
+ return { code: await p.exited, out: out.trimEnd(), err: err.trimEnd() };
15
+ }
16
+
17
+ async function gitDir(cwd: string): Promise<string | null> {
18
+ const r = await run(cwd, ["rev-parse", "--absolute-git-dir"]);
19
+ return r.code === 0 ? r.out : null;
20
+ }
21
+
22
+ /** Untracked files above this size are left out of snapshots (and so never deleted by /undo). */
23
+ export const MAX_UNTRACKED_BYTES = 5 * 1024 * 1024;
24
+ /** Checkpoint refs kept per repo; older ones are deleted so git can collect their objects. */
25
+ export const KEEP_CHECKPOINTS = 50;
26
+
27
+ async function largeUntracked(cwd: string): Promise<string[]> {
28
+ const r = await run(cwd, ["ls-files", "-o", "--exclude-standard", "-z"]);
29
+ if (r.code !== 0) return [];
30
+ const { statSync } = await import("node:fs");
31
+ return r.out.split("\0").filter((f) => {
32
+ if (!f) return false;
33
+ try {
34
+ return statSync(join(cwd, f)).size > MAX_UNTRACKED_BYTES;
35
+ } catch {
36
+ return false;
37
+ }
38
+ });
39
+ }
40
+
41
+ async function treeOfWorkingDir(cwd: string, index: string): Promise<string | null> {
42
+ const env = { GIT_INDEX_FILE: index };
43
+ await run(cwd, ["read-tree", "--empty"], env);
44
+ const skip = (await largeUntracked(cwd)).map((f) => `:(exclude,literal)${f}`);
45
+ if ((await run(cwd, ["add", "-A", "--", ".", ...skip], env)).code !== 0) return null;
46
+ const t = await run(cwd, ["write-tree"], env);
47
+ return t.code === 0 ? t.out : null;
48
+ }
49
+
50
+ export interface Checkpoint {
51
+ sha: string;
52
+ label: string;
53
+ ts: number;
54
+ }
55
+
56
+ /** Snapshot the working tree. Returns null outside a git repo. */
57
+ export async function createCheckpoint(cwd: string, label: string): Promise<Checkpoint | null> {
58
+ const dir = await gitDir(cwd);
59
+ if (!dir) return null;
60
+ const index = join(dir, "codemax-checkpoint-index");
61
+ const tree = await treeOfWorkingDir(cwd, index);
62
+ if (!tree) return null;
63
+ const head = await run(cwd, ["rev-parse", "--verify", "-q", "HEAD"]);
64
+ const ident = { GIT_AUTHOR_NAME: "codemax", GIT_AUTHOR_EMAIL: "codemax@localhost", GIT_COMMITTER_NAME: "codemax", GIT_COMMITTER_EMAIL: "codemax@localhost" };
65
+ const c = await run(cwd, ["commit-tree", tree, ...(head.code === 0 ? ["-p", head.out] : []), "-m", label], ident);
66
+ if (c.code !== 0) return null;
67
+ const ts = Date.now();
68
+ await run(cwd, ["update-ref", `refs/codemax/checkpoints/${ts}`, c.out]);
69
+ await pruneCheckpoints(cwd);
70
+ return { sha: c.out, label, ts };
71
+ }
72
+
73
+ /** Keep the newest KEEP_CHECKPOINTS refs; delete the rest. */
74
+ export async function pruneCheckpoints(cwd: string, keep = KEEP_CHECKPOINTS) {
75
+ const r = await run(cwd, ["for-each-ref", "--sort=-refname", "--format=%(refname)", "refs/codemax/checkpoints"]);
76
+ const old = r.out.split("\n").filter(Boolean).slice(keep);
77
+ for (const ref of old) await run(cwd, ["update-ref", "-d", ref]);
78
+ }
79
+
80
+ /** Make the working tree match a checkpoint: restore its files, delete files created since. */
81
+ export async function restoreCheckpoint(cwd: string, sha: string): Promise<{ restored: number; removed: string[] }> {
82
+ const dir = await gitDir(cwd);
83
+ if (!dir) throw new Error("not a git repository");
84
+ const index = join(dir, "codemax-restore-index");
85
+ const now = await treeOfWorkingDir(cwd, index);
86
+ const list = async (tree: string) => (await run(cwd, ["ls-tree", "-r", "--name-only", tree])).out.split("\n").filter(Boolean);
87
+ const target = new Set(await list(sha));
88
+ const current = now ? await list(now) : [];
89
+ const removed = current.filter((f) => !target.has(f));
90
+ for (const f of removed) {
91
+ const abs = join(cwd, f);
92
+ rmSync(abs, { force: true });
93
+ // tidy now-empty directories the agent created
94
+ let d = dirname(abs);
95
+ while (d.startsWith(cwd) && d !== cwd && existsSync(d) && readdirSync(d).length === 0) {
96
+ rmdirSync(d);
97
+ d = dirname(d);
98
+ }
99
+ }
100
+ const env = { GIT_INDEX_FILE: index };
101
+ await run(cwd, ["read-tree", sha], env);
102
+ const co = await run(cwd, ["checkout-index", "-a", "-f"], env);
103
+ if (co.code !== 0) throw new Error(co.err || "checkout-index failed");
104
+ return { restored: target.size, removed };
105
+ }
106
+
107
+ export async function listCheckpoints(cwd: string, limit = 20): Promise<Checkpoint[]> {
108
+ const r = await run(cwd, ["for-each-ref", "--sort=-refname", `--count=${limit}`, "--format=%(objectname) %(refname:lstrip=3) %(contents:subject)", "refs/codemax/checkpoints"]);
109
+ if (r.code !== 0 || !r.out) return [];
110
+ return r.out.split("\n").map((l) => {
111
+ const [sha, ts, ...rest] = l.split(" ");
112
+ return { sha: sha!, ts: Number(ts), label: rest.join(" ") };
113
+ });
114
+ }
115
+
116
+ /** Files that differ between a checkpoint and the working tree now. */
117
+ export async function checkpointDiffStat(cwd: string, sha: string): Promise<string> {
118
+ const dir = await gitDir(cwd);
119
+ if (!dir) return "";
120
+ const now = await treeOfWorkingDir(cwd, join(dir, "codemax-restore-index"));
121
+ if (!now) return "";
122
+ return (await run(cwd, ["diff", "--stat", sha, now])).out;
123
+ }
124
+
125
+ /**
126
+ * The full code change between a checkpoint and the working tree now, new files
127
+ * included (the snapshot uses a private index, so the user's staging is untouched).
128
+ */
129
+ export async function checkpointDiff(cwd: string, sha: string): Promise<string> {
130
+ const dir = await gitDir(cwd);
131
+ if (!dir) return "";
132
+ const now = await treeOfWorkingDir(cwd, join(dir, "codemax-restore-index"));
133
+ if (!now) return "";
134
+ return (await run(cwd, ["diff", "--no-color", "--no-ext-diff", "-M", sha, now])).out;
135
+ }
136
+
137
+ export function ensureDirFor(path: string) {
138
+ mkdirSync(dirname(path), { recursive: true });
139
+ }
@@ -0,0 +1,67 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
4
+ import type { AgentId } from "../adapters/types.ts";
5
+
6
+ export interface Finding {
7
+ agent: AgentId | "all";
8
+ severity: "block" | "warn";
9
+ message: string;
10
+ /** What `codemax doctor --fix` would do, when a safe fix exists. */
11
+ fix?: { description: string; apply: () => string };
12
+ }
13
+
14
+ const KEY_VARS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
15
+
16
+ /**
17
+ * Checks that would make an agent bill an API key instead of a subscription,
18
+ * or act without asking. codemax is subscriptions-only, so "block" findings
19
+ * stop that agent from launching until fixed.
20
+ */
21
+ export function diagnose(home: string = homedir(), env: NodeJS.ProcessEnv = process.env): Finding[] {
22
+ const out: Finding[] = [];
23
+ const CLAUDE_SETTINGS = join(home, ".claude", "settings.json");
24
+ const CLAUDE_LOCAL = join(home, ".claude", "settings.local.json");
25
+ for (const file of [CLAUDE_SETTINGS, CLAUDE_LOCAL]) {
26
+ if (!existsSync(file)) continue;
27
+ let json: any;
28
+ try {
29
+ json = JSON.parse(readFileSync(file, "utf8"));
30
+ } catch {
31
+ out.push({ agent: "claude", severity: "warn", message: `${file} is not valid JSON; Claude Code may ignore it` });
32
+ continue;
33
+ }
34
+ const injected = KEY_VARS.filter((k) => typeof json?.env?.[k] === "string" && json.env[k].length > 0);
35
+ if (injected.length) {
36
+ out.push({
37
+ agent: "claude",
38
+ severity: "block",
39
+ message: `${file} sets ${injected.join(", ")} in its "env" block. Claude Code applies that at startup, so every session (codemax or not) bills the API key instead of your claude.ai plan.`,
40
+ fix: {
41
+ description: `remove ${injected.join(", ")} from the "env" block of ${file} (a backup is written next to it)`,
42
+ apply: () => {
43
+ copyFileSync(file, `${file}.codemax-backup`);
44
+ for (const k of injected) delete json.env[k];
45
+ if (json.env && Object.keys(json.env).length === 0) delete json.env;
46
+ writeFileSync(file, JSON.stringify(json, null, 2) + "\n");
47
+ return `removed ${injected.join(", ")} from ${file}; backup at ${file}.codemax-backup`;
48
+ },
49
+ },
50
+ });
51
+ }
52
+ if (typeof json?.apiKeyHelper === "string" && json.apiKeyHelper) {
53
+ out.push({ agent: "claude", severity: "block", message: `${file} sets "apiKeyHelper"; Claude Code will fetch an API key from it and bill that key.` });
54
+ }
55
+ const mode = json?.permissions?.defaultMode;
56
+ if (file === CLAUDE_SETTINGS && mode && mode !== "default") {
57
+ out.push({ agent: "grok", severity: "warn", message: `Grok reads ${file}: permissions.defaultMode is "${mode}", so Grok acts without asking. Set it to "default" there to get prompts (this also changes Claude Code's own default).` });
58
+ }
59
+ }
60
+ const envKeys = KEY_VARS.concat(["OPENAI_API_KEY", "CURSOR_API_KEY", "XAI_API_KEY", "GROK_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"]).filter((k) => env[k]);
61
+ if (envKeys.length) out.push({ agent: "all", severity: "warn", message: `${envKeys.join(", ")} set in this shell; codemax strips them for agents, but your plain CLI sessions will bill them.` });
62
+ return out;
63
+ }
64
+
65
+ export function blockers(agent: AgentId): Finding[] {
66
+ return diagnose().filter((f) => f.severity === "block" && (f.agent === agent || f.agent === "all"));
67
+ }
@@ -0,0 +1,62 @@
1
+ import { join, relative } from "node:path";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+
4
+ const SKIP = new Set([".git", "node_modules", "dist", "build", ".next", ".turbo", "target", ".venv", "__pycache__"]);
5
+
6
+ /** Repo files for @-mentions: git's view when available (ignores respected), else a bounded walk. */
7
+ export async function listProjectFiles(cwd: string, max = 20000): Promise<string[]> {
8
+ try {
9
+ const p = Bun.spawn(["git", "ls-files", "-co", "--exclude-standard"], { cwd, stdout: "pipe", stderr: "ignore" });
10
+ const out = await new Response(p.stdout).text();
11
+ if ((await p.exited) === 0 && out.trim()) return out.split("\n").filter(Boolean).slice(0, max);
12
+ } catch {
13
+ /* fall through */
14
+ }
15
+ const files: string[] = [];
16
+ const walk = (dir: string, depth: number) => {
17
+ if (depth > 6 || files.length >= max) return;
18
+ for (const name of readdirSync(dir)) {
19
+ if (SKIP.has(name) || name.startsWith(".")) continue;
20
+ const abs = join(dir, name);
21
+ const st = statSync(abs);
22
+ if (st.isDirectory()) walk(abs, depth + 1);
23
+ else files.push(relative(cwd, abs));
24
+ if (files.length >= max) return;
25
+ }
26
+ };
27
+ walk(cwd, 0);
28
+ return files;
29
+ }
30
+
31
+ export const MENTION_RE = /(^|\s)@([^\s@]*)$/;
32
+
33
+ /** Paths mentioned as @path in a message that exist in the project. */
34
+ export function mentionedFiles(text: string, cwd: string): string[] {
35
+ const out: string[] = [];
36
+ for (const m of text.matchAll(/(?:^|\s)@([^\s@]+)/g)) {
37
+ const p = m[1]!.replace(/[),.;:]+$/, "");
38
+ if (!out.includes(p) && existsSync(join(cwd, p)) && statSync(join(cwd, p)).isFile()) out.push(p);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** Contents block appended to a prompt for the mentioned files (bounded). */
44
+ export function attachFiles(paths: string[], cwd: string, perFile = 30_000, total = 100_000): string {
45
+ let used = 0;
46
+ const parts: string[] = [];
47
+ for (const p of paths) {
48
+ let body = readFileSync(join(cwd, p), "utf8");
49
+ if (body.includes("\u0000")) {
50
+ parts.push(`--- ${p} (binary, not attached) ---`);
51
+ continue;
52
+ }
53
+ if (body.length > perFile) body = body.slice(0, perFile) + `\n… [truncated, ${body.length - perFile} more chars]`;
54
+ if (used + body.length > total) {
55
+ parts.push(`--- ${p} (skipped, attachment limit reached) ---`);
56
+ continue;
57
+ }
58
+ used += body.length;
59
+ parts.push(`--- ${p} ---\n${body}`);
60
+ }
61
+ return parts.length ? `\n\n[files the user attached with @]\n${parts.join("\n\n")}` : "";
62
+ }
@@ -0,0 +1,36 @@
1
+ export async function git(cwd: string, args: string[]): Promise<string | null> {
2
+ try {
3
+ const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
4
+ const text = await new Response(p.stdout).text();
5
+ const code = await p.exited;
6
+ return code === 0 ? text.trimEnd() : null;
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ export async function isRepo(cwd: string): Promise<boolean> {
13
+ return (await git(cwd, ["rev-parse", "--is-inside-work-tree"])) === "true";
14
+ }
15
+
16
+ export async function diffStat(cwd: string): Promise<string> {
17
+ if (!(await isRepo(cwd))) return "(not a git repo)";
18
+ const staged = await git(cwd, ["diff", "--cached", "--stat"]);
19
+ const unstaged = await git(cwd, ["diff", "--stat"]);
20
+ const untracked = await git(cwd, ["ls-files", "--others", "--exclude-standard"]);
21
+ const parts: string[] = [];
22
+ if (staged) parts.push("staged:\n" + staged);
23
+ if (unstaged) parts.push("unstaged:\n" + unstaged);
24
+ if (untracked) parts.push("untracked:\n" + untracked.split("\n").slice(0, 30).join("\n"));
25
+ return parts.length ? parts.join("\n") : "(working tree clean)";
26
+ }
27
+
28
+ export async function headLine(cwd: string): Promise<string> {
29
+ const branch = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
30
+ const log = await git(cwd, ["log", "-1", "--format=%h %s"]);
31
+ return `${branch ?? "?"} @ ${log ?? "(no commits)"}`;
32
+ }
33
+
34
+ export async function recentCommits(cwd: string, n = 5): Promise<string> {
35
+ return (await git(cwd, ["log", `-${n}`, "--format=%h %s"])) ?? "";
36
+ }