codemax-cli 0.0.0-stage → 0.1.1

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 (52) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +27 -0
  3. package/README.md +150 -2
  4. package/TRADEMARKS.md +27 -0
  5. package/bin/codemax +2 -0
  6. package/bin/codemax.cjs +77 -0
  7. package/package.json +63 -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 +1422 -0
  15. package/src/chat/markdown.ts +77 -0
  16. package/src/chat/palette.ts +179 -0
  17. package/src/chat/permission.ts +111 -0
  18. package/src/chat/theme.ts +158 -0
  19. package/src/chat/transcript.ts +324 -0
  20. package/src/cli.ts +209 -0
  21. package/src/config.ts +96 -0
  22. package/src/core/brief.ts +314 -0
  23. package/src/core/budget.ts +42 -0
  24. package/src/core/checkpoint.ts +139 -0
  25. package/src/core/doctor.ts +67 -0
  26. package/src/core/files.ts +62 -0
  27. package/src/core/git.ts +36 -0
  28. package/src/core/hotkey.ts +75 -0
  29. package/src/core/journal.ts +213 -0
  30. package/src/core/ledger.ts +153 -0
  31. package/src/core/menu.ts +134 -0
  32. package/src/core/orchestrator.ts +352 -0
  33. package/src/core/paths.ts +30 -0
  34. package/src/core/prices-live.ts +123 -0
  35. package/src/core/pricing.ts +162 -0
  36. package/src/core/pty.ts +99 -0
  37. package/src/core/quota.ts +55 -0
  38. package/src/core/spawn.ts +20 -0
  39. package/src/core/terminal.ts +51 -0
  40. package/src/core/usage.ts +116 -0
  41. package/src/engines/acp.ts +419 -0
  42. package/src/engines/codex.ts +330 -0
  43. package/src/engines/env.ts +17 -0
  44. package/src/engines/errors.ts +47 -0
  45. package/src/engines/index.ts +154 -0
  46. package/src/engines/types.ts +131 -0
  47. package/src/transcripts/claude-usage.ts +63 -0
  48. package/src/transcripts/claude.ts +69 -0
  49. package/src/transcripts/codex.ts +74 -0
  50. package/src/tui/app.ts +377 -0
  51. package/src/tui/menu.ts +126 -0
  52. package/src/tui/pane.ts +79 -0
package/NOTICE ADDED
@@ -0,0 +1,27 @@
1
+ codemax
2
+ Copyright (C) 2026 Saidev Dhal <https://saidev.codes>
3
+
4
+ This program is free software: you can redistribute it and/or modify it under
5
+ the terms of the GNU Affero General Public License as published by the Free
6
+ Software Foundation, version 3 only (AGPL-3.0-only). See LICENSE.
7
+
8
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY
9
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
10
+ PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
11
+
12
+ Additional permission under GNU AGPL version 3 section 7
13
+
14
+ codemax works by starting and talking to coding agents and their libraries,
15
+ some of which are distributed under their own non-free terms (for example
16
+ Claude Code and @anthropic-ai/claude-agent-sdk, Codex, Grok, Gemini CLI,
17
+ Cursor Agent and GitHub Copilot CLI). If you modify this program, or any
18
+ covered work, by linking or combining it with such an agent program or library
19
+ (or a modified version of it), the copyright holder grants you additional
20
+ permission to convey the resulting work. Corresponding Source for the
21
+ non-source form of such a combination shall not include the source code for
22
+ the parts of those agent programs or libraries that are not covered by this
23
+ License.
24
+
25
+ Trademarks
26
+
27
+ The AGPL covers the code, not the codemax name or logo. See TRADEMARKS.md.
package/README.md CHANGED
@@ -1,3 +1,151 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="assets/logo.svg" width="360" alt="codemax" />
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <p align="center"><b>Every AI coding plan you pay for. One terminal.</b></p>
6
+
7
+ <p align="center">
8
+ Claude Code · Codex · OpenCode · Grok · Cursor · Gemini CLI
9
+ </p>
10
+
11
+ ---
12
+
13
+ ## What you get
14
+
15
+ - **Never stop at a usage limit.** When one agent runs out, press Enter and the next one resumes the same task exactly where it stopped: same plan, the step that was in flight, and the real code changes so far.
16
+ - **One shared task across every agent.** Every step each agent takes is recorded, so a switch is a resume, not a summary. An agent you return to is caught up on what the others did.
17
+ - **Only the subscriptions you already pay for.** No API keys, no per-token bills, no new account.
18
+ - **Every agent asks before acting.** Edits and commands wait for your approval, just like in each agent's own app.
19
+ - **Undo any turn.** Roll back the files a reply changed with `/undo`.
20
+ - **See what you're getting.** `/usage` shows tokens and their API-equivalent value per agent, with daily budgets if you want them.
21
+ - **Free and open source.** Everything stays on your machine.
22
+
23
+ ```
24
+ ❯ fix the failing auth test
25
+ ⏺ Fixed the off-by-one in token expiry. 14 tests pass.
26
+ ⎿ Claude Code reached its daily limit · Enter to continue in Codex
27
+ ───────────────────── Codex · gpt-5.6-terra · new session ─────────────────────
28
+ ❯ now add a test for the refresh path
29
+ ⏺ Added refresh-expiry coverage. 15 tests pass.
30
+ ```
31
+
32
+ ## Get started
33
+
34
+ **macOS and Linux:**
35
+
36
+ ```sh
37
+ curl -fsSL https://codemax.saidev.codes/install | bash
38
+ ```
39
+
40
+ **Windows** (PowerShell; Windows support is new):
41
+
42
+ ```powershell
43
+ irm https://codemax.saidev.codes/install.ps1 | iex
44
+ ```
45
+
46
+ It installs codemax (and [Bun](https://bun.sh) if you don't have it), then lists the agent CLIs it found. You need at least one agent you're logged in to. Then:
47
+
48
+ ```sh
49
+ codemax doctor # confirms nothing will bill an API key
50
+ cd ~/your-project && codemax
51
+ ```
52
+
53
+ It installs the latest release. Run the same command again to update. To uninstall, delete `~/.codemax/cli` and `codemax` (or `codemax.cmd` on Windows) from `~/.bun/bin`.
54
+
55
+ <details>
56
+ <summary>Install from a clone instead</summary>
57
+
58
+ ```sh
59
+ git clone https://github.com/SkidGod4444/codemax-cli.git
60
+ cd codemax-cli && bun install && bun link
61
+ ```
62
+
63
+ </details>
64
+
65
+ ## Supported agents
66
+
67
+ | Agent | Plan it uses | Status |
68
+ |---|---|---|
69
+ | Claude Code | Claude Pro / Max | Supported |
70
+ | Codex | ChatGPT Plus / Pro | Supported |
71
+ | OpenCode | Your OpenCode login | Supported |
72
+ | Grok | SuperGrok | Supported |
73
+ | Cursor Agent | Cursor plans | Supported (needs Cursor Agent 2026.10 or newer). Changing its model with `/model` also changes Cursor's own default. |
74
+ | Gemini CLI | Google AI plans | Early, untested |
75
+ | Copilot CLI | GitHub Copilot plans | In its own UI via `codemax --tui` |
76
+
77
+ ## Everyday keys
78
+
79
+ | | |
80
+ |---|---|
81
+ | `/switch` or **Ctrl+^** | Move to another agent, conversation included |
82
+ | `/ask codex <msg>` | One question to another agent |
83
+ | `@file` | Attach a file to your message |
84
+ | `/task` | The shared task: plan, who did what, where it stopped, files changed |
85
+ | `/undo` | Revert the last turn's changes |
86
+ | `/usage`, `/budget` | Usage and spend per agent |
87
+ | `/model`, `/effort`, **Shift+Tab** | Pick the model or reasoning effort, cycle permission mode |
88
+ | **Ctrl+O** | Expand cut-off tool output and thinking |
89
+ | Select with the mouse | Copies the text to your clipboard |
90
+ | `/theme dark`, `light`, `auto` | Colour theme (auto follows your terminal) |
91
+ | **Esc** | Stop the current turn |
92
+ | `/` | All commands |
93
+
94
+ <details>
95
+ <summary><b>Configuration</b></summary>
96
+
97
+ `~/.codemax/config.json` is created on first run. `codemax config` prints it.
98
+
99
+ | Key | Default | What it does |
100
+ |---|---|---|
101
+ | `order` | `["claude", "codex", "opencode", "cursor", "grok", "gemini", "copilot"]` | Which agent comes next on a handoff |
102
+ | `budgets` | `{}` | Daily limit per agent, e.g. `{"claude": 20, "all": 50}` |
103
+ | `routes` | `[]` | Send matching messages to a specific agent |
104
+ | `checkpoints` | `true` | Snapshots for `/undo` |
105
+ | `autoSwitch` | `true` | Offer a handoff when an agent runs out |
106
+ | `summarizeOnSwitch` | `true` | Ask the outgoing agent for a summary before a switch. Only used for history recorded before the step journal; otherwise the next agent resumes from the recorded steps |
107
+ | `priceUpdates` | `true` | Weekly price list refresh |
108
+ | `hotkeys` | `["ctrl+/", "ctrl+^"]` | Keys that open the agent switcher |
109
+
110
+ </details>
111
+
112
+ <details>
113
+ <summary><b>CLI commands</b></summary>
114
+
115
+ ```
116
+ codemax [--agent <id>] [--fresh] start chatting
117
+ codemax doctor [--fix] check nothing bills an API key
118
+ codemax agents installed agents and their quota
119
+ codemax usage [--all] usage for this project or all projects
120
+ codemax prices [update] price list used for usage values
121
+ codemax history | brief | status | config | keys
122
+ ```
123
+
124
+ </details>
125
+
126
+ <details>
127
+ <summary><b>Privacy and billing</b></summary>
128
+
129
+ - Your code goes only to the agents you already use, under your own logins.
130
+ - codemax always removes vendor API keys from the agents it starts, and `codemax doctor --fix` removes any left in Claude Code's settings.
131
+ - Grok follows your Claude Code permission setting. If that is `auto`, Grok acts without asking, and codemax warns you.
132
+ - Its only network call is a weekly price-list download, which `priceUpdates: false` turns off.
133
+ - Usage values are what your plans covered at public API rates, not money you spent.
134
+
135
+ </details>
136
+
137
+ ## Contributing
138
+
139
+ Issues and pull requests are welcome. First-time contributors sign a short [CLA](CLA.md) on their pull request. See [CONTRIBUTING.md](CONTRIBUTING.md) and [PLAN.md](PLAN.md) for design notes.
140
+
141
+ ```sh
142
+ bun test && bun run typecheck
143
+ ```
144
+
145
+ Not affiliated with Anthropic, OpenAI, xAI, Google or the OpenCode team.
146
+
147
+ ## License
148
+
149
+ Copyright © 2026 [Saidev Dhal](https://saidev.codes). Free and open source under the [GNU AGPL-3.0](LICENSE), with an [added permission](NOTICE) for the agent tools codemax drives.
150
+
151
+ You can use, change and share codemax. If you distribute a modified version, or run one as a service, you must publish your changes under the same license. The codemax name and logo are not covered by the license: forks need their own name. See [TRADEMARKS.md](TRADEMARKS.md).
package/TRADEMARKS.md ADDED
@@ -0,0 +1,27 @@
1
+ # codemax trademark policy
2
+
3
+ The codemax code is open source under the [AGPL-3.0](LICENSE). The **codemax name, the halftone wordmark and the "cm" icon** are not covered by that license. They identify the official project, and this policy says how you can use them.
4
+
5
+ ## You can, without asking
6
+
7
+ - Say your project is **based on codemax** or **a fork of codemax**, or that it **works with codemax**.
8
+ - Use the name to talk about codemax accurately: in articles, talks, tutorials, comparisons and reviews.
9
+ - Keep the name in the code, file names and git history of an unmodified copy.
10
+ - Link to the official project with the name: https://github.com/SkidGod4444/codemax-cli or https://codemax.saidev.codes.
11
+
12
+ ## You can't, without written permission
13
+
14
+ - Name a fork, product, package, command, domain or company **codemax**, or something confusingly close (for example "codemax pro", "codemaxx", "code-max").
15
+ - Use the codemax wordmark or icon for your own project, or a logo that looks like them.
16
+ - Suggest that your project is official, endorsed by or affiliated with codemax or Saidev Dhal.
17
+ - Publish a modified build under the codemax name on a package registry, app store or download page.
18
+
19
+ ## If you fork
20
+
21
+ Give your fork its own name and logo before you publish it. Keep the copyright and license notices in `LICENSE` and `NOTICE`, as the AGPL requires. A line such as "based on codemax" is welcome.
22
+
23
+ ## Questions
24
+
25
+ Ask before you're unsure: open an issue on the repository, or contact Saidev Dhal at https://saidev.codes.
26
+
27
+ This policy may be updated as the project grows.
package/bin/codemax ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/cli.ts";
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // codemax launcher for npm installs (`npm i -g codemax-cli`, `npx codemax-cli`).
3
+ // codemax runs on Bun; this finds a Bun binary and starts src/cli.ts with it:
4
+ // 1. the `bun` npm package installed alongside codemax (a dependency),
5
+ // 2. its platform package directly (when install scripts were skipped),
6
+ // 3. a `bun` already on PATH.
7
+ "use strict";
8
+ const { spawn, spawnSync } = require("node:child_process");
9
+ const { existsSync } = require("node:fs");
10
+ const { dirname, join } = require("node:path");
11
+
12
+ const cli = join(__dirname, "..", "src", "cli.ts");
13
+ const exe = process.platform === "win32" ? "bun.exe" : "bun";
14
+
15
+ function works(path) {
16
+ if (!path || !existsSync(path)) return false;
17
+ const r = spawnSync(path, ["--version"], { stdio: "ignore", timeout: 10000 });
18
+ return r.status === 0;
19
+ }
20
+
21
+ function resolveIn(pkg, ...rest) {
22
+ try {
23
+ return join(dirname(require.resolve(`${pkg}/package.json`)), ...rest);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function isMusl() {
30
+ if (process.platform !== "linux") return false;
31
+ try {
32
+ const report = process.report && process.report.getReport();
33
+ return !(report && report.header && report.header.glibcVersionRuntime);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function platformPackage() {
40
+ const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x64" : null;
41
+ if (!arch) return null;
42
+ if (process.platform === "darwin") return `@oven/bun-darwin-${arch}`;
43
+ if (process.platform === "win32") return `@oven/bun-windows-${arch}`;
44
+ if (process.platform === "linux") return `@oven/bun-linux-${arch}${isMusl() ? "-musl" : ""}`;
45
+ return null;
46
+ }
47
+
48
+ function findBun() {
49
+ const fromPackage = resolveIn("bun", "bin", "bun.exe");
50
+ if (works(fromPackage)) return fromPackage;
51
+ const pp = platformPackage();
52
+ const fromPlatform = pp && resolveIn(pp, "bin", exe);
53
+ if (works(fromPlatform)) return fromPlatform;
54
+ const which = spawnSync(process.platform === "win32" ? "where" : "which", ["bun"], { encoding: "utf8" });
55
+ const onPath = which.status === 0 ? which.stdout.split(/\r?\n/)[0].trim() : null;
56
+ if (works(onPath)) return onPath;
57
+ return null;
58
+ }
59
+
60
+ const bun = findBun();
61
+ if (!bun) {
62
+ console.error("codemax: could not find Bun, which codemax runs on.");
63
+ console.error("Reinstall with `npm i -g codemax-cli` (it installs Bun), or install Bun from https://bun.sh");
64
+ process.exit(1);
65
+ }
66
+
67
+ const child = spawn(bun, [cli, ...process.argv.slice(2)], { stdio: "inherit" });
68
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
69
+ process.on(sig, () => {
70
+ // The TUI handles Ctrl+C itself; forward only what the child would not see.
71
+ if (sig !== "SIGINT") child.kill(sig);
72
+ });
73
+ }
74
+ child.on("exit", (code, signal) => {
75
+ if (signal) process.kill(process.pid, signal);
76
+ else process.exit(code ?? 0);
77
+ });
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "codemax-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "One terminal for every coding agent you pay for: drive Claude Code, Codex, OpenCode, Grok and Gemini CLI from one chat, on your subscriptions, with context that survives switching agents.",
5
+ "keywords": [
6
+ "acp",
7
+ "agent-client-protocol",
8
+ "ai",
9
+ "claude-code",
10
+ "cli",
11
+ "codex",
12
+ "coding-agent",
13
+ "gemini-cli",
14
+ "grok",
15
+ "opencode",
16
+ "terminal",
17
+ "tui"
18
+ ],
19
+ "homepage": "https://github.com/SkidGod4444/codemax-cli#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/SkidGod4444/codemax-cli/issues"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/SkidGod4444/codemax-cli.git"
26
+ },
27
+ "license": "AGPL-3.0-only",
28
+ "author": "Saidev Dhal",
29
+ "type": "module",
30
+ "bin": {
31
+ "codemax": "bin/codemax.cjs"
32
+ },
33
+ "files": [
34
+ "bin",
35
+ "src",
36
+ "README.md",
37
+ "LICENSE",
38
+ "NOTICE",
39
+ "TRADEMARKS.md"
40
+ ],
41
+ "engines": {
42
+ "node": ">=18",
43
+ "bun": ">=1.3.0"
44
+ },
45
+ "scripts": {
46
+ "dev": "bun src/cli.ts",
47
+ "typecheck": "bun x tsc --noEmit",
48
+ "test": "bun test",
49
+ "build": "bun build --compile --minify src/cli.ts --outfile dist/codemax && (test \"$(uname)\" != Darwin || codesign --force --sign - dist/codemax)",
50
+ "prepublishOnly": "bun run typecheck && bun test"
51
+ },
52
+ "dependencies": {
53
+ "@agentclientprotocol/sdk": "1.9.0",
54
+ "@opentui/core": "0.6.6",
55
+ "@zed-industries/claude-code-acp": "0.16.2",
56
+ "bun": "^1.3.5"
57
+ },
58
+ "devDependencies": {
59
+ "@types/bun": "latest",
60
+ "typescript": "^5.6.0"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }
@@ -0,0 +1,43 @@
1
+ import { join } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import type { Adapter, LaunchOptions, LaunchSpec, SessionRef } from "./types.ts";
4
+ import { HOME, escapeCwd } from "../core/paths.ts";
5
+ import { detectBin } from "./detect.ts";
6
+ import { parseClaudeTranscript } from "../transcripts/claude.ts";
7
+
8
+ function transcriptPath(cwd: string, id: string) {
9
+ return join(HOME, ".claude", "projects", escapeCwd(cwd), `${id}.jsonl`);
10
+ }
11
+
12
+ export const claude: Adapter = {
13
+ id: "claude",
14
+ name: "Claude Code",
15
+ bin: "claude",
16
+ detect: () => detectBin("claude", ["--version"]),
17
+ newSessionId: () => crypto.randomUUID(),
18
+ launch(o: LaunchOptions): LaunchSpec {
19
+ // Settings files can inject ANTHROPIC_API_KEY via their "env" block; blank it so the
20
+ // claude.ai login is billed. (The ACP engine path cannot pass this and is gated by doctor.)
21
+ const cmd = ["claude", "--settings", JSON.stringify({ env: { ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "" } })];
22
+ if (o.resume) cmd.push("--resume", o.resume.id);
23
+ else if (o.sessionId) cmd.push("--session-id", o.sessionId);
24
+ if (o.extraArgs) cmd.push(...o.extraArgs);
25
+ // Interactive claude accepts an initial prompt as the positional argument.
26
+ if (o.prompt) cmd.push(o.prompt);
27
+ return { cmd, promptConsumed: !!o.prompt };
28
+ },
29
+ async locateSession({ cwd, startedAt, sessionId }) {
30
+ if (!sessionId) return null;
31
+ // The transcript appears after the first message; until then the session
32
+ // does not exist on Claude's side and could not be resumed.
33
+ const p = transcriptPath(cwd, sessionId);
34
+ if (!existsSync(p)) return null;
35
+ return { agent: "claude", id: sessionId, transcriptPath: p, cwd, startedAt, lastUsedAt: Date.now() };
36
+ },
37
+ async readTranscript(ref: SessionRef) {
38
+ const p = ref.transcriptPath ?? transcriptPath(ref.cwd, ref.id);
39
+ return parseClaudeTranscript(p, ref.id);
40
+ },
41
+ quotaPatterns: [/claude (ai )?usage limit reached/i, /out of extra usage/i, /limit will reset at/i],
42
+ apiKeyEnv: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
43
+ };
@@ -0,0 +1,101 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs";
3
+ import type { Adapter, LaunchOptions, LaunchSpec, SessionRef } from "./types.ts";
4
+ import { HOME } from "../core/paths.ts";
5
+ import { detectBin } from "./detect.ts";
6
+ import { parseCodexTranscript, codexQuotaFromRollout } from "../transcripts/codex.ts";
7
+
8
+ const SESSIONS = join(HOME, ".codex", "sessions");
9
+
10
+ /** First line of a file. session_meta lines run to tens of KB, so read in chunks until a newline. */
11
+ function firstLine(path: string, max = 4 * 1024 * 1024): string {
12
+ const fd = openSync(path, "r");
13
+ try {
14
+ const chunks: Buffer[] = [];
15
+ let total = 0;
16
+ while (total < max) {
17
+ const buf = Buffer.alloc(65536);
18
+ const n = readSync(fd, buf, 0, buf.length, total);
19
+ if (n <= 0) break;
20
+ chunks.push(buf.subarray(0, n));
21
+ total += n;
22
+ if (buf.subarray(0, n).includes(0x0a)) break;
23
+ }
24
+ return Buffer.concat(chunks).toString("utf8").split("\n")[0] ?? "";
25
+ } finally {
26
+ closeSync(fd);
27
+ }
28
+ }
29
+
30
+ /** Rollout files written on/after `since`, newest first. Scans today and yesterday. */
31
+ function recentRollouts(since: number): string[] {
32
+ const out: string[] = [];
33
+ for (const dayOffset of [0, 1]) {
34
+ const d = new Date(since - dayOffset * 86_400_000);
35
+ const dir = join(SESSIONS, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, "0"), String(d.getDate()).padStart(2, "0"));
36
+ if (!existsSync(dir)) continue;
37
+ for (const f of readdirSync(dir)) {
38
+ if (!f.endsWith(".jsonl")) continue;
39
+ const p = join(dir, f);
40
+ if (statSync(p).mtimeMs >= since - 2000) out.push(p);
41
+ }
42
+ }
43
+ return out.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
44
+ }
45
+
46
+ function findRolloutById(id: string): string | undefined {
47
+ // Walk year/month/day dirs, newest first.
48
+ if (!existsSync(SESSIONS)) return undefined;
49
+ const years = readdirSync(SESSIONS).sort().reverse();
50
+ for (const y of years) {
51
+ const yd = join(SESSIONS, y);
52
+ for (const m of readdirSync(yd).sort().reverse()) {
53
+ const md = join(yd, m);
54
+ for (const d of readdirSync(md).sort().reverse()) {
55
+ const dd = join(md, d);
56
+ const hit = readdirSync(dd).find((f) => f.includes(id));
57
+ if (hit) return join(dd, hit);
58
+ }
59
+ }
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ export const codex: Adapter = {
65
+ id: "codex",
66
+ name: "Codex",
67
+ bin: "codex",
68
+ detect: () => detectBin("codex", ["--version"]),
69
+ launch(o: LaunchOptions): LaunchSpec {
70
+ // Global flags go before the subcommand so `resume` accepts them too.
71
+ const cmd = ["codex", "-C", o.cwd];
72
+ if (o.resume) cmd.push("resume", o.resume.id);
73
+ if (o.extraArgs) cmd.push(...o.extraArgs);
74
+ if (o.prompt) cmd.push(o.prompt);
75
+ return { cmd, promptConsumed: !!o.prompt };
76
+ },
77
+ async locateSession({ cwd, startedAt }) {
78
+ for (const p of recentRollouts(startedAt)) {
79
+ try {
80
+ const meta = JSON.parse(firstLine(p));
81
+ if (meta.type === "session_meta" && meta.payload?.cwd === cwd) {
82
+ return { agent: "codex", id: meta.payload.id ?? meta.payload.session_id, transcriptPath: p, cwd, startedAt, lastUsedAt: Date.now() };
83
+ }
84
+ } catch {
85
+ /* partial write, try next */
86
+ }
87
+ }
88
+ return null;
89
+ },
90
+ async readTranscript(ref: SessionRef) {
91
+ const p = ref.transcriptPath ?? findRolloutById(ref.id);
92
+ return p ? parseCodexTranscript(p, ref.id) : [];
93
+ },
94
+ async probeQuota(ref) {
95
+ const p = ref?.transcriptPath ?? (ref ? findRolloutById(ref.id) : undefined);
96
+ if (!p) return null;
97
+ return codexQuotaFromRollout(p);
98
+ },
99
+ quotaPatterns: [/you['’]ve hit your usage limit/i, /usage limit reached/i],
100
+ apiKeyEnv: ["OPENAI_API_KEY"],
101
+ };
@@ -0,0 +1,16 @@
1
+ import type { DetectResult } from "./types.ts";
2
+ import { platformArgv } from "../core/spawn.ts";
3
+
4
+ export async function detectBin(bin: string, versionArgs: string[] = ["--version"]): Promise<DetectResult> {
5
+ const path = Bun.which(bin);
6
+ if (!path) return { installed: false };
7
+ try {
8
+ const p = Bun.spawn(platformArgv([path, ...versionArgs]), { stdout: "pipe", stderr: "pipe" });
9
+ const text = (await new Response(p.stdout).text()).trim();
10
+ await p.exited;
11
+ const m = text.match(/\d+\.\d+(\.\d+)?/);
12
+ return { installed: true, path, version: m?.[0] ?? text.split("\n")[0] };
13
+ } catch {
14
+ return { installed: true, path };
15
+ }
16
+ }
@@ -0,0 +1,21 @@
1
+ import type { Adapter, AgentId } from "./types.ts";
2
+ import { claude } from "./claude.ts";
3
+ import { codex } from "./codex.ts";
4
+ import { opencode, cursor, grok, gemini, copilot, fake } from "./simple.ts";
5
+
6
+ const all: Adapter[] = [claude, codex, opencode, cursor, grok, gemini, copilot];
7
+ if (process.env.CODEMAX_FAKE_AGENT) all.push(fake);
8
+
9
+ export const adapters: Record<string, Adapter> = Object.fromEntries(all.map((a) => [a.id, a]));
10
+
11
+ export function getAdapter(id: string): Adapter {
12
+ const a = adapters[id];
13
+ if (!a) throw new Error(`unknown agent "${id}". Known: ${Object.keys(adapters).join(", ")}`);
14
+ return a;
15
+ }
16
+
17
+ export function listAdapters(order: AgentId[]): Adapter[] {
18
+ const ranked = order.map((id) => adapters[id]).filter((a): a is Adapter => !!a);
19
+ const rest = all.filter((a) => !ranked.includes(a));
20
+ return [...ranked, ...rest];
21
+ }
@@ -0,0 +1,89 @@
1
+ import type { Adapter, AgentId, LaunchOptions, LaunchSpec } from "./types.ts";
2
+ import { detectBin } from "./detect.ts";
3
+
4
+ /**
5
+ * Adapters for agents whose transcripts we do not parse yet (v0.2 work).
6
+ * They launch, pass through, and get the handoff brief typed into the TUI.
7
+ */
8
+ function simple(opts: {
9
+ id: AgentId;
10
+ name: string;
11
+ bin: string;
12
+ versionArgs?: string[];
13
+ resumeArgs?: (id: string) => string[];
14
+ promptPositional?: boolean;
15
+ patterns?: RegExp[];
16
+ apiKeyEnv?: string[];
17
+ }): Adapter {
18
+ return {
19
+ id: opts.id,
20
+ name: opts.name,
21
+ bin: opts.bin,
22
+ detect: () => detectBin(opts.bin, opts.versionArgs),
23
+ launch(o: LaunchOptions): LaunchSpec {
24
+ const cmd = [opts.bin];
25
+ if (o.resume && opts.resumeArgs) cmd.push(...opts.resumeArgs(o.resume.id));
26
+ if (o.extraArgs) cmd.push(...o.extraArgs);
27
+ const consumed = !!o.prompt && !!opts.promptPositional;
28
+ if (consumed) cmd.push(o.prompt!);
29
+ return { cmd, promptConsumed: consumed };
30
+ },
31
+ async locateSession() {
32
+ return null;
33
+ },
34
+ quotaPatterns: opts.patterns ?? [],
35
+ apiKeyEnv: opts.apiKeyEnv ?? [],
36
+ };
37
+ }
38
+
39
+ export const opencode = simple({
40
+ id: "opencode",
41
+ name: "OpenCode",
42
+ bin: "opencode",
43
+ // TUI positional is a project path, not a prompt → brief is typed in.
44
+ patterns: [/usage limit reached/i],
45
+ });
46
+
47
+ export const cursor = simple({
48
+ id: "cursor",
49
+ name: "Cursor Agent",
50
+ bin: "cursor-agent",
51
+ resumeArgs: (id) => ["--resume", id],
52
+ promptPositional: true,
53
+ patterns: [/usage limit reached/i, /out of (fast )?requests/i],
54
+ apiKeyEnv: ["CURSOR_API_KEY"],
55
+ });
56
+
57
+ export const grok = simple({
58
+ id: "grok",
59
+ name: "Grok CLI",
60
+ bin: "grok",
61
+ resumeArgs: (id) => ["--resume", id],
62
+ patterns: [/usage limit reached/i],
63
+ apiKeyEnv: ["XAI_API_KEY", "GROK_API_KEY"],
64
+ });
65
+
66
+ export const gemini = simple({
67
+ id: "gemini",
68
+ name: "Gemini CLI",
69
+ bin: "gemini",
70
+ promptPositional: false,
71
+ patterns: [/quota exceeded/i, /RESOURCE_EXHAUSTED/],
72
+ apiKeyEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
73
+ });
74
+
75
+ export const copilot = simple({
76
+ id: "copilot",
77
+ name: "Copilot CLI",
78
+ bin: "copilot",
79
+ patterns: [/premium requests? (limit|exhausted|used up)/i],
80
+ });
81
+
82
+ /** Test-only agent: a plain shell, enabled with CODEMAX_FAKE_AGENT=1. */
83
+ export const fake = simple({
84
+ id: "fake",
85
+ name: "Fake (sh)",
86
+ bin: "sh",
87
+ versionArgs: ["-c", "echo 0.0.0"],
88
+ patterns: [/FAKE_QUOTA_HIT/],
89
+ });