cheap-agent 0.0.0-stage → 1.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,95 @@
1
+ Copyright (c) 2026 muaaz9329
2
+
3
+ Elastic License 2.0
4
+
5
+ URL: https://www.elastic.co/licensing/elastic-license
6
+
7
+ Acceptance
8
+
9
+ By using the software, you agree to all of the terms and conditions below.
10
+
11
+ Copyright License
12
+
13
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
14
+ non-sublicensable, non-transferable license to use, copy, distribute, make
15
+ available, and prepare derivative works of the software, in each case subject to
16
+ the limitations and conditions below.
17
+
18
+ Limitations
19
+
20
+ You may not provide the software to third parties as a hosted or managed
21
+ service, where the service provides users with access to any substantial set of
22
+ the features or functionality of the software.
23
+
24
+ You may not move, change, disable, or circumvent the license key functionality
25
+ in the software, and you may not remove or obscure any functionality in the
26
+ software that is protected by the license key.
27
+
28
+ You may not alter, remove, or obscure any licensing, copyright, or other notices
29
+ of the licensor in the software. Any use of the licensor’s trademarks is subject
30
+ to applicable law.
31
+
32
+ Patents
33
+
34
+ The licensor grants you a license, under any patent claims the licensor can
35
+ license, or becomes able to license, to make, have made, use, sell, offer for
36
+ sale, import and have imported the software, in each case subject to the
37
+ limitations and conditions in this license. This license does not cover any
38
+ patent claims that you cause to be infringed by modifications or additions to
39
+ the software. If you or your company make any written claim that the software
40
+ infringes or contributes to infringement of any patent, your patent license for
41
+ the software granted under these terms ends immediately. If your company makes
42
+ such a claim, your patent license ends immediately for work on behalf of your
43
+ company.
44
+
45
+ Notices
46
+
47
+ You must ensure that anyone who gets a copy of any part of the software from you
48
+ also gets a copy of these terms.
49
+
50
+ If you modify the software, you must include in any modified copies of the
51
+ software prominent notices stating that you have modified the software.
52
+
53
+ ## No Other Rights
54
+
55
+ These terms do not imply any licenses other than those expressly granted in
56
+ these terms.
57
+
58
+ Termination
59
+
60
+ If you use the software in violation of these terms, such use is not licensed,
61
+ and your licenses will automatically terminate. If the licensor provides you
62
+ with a notice of your violation, and you cease all violation of this license no
63
+ later than 30 days after you receive that notice, your licenses will be
64
+ reinstated retroactively. However, if you violate these terms after such
65
+ reinstatement, any additional violation of these terms will cause your licenses
66
+ to terminate automatically and permanently.
67
+
68
+ No Liability
69
+
70
+ As far as the law allows, the software comes as is, without any warranty or
71
+ condition, and the licensor will not be liable to you for any damages arising
72
+ out of these terms or the use or nature of the software, under any kind of
73
+ legal claim.
74
+
75
+ Definitions
76
+
77
+ The licensor is the entity offering these terms, and the software is the
78
+ software the licensor makes available under these terms, including any portion
79
+ of it.
80
+
81
+ you refers to the individual or entity agreeing to these terms.
82
+
83
+ your company is any legal entity, sole proprietorship, or other kind of
84
+ organization that you work for, plus all organizations that have control over,
85
+ are under the control of, or are under common control with that
86
+ organization. control means ownership of substantially all the assets of an
87
+ entity, or the power to direct its management and policies by vote, contract, or
88
+ otherwise. Control can be direct or indirect.
89
+
90
+ your licenses are all the licenses granted to you for the software under
91
+ these terms.
92
+
93
+ use means anything you do with the software requiring one of your licenses.
94
+
95
+ trademark means trademarks, service marks, and similar rights.
package/README.md CHANGED
@@ -1,3 +1,188 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">Cheap Agent</h1>
2
2
 
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.
3
+ <p align="center">
4
+ <em>Your Opus is a principal engineer. Stop paying it to type.</em>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <img src="https://img.shields.io/npm/v/cheap-agent?style=flat-square&color=111111&label=npm" alt="npm">
9
+ <img src="https://img.shields.io/npm/dt/cheap-agent?style=flat-square&color=111111&label=downloads" alt="npm downloads">
10
+ <img src="https://img.shields.io/badge/for-Claude%20Code-111111?style=flat-square" alt="For Claude Code">
11
+ <img src="https://img.shields.io/badge/cheap%20model-%C2%BD%C2%A2%20a%20task-111111?style=flat-square" alt="Cheap model: half a cent a task">
12
+ <img src="https://img.shields.io/badge/tested%20on-SWE--bench%20Verified-111111?style=flat-square" alt="Tested on SWE-bench Verified">
13
+ <img src="https://img.shields.io/badge/license-Elastic%202.0-111111?style=flat-square" alt="Elastic License 2.0">
14
+ </p>
15
+
16
+ <p align="center">
17
+ <img src="assets/hero.svg" width="880" alt="Same feature, same prompt: Claude Code alone used 154k Opus tokens in 5 turns; with Cheap Agent 57k Opus tokens in 2 turns, 63% fewer, and the cheap model cost half a cent. Both versions pass all their tests.">
18
+ </p>
19
+
20
+ <p align="center">
21
+ <strong>Same feature. Same prompt. 63% fewer Opus tokens.</strong><br>
22
+ <strong>Your Claude plan lasts longer. Your code still ships with tests.</strong>
23
+ </p>
24
+
25
+ ---
26
+
27
+ You know the principal engineer. Twenty years in. Sees the whole system at once. Costs a fortune per hour.
28
+
29
+ You would never pay them to type out a CSV exporter.
30
+
31
+ But that's exactly what happens every time Opus writes your code: the most expensive model you have, reading files, typing boilerplate, re-running tests, burning through your plan.
32
+
33
+ **Cheap Agent fixes the org chart.** Opus plans and decides. A model that costs half a cent per task does the typing, in its own copy of your project, and has to prove its work with a test before anything reaches you.
34
+
35
+ ## Why I built this
36
+
37
+ The idea: get **$200-plan usage out of the $100 Claude plan**, and **$50–70 worth out of the $20 plan**, with a few dollars of DeepSeek doing the typing.
38
+
39
+ I've met a lot of students, mostly in developing countries, who really want Opus for the hard problems and simply can't afford $100 a month. I wanted to find something that helps them.
40
+
41
+ This is a small effort from my side. It won't make Opus ten times cheaper. But stretching the plan you already pay for is something, and something is better than nothing.
42
+
43
+ ## Install
44
+
45
+ **Easiest:** paste this into Claude Code. It asks what you want, then gives you one command to run.
46
+
47
+ ```text
48
+ Set up Cheap Agent (npm: cheap-agent) for me.
49
+ 1. Ask me: which provider (OpenRouter recommended, DeepSeek or OpenCode Go), and which
50
+ models (balanced is the default; cheapest, strongest, or my own provider/model).
51
+ 2. Check that node is 20+ and the claude CLI is installed.
52
+ 3. If I picked my own model, save {"models":{"implement":"<model>"}} in
53
+ ~/.config/cheapag/config.json, keeping what else is there.
54
+ 4. Give me the command to run in my terminal: npx cheap-agent login --preset <my choice,
55
+ or custom>, and which number to pick for my provider (1 OpenCode Go, 2 OpenRouter,
56
+ 3 DeepSeek). I run it and type my API key there. Never ask for my key.
57
+ 5. When I say done, check `claude mcp list` shows cheapag, then tell me to run
58
+ /cheapag:allow in a new Claude Code session in my project.
59
+ ```
60
+
61
+ **Or do it yourself:**
62
+
63
+ ```bash
64
+ npx cheap-agent
65
+ ```
66
+
67
+ Pick a provider (**OpenRouter** if unsure), press Enter for the recommended models, paste your API key. It checks the key works, then adds itself to Claude Code for every project.
68
+
69
+ Then, in each project, once:
70
+
71
+ ```
72
+ /cheapag:allow
73
+ ```
74
+
75
+ **That's it.** Ask Claude for changes like you always do.
76
+
77
+ **You need:**
78
+ - [Claude Code](https://claude.com/claude-code)
79
+ - [Node.js](https://nodejs.org) 20+
80
+ - An API key with a few dollars on it from [OpenRouter](https://openrouter.ai/keys), [DeepSeek](https://platform.deepseek.com) or [OpenCode Go](https://opencode.ai)
81
+
82
+ A few dollars lasts hundreds of tasks. It runs on macOS and Linux.
83
+
84
+ **Windows (beta):** tested in CI, not yet by a Windows user. You also need [Git for Windows](https://git-scm.com/downloads/win) (Git Bash). Known so far:
85
+ - The very first job can take about 2 minutes while the cheap model's engine (OpenCode) sets itself up. Later jobs start in seconds.
86
+ - If that engine crashes mid-session, Cheap Agent starts a new one, but the crashed copy can stay running (`opencode.exe` in Task Manager) until you end it or restart.
87
+ - The cheap model can't read your dependency folders (like `node_modules`) inside its copy of the project. Your tests still run normally.
88
+
89
+ ## The numbers
90
+
91
+ Tested on [SWE-bench Verified](https://www.swebench.com): real bugs from real open-source projects, graded by the official harness. Same prompts in Claude Code with Opus 5.5, with and without Cheap Agent.
92
+
93
+ <p align="center">
94
+ <img src="assets/chart.svg" width="880" alt="Opus tokens, Claude Code alone versus with Cheap Agent. Adding a feature: 154k vs 57k, 63% less, both pass all tests. 7 everyday bug fixes: 1.6M vs 655k, 59% less, 7 of 7 solved by both. 9 of the hardest bugs: 3.0M vs 1.0M, 67% less, 5 of 9 solved vs 7 of 9, with a Sonnet helper doing the leftovers.">
95
+ </p>
96
+
97
+ **What I won't hide:**
98
+ - On the very hardest bugs it solved 5 of 9 where plain Opus solved 7.
99
+ - On tiny jobs (a few lines, or a whole small app from nothing), Opus is already quick, so there's nothing to save.
100
+
101
+ Cheap Agent shines on the work in between: real features and real fixes in real code. That's most of your day.
102
+
103
+ ## Before / after
104
+
105
+ **The ask:** *"Add monthly budgets to the expense tracker, plus a CSV export. Tests for everything."*
106
+
107
+ | | Claude Code alone | Claude Code + Cheap Agent |
108
+ |---|---|---|
109
+ | Opus tokens | 154k | **57k** |
110
+ | Opus turns | 5 | **2** |
111
+ | Who wrote the code | Opus | a cheap model, for **$0.005** |
112
+ | Tests | 33 passing | **38 passing** |
113
+
114
+ Opus wrote one task. The cheap model did the rest, tests included.
115
+
116
+ ## Built with itself
117
+
118
+ Native Windows support (36 files, +753/−278 lines) was built with Cheap Agent. Claude found the problems and wrote the plans. The cheap model wrote the code in 30 jobs for **$0.29 in total**, Sonnet helpers finished what it couldn't and read OpenCode's source to find the two Windows-only bugs, and the full test suite now passes on Linux and Windows.
119
+
120
+ ## How it works
121
+
122
+ <p align="center">
123
+ <img src="assets/flow.svg" width="880" alt="How it works: Opus writes a short task; a cheap model works in its own copy of your project, writes a test that fails, fixes the code and makes the test pass; Cheap Agent runs the tests itself and only passing work lands, uncommitted; Opus reads a ten-line result and tells you. If the cheap model can't finish, a Sonnet helper finishes the step instead of Opus.">
124
+ </p>
125
+
126
+ - **Every change is proven.** The cheap model must write a test that fails before its fix and passes after. Cheap Agent runs the tests itself; it doesn't take the model's word for it.
127
+ - **Your project stays clean.** Work happens in a private copy (a git worktree). Only passing work lands, uncommitted, so you review the diff like any other.
128
+ - **Opus stays in charge,** but it's held to it: Cheap Agent's hooks stop Opus from quietly redoing work that already passed, or doing a failed step itself when Sonnet can.
129
+
130
+ ## Commands
131
+
132
+ | Command | What it does |
133
+ |---|---|
134
+ | `/cheapag:allow` | Your OK to send this project's code to the cheap model. Once per project. |
135
+ | `/cheapag:status` | Recent jobs, and what the cheap model has cost you. |
136
+ | `/cheapag:hook on` | No more "I'll just do it myself": Claude *must* hand code changes to the cheap model. |
137
+ | `/cheapag:clean` | Tidy up old job folders. |
138
+
139
+ ## Settings
140
+
141
+ Optional. Put a `cheapag.json` in a project:
142
+
143
+ | Setting | Default | What it does |
144
+ |---|---|---|
145
+ | `takeover` | `"sonnet"` | Who finishes a step the cheap model couldn't: a Sonnet helper, or `"self"` (Opus, capped at 15 tool calls). |
146
+ | `limits.max_turns` | `25` | The most turns the cheap model gets per step. |
147
+ | `models` | from setup | Which cheap models to use. Switch with `npx cheap-agent login --preset strongest`. |
148
+
149
+ ## FAQ
150
+
151
+ **Is a half-cent model actually any good?**
152
+ On its own, no. With a written task from Opus, a test it has to pass, and a referee that runs that test, yes, most of the time. When it isn't, it says so and Sonnet takes over.
153
+
154
+ **Does Claude check the work?**
155
+ The tests check the work, and Cheap Agent runs them. Opus checking every passing change cost three times the tokens and caught nothing in my runs, so it trusts a pass. You still review the diff before you commit.
156
+
157
+ **Where does my code go?**
158
+ To the provider you picked, only after you run `/cheapag:allow` in that project, which also shows exactly which models will see it. Your API key stays on your machine, in a hidden prompt and a local credential store, never in chat.
159
+
160
+ **Will it commit or push anything?**
161
+ Never. Changes land uncommitted. Your git, your call.
162
+
163
+ **Why not just use Sonnet for everything?**
164
+ Sonnet still costs real usage on every line it types. The cheap model types for half a cent. Sonnet only gets called when the cheap model is stuck.
165
+
166
+ **Can I sell it as a service?**
167
+ No. Use it, change it, share it, at any company, for free. Just don't host it and sell it ([Elastic License 2.0](./LICENSE)).
168
+
169
+ **Why "Cheap Agent"?**
170
+ Because your plan isn't.
171
+
172
+ ## Update or remove
173
+
174
+ **Update:**
175
+
176
+ ```bash
177
+ npx cheap-agent@latest
178
+ ```
179
+
180
+ **Remove** (two commands):
181
+
182
+ ```bash
183
+ claude plugin uninstall cheapag@cheapag
184
+ ```
185
+
186
+ ```bash
187
+ claude mcp remove -s user cheapag
188
+ ```
package/dist/allow.js ADDED
@@ -0,0 +1,48 @@
1
+ import { consentGate, describe, writeConsent } from "./consent.js";
2
+ import { detectProvider, LOGIN_COMMAND, providerLabel, redact } from "./credentials.js";
3
+ import { modelName, routeModels } from "./routing.js";
4
+ import { projectRoot } from "./workspace.js";
5
+ const USAGE = "usage: cheapag allow [--zdr-only | --models <provider/model,…>]";
6
+ /**
7
+ * `cheapag allow`: the developer's opt-in for the current repo (spec: Consent). Non-interactive, so the plugin's
8
+ * user-only `/cheapag:allow` can wrap it. No MCP tool calls this. Modes: all models (no flag), zero-retention only,
9
+ * or an explicit model list. The consent is for the provider logged in now; a later switch asks again.
10
+ */
11
+ export function allow(args) {
12
+ const out = (line) => process.stdout.write(redact(line) + "\n");
13
+ let consent;
14
+ if (!args.length)
15
+ consent = { mode: "all" };
16
+ else if (args.length === 1 && args[0] === "--zdr-only")
17
+ consent = { mode: "zdr-only" };
18
+ else if (args.length === 2 && args[0] === "--models" && args[1].split(",").some((m) => m.trim())) {
19
+ consent = { mode: "models", models: args[1].split(",").map((m) => m.trim()).filter(Boolean) };
20
+ }
21
+ else {
22
+ out(`✗ ${USAGE}`);
23
+ return 1;
24
+ }
25
+ const provider = detectProvider();
26
+ if (!provider) {
27
+ out(`✗ no worker provider; run ${LOGIN_COMMAND} first`);
28
+ return 1;
29
+ }
30
+ let root;
31
+ try {
32
+ root = projectRoot(process.cwd());
33
+ }
34
+ catch (err) {
35
+ out(`✗ ${err.message}`);
36
+ return 1;
37
+ }
38
+ const record = { ...consent, provider: provider.provider, approvedAt: new Date().toISOString() };
39
+ writeConsent(root, record);
40
+ out(`✓ ${root}: code may go to ${providerLabel(provider.provider)} (${describe(record)})`);
41
+ // the same gate delegate uses, so what this prints is what jobs will run
42
+ const gate = consentGate(root, provider.provider);
43
+ if ("block" in gate)
44
+ out(`! ${gate.block}`);
45
+ else
46
+ out(` models: ${routeModels(gate.route).map(modelName).join(", ")}`);
47
+ return 0;
48
+ }
@@ -0,0 +1,173 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadConfig } from "./config.js";
4
+ import { redact } from "./credentials.js";
5
+ import { recoverJobs } from "./jobs.js";
6
+ import { isAlive, removeLink } from "./platform.js";
7
+ import { git, projectRoot } from "./workspace.js";
8
+ /**
9
+ * Cleanup (spec: Concurrency, platform, cleanup; A20), part of the Workspace module: at server start and on
10
+ * `cheapag clean`. Unlanded worktrees go after `retention.unlanded_worktree_days`, job folders after `job_log_days`
11
+ * (`all`: both at once). It only ever touches `.cheapag/worktrees/<id>` and `.cheapag/jobs/<id>`: consent.json,
12
+ * opencode/ and land.lock are never cleaned. Run `recoverJobs` first, so a crashed server's jobs age like any other.
13
+ */
14
+ const DAY_MS = 86_400_000;
15
+ // only folders named like our job ids are ours to remove
16
+ const JOB_ID = /^j[0-9a-z]{4}$/;
17
+ /** A job (`j…`) or research (`r…`) log folder under `.cheapag/jobs/`. */
18
+ const LOG_ID = /^[jr][0-9a-z]{4}$/;
19
+ const BATCH_FILE = /^b[0-9a-z]{4}\.json$/;
20
+ /** How long a job folder without a readable record counts as just created, so even --all leaves it alone. */
21
+ const UNKNOWN_GRACE_MS = 60 * 60_000;
22
+ export function cleanup(root, retention, linkDirs, opts = {}) {
23
+ const now = opts.now ?? Date.now();
24
+ const out = { removed: [], kept: [] };
25
+ const state = join(root, ".cheapag");
26
+ // a symlinked state dir (or subdir) would put what we delete outside the repo: leave it alone
27
+ if (!realDir(state))
28
+ return out;
29
+ const jobsDir = join(state, "jobs");
30
+ const treesDir = join(state, "worktrees");
31
+ const days = (since) => Math.floor((now - since) / DAY_MS);
32
+ const old = (since, limit) => opts.all || now - since >= limit * DAY_MS;
33
+ /** A job folder's record: whether it is in flight, and its age from when it ended (else started), else the folder's mtime. */
34
+ const job = (id) => {
35
+ const dir = join(jobsDir, id);
36
+ let record;
37
+ try {
38
+ // research logs keep their record as research.json (never in flight across a restart: it has no RUNNING state)
39
+ record = JSON.parse(readFileSync(join(dir, id.startsWith("r") ? "research.json" : "result.json"), "utf8"));
40
+ }
41
+ catch {
42
+ // no record (yet), or unreadable
43
+ }
44
+ // the record's own time, not mtime: recovery rewrites a record long after its job stopped
45
+ const at = Date.parse(record?.timing?.endedAt ?? record?.timing?.startedAt ?? "");
46
+ // no readable record: a job being created (or its record being rewritten) right now, unless the folder is long untouched
47
+ const unknown = !record && now - (mtime(dir) ?? 0) < UNKNOWN_GRACE_MS;
48
+ const live = unknown || ((record?.status === "RUNNING" || record?.status === "BLOCKED") && !!record.pid && isAlive(record.pid));
49
+ return { live, status: record?.status ?? (unknown ? "STARTING" : undefined), since: Number.isNaN(at) ? mtime(dir) ?? mtime(join(treesDir, id)) ?? now : at };
50
+ };
51
+ let removedTree = false;
52
+ for (const id of entries(treesDir, LOG_ID)) { // r…: a research worktree left by a crash
53
+ const path = join(treesDir, id);
54
+ const j = job(id);
55
+ if (j.live)
56
+ out.kept.push(`worktree ${id}: ${j.status} job in flight`);
57
+ else if (!old(j.since, retention.unlanded_worktree_days))
58
+ out.kept.push(`worktree ${id}: ${days(j.since)}d old, kept ${retention.unlanded_worktree_days}d`);
59
+ else {
60
+ // unlink first, so nothing can reach through into the real dependency dirs
61
+ for (const dir of linkDirs)
62
+ removeLink(join(path, dir));
63
+ try {
64
+ git(root, ["worktree", "remove", "--force", path]);
65
+ }
66
+ catch {
67
+ rmSync(path, { recursive: true, force: true }); // git no longer knows it; rm never follows symlinks
68
+ }
69
+ removedTree = true;
70
+ out.removed.push(`worktree ${id}: ${opts.all ? "--all" : `unlanded, ${days(j.since)}d old`}`);
71
+ }
72
+ }
73
+ if (removedTree)
74
+ git(root, ["worktree", "prune"]);
75
+ for (const id of entries(jobsDir, LOG_ID)) {
76
+ const j = job(id);
77
+ if (j.live)
78
+ out.kept.push(`job ${id}: ${j.status} job in flight`);
79
+ // the record explains its worktree: it stays while the worktree does
80
+ else if (realDir(join(treesDir, id)))
81
+ out.kept.push(`job ${id}: its worktree is kept`);
82
+ else if (!old(j.since, retention.job_log_days))
83
+ out.kept.push(`job ${id}: ${days(j.since)}d old, kept ${retention.job_log_days}d`);
84
+ else {
85
+ rmSync(join(jobsDir, id), { recursive: true, force: true });
86
+ out.removed.push(`job ${id}: ${opts.all ? "--all" : `${days(j.since)}d old`}`);
87
+ }
88
+ }
89
+ // a batch record goes with its last job folder; one that never got a job (a crashed start) goes by age
90
+ const batchesDir = join(state, "batches");
91
+ if (realDir(batchesDir)) {
92
+ for (const name of readdirSync(batchesDir).filter((n) => BATCH_FILE.test(n))) {
93
+ const file = join(batchesDir, name);
94
+ let jobs = [];
95
+ try {
96
+ jobs = JSON.parse(readFileSync(file, "utf8")).jobs ?? [];
97
+ }
98
+ catch {
99
+ // unreadable: judged by age alone
100
+ }
101
+ const id = name.slice(0, -".json".length);
102
+ if (jobs.some((j) => JOB_ID.test(j) && existsSync(join(jobsDir, j))))
103
+ continue; // its jobs say what it was: kept silently
104
+ const since = mtime(file) ?? now;
105
+ if (jobs.length === 0 && !old(since, retention.job_log_days))
106
+ continue;
107
+ rmSync(file, { force: true });
108
+ out.removed.push(`batch ${id}: ${jobs.length ? "its jobs are gone" : opts.all ? "--all" : `${days(since)}d old`}`);
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /** Cleans the repo containing `cwd` with its configured retention (throws on a bad config or outside a repo). */
114
+ export function cleanProject(cwd, all = false) {
115
+ const root = projectRoot(cwd);
116
+ const config = loadConfig(root);
117
+ return cleanup(root, config.retention, config.link_dirs, { all });
118
+ }
119
+ /** `cheapag clean [--all]`: non-interactive, so the plugin's `/cheapag:clean` can wrap it; prints what went and what stayed. */
120
+ export function clean(args) {
121
+ const out = (line) => process.stdout.write(redact(line) + "\n");
122
+ if (args.length > 1 || (args.length && args[0] !== "--all")) {
123
+ out("✗ usage: cheapag clean [--all] (--all: remove everything not in flight)");
124
+ return 1;
125
+ }
126
+ let result;
127
+ try {
128
+ recoverJobs(process.cwd()); // a crashed server's RUNNING/BLOCKED jobs become CANCELLED, then age normally
129
+ result = cleanProject(process.cwd(), args[0] === "--all");
130
+ }
131
+ catch (err) {
132
+ out(`✗ ${err.message}`);
133
+ return 1;
134
+ }
135
+ out(`✓ removed ${result.removed.length}, kept ${result.kept.length}`);
136
+ for (const line of result.removed)
137
+ out(` removed ${line}`);
138
+ for (const line of result.kept)
139
+ out(` kept ${line}`);
140
+ return 0;
141
+ }
142
+ /** A one-line offer to gitignore `.cheapag/` when the repo containing `cwd` does not ignore it (spec E5); never edits .gitignore. */
143
+ export function gitignoreHint(cwd) {
144
+ try {
145
+ git(projectRoot(cwd), ["check-ignore", "-q", ".cheapag/"]);
146
+ return undefined;
147
+ }
148
+ catch (err) {
149
+ // exit 1: not ignored; anything else (not a repo, git error) offers nothing
150
+ return err.status === 1
151
+ ? "hint: .cheapag/ (Cheap Agent's runtime state) is not gitignored; add a `.cheapag/` line to .gitignore so it is never committed"
152
+ : undefined;
153
+ }
154
+ }
155
+ /** Our job-id folders directly under `dir`, if `dir` is a real directory. */
156
+ const entries = (dir, ids = JOB_ID) => (realDir(dir) ? readdirSync(dir).filter((id) => ids.test(id) && realDir(join(dir, id))) : []);
157
+ /** A directory that is not a symlink. */
158
+ function realDir(path) {
159
+ try {
160
+ return lstatSync(path).isDirectory();
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
166
+ function mtime(path) {
167
+ try {
168
+ return lstatSync(path).mtimeMs;
169
+ }
170
+ catch {
171
+ return undefined;
172
+ }
173
+ }
package/dist/config.js ADDED
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join } from "node:path";
4
+ /** Who implements a hard task (ticket 26): the worker on `models.hard`, Claude itself, or Claude's own Sonnet subagent. */
5
+ export const HARD_TASKS = ["delegate", "self", "sonnet"];
6
+ /** Who finishes a step the worker could not (ticket 30): a Sonnet subagent, or Claude itself within limits.takeover_calls. */
7
+ export const TAKE_OVER = ["sonnet", "self"];
8
+ const DEFAULTS = {
9
+ link_dirs: ["node_modules", ".venv", "venv", "vendor"], allow_commands: ["npx tsc --noEmit"],
10
+ limits: { max_turns: 25, max_minutes: 20, max_cost_usd: 0.5, max_parallel: 3, max_blocked_minutes: 60, repro_by_turns: 12, takeover_calls: 15 },
11
+ retention: { unlanded_worktree_days: 7, job_log_days: 30 },
12
+ models: {}, // the provider's role defaults (src/routing.ts)
13
+ escalation_skip_at: 0.8,
14
+ guard_lines: 20,
15
+ hard_tasks: "delegate",
16
+ takeover: "sonnet",
17
+ };
18
+ // build output is never linked: a job must build its own (ADR-0003)
19
+ const BUILD_OUTPUT = /^(dist|build|out|\.next|\.nuxt|target|coverage)$/;
20
+ /** The user-level config every repo's `cheapag.json` overrides; `cheapag login` writes its `models` (the preset). */
21
+ export const userConfigFile = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cheapag", "config.json");
22
+ export function loadConfig(root) {
23
+ const [mine, repo] = [read(userConfigFile()), read(join(root, "cheapag.json"))];
24
+ // caps, retention and models merge key by key, so a repo can set one and keep the user's others
25
+ return {
26
+ ...DEFAULTS, ...mine, ...repo,
27
+ limits: { ...DEFAULTS.limits, ...mine.limits, ...repo.limits },
28
+ retention: { ...DEFAULTS.retention, ...mine.retention, ...repo.retention },
29
+ models: { ...mine.models, ...repo.models },
30
+ };
31
+ }
32
+ function read(file) {
33
+ if (!existsSync(file))
34
+ return {};
35
+ let raw;
36
+ try {
37
+ raw = readFileSync(file, "utf8");
38
+ }
39
+ catch (err) {
40
+ throw new Error(`cannot read ${file}: ${err.message}`);
41
+ }
42
+ let value;
43
+ try {
44
+ value = JSON.parse(raw);
45
+ }
46
+ catch (err) {
47
+ throw new Error(`${file} is not valid JSON: ${err.message}`);
48
+ }
49
+ if (typeof value !== "object" || value === null || Array.isArray(value))
50
+ throw new Error(`${file} must hold a JSON object`);
51
+ const { test_cmd, integration_cmd, link_dirs, allow_commands, limits, retention, models, escalation_skip_at, guard_lines, hard_tasks, takeover } = value;
52
+ if (limits !== undefined && (typeof limits !== "object" || limits === null || Array.isArray(limits)))
53
+ throw new Error(`${file}: limits must be an object`);
54
+ const caps = {};
55
+ // E4's self_fix_attempts is not read yet
56
+ for (const key of ["max_turns", "max_minutes", "max_cost_usd", "max_parallel", "max_blocked_minutes", "repro_by_turns", "takeover_calls"]) {
57
+ const v = limits?.[key];
58
+ if (v === undefined)
59
+ continue;
60
+ const whole = key === "max_turns" || key === "max_parallel" || key === "repro_by_turns" || key === "takeover_calls";
61
+ // takeover_calls 0 turns the take-over budget off (ticket 29)
62
+ const min = key === "takeover_calls" ? 0 : Number.MIN_VALUE;
63
+ if (typeof v !== "number" || !Number.isFinite(v) || v < min || (whole && !Number.isInteger(v))) {
64
+ throw new Error(`${file}: limits.${key} must be a ${min ? "positive" : "non-negative"} ${whole ? "whole number" : "number"}`);
65
+ }
66
+ caps[key] = v;
67
+ }
68
+ if (retention !== undefined && (typeof retention !== "object" || retention === null || Array.isArray(retention)))
69
+ throw new Error(`${file}: retention must be an object`);
70
+ const keep = {};
71
+ for (const key of ["unlanded_worktree_days", "job_log_days"]) {
72
+ const v = retention?.[key];
73
+ if (v === undefined)
74
+ continue;
75
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
76
+ throw new Error(`${file}: retention.${key} must be a number of days (0 or more)`);
77
+ keep[key] = v;
78
+ }
79
+ if (allow_commands !== undefined && (!Array.isArray(allow_commands) || allow_commands.some((c) => typeof c !== "string" || !c.trim()))) {
80
+ throw new Error(`${file}: allow_commands must be a list of commands`);
81
+ }
82
+ if (models !== undefined && (typeof models !== "object" || models === null || Array.isArray(models)))
83
+ throw new Error(`${file}: models must be an object`);
84
+ const roles = (models ?? {});
85
+ const name = (v) => typeof v === "string" && /^[\w.-]+\/\S+$/.test(v);
86
+ for (const role of ["implement", "medium", "hard", "research", "escalation"]) {
87
+ if (roles[role] !== undefined && !name(roles[role]))
88
+ throw new Error(`${file}: models.${role} must be a provider/model name, e.g. opencode-go/deepseek-v4.1-flash`);
89
+ }
90
+ if (roles.fallback !== undefined && (!Array.isArray(roles.fallback) || !roles.fallback.every(name)))
91
+ throw new Error(`${file}: models.fallback must be a list of provider/model names`);
92
+ if (escalation_skip_at !== undefined && (typeof escalation_skip_at !== "number" || !(escalation_skip_at > 0 && escalation_skip_at <= 1))) {
93
+ throw new Error(`${file}: escalation_skip_at must be a number above 0 and at most 1`);
94
+ }
95
+ if (hard_tasks !== undefined && !HARD_TASKS.includes(hard_tasks))
96
+ throw new Error(`${file}: hard_tasks must be one of ${HARD_TASKS.join(", ")}`);
97
+ if (takeover !== undefined && !TAKE_OVER.includes(takeover))
98
+ throw new Error(`${file}: takeover must be one of ${TAKE_OVER.join(", ")}`);
99
+ if (guard_lines !== undefined && (typeof guard_lines !== "number" || !Number.isInteger(guard_lines) || guard_lines <= 0)) {
100
+ throw new Error(`${file}: guard_lines must be a positive whole number`);
101
+ }
102
+ if (test_cmd !== undefined && (typeof test_cmd !== "string" || !test_cmd.trim()))
103
+ throw new Error(`${file}: test_cmd must be a non-empty string`);
104
+ // null is E4's written default: test_cmd
105
+ if (integration_cmd != null && (typeof integration_cmd !== "string" || !integration_cmd.trim()))
106
+ throw new Error(`${file}: integration_cmd must be a non-empty string`);
107
+ let dirs;
108
+ if (link_dirs !== undefined) {
109
+ if (!Array.isArray(link_dirs) || link_dirs.some((d) => typeof d !== "string" || !d.trim()))
110
+ throw new Error(`${file}: link_dirs must be a list of directory paths`);
111
+ dirs = link_dirs.map((dir) => {
112
+ const parts = dir.split(/[\\/]+/).filter(Boolean);
113
+ if (isAbsolute(dir) || parts.includes(".."))
114
+ throw new Error(`${file}: link_dirs entry "${dir}" must be a path inside the repository`);
115
+ if (BUILD_OUTPUT.test(parts.at(-1)))
116
+ throw new Error(`${file}: link_dirs entry "${dir}" is build output, which is never linked`);
117
+ return parts.join("/");
118
+ });
119
+ }
120
+ return {
121
+ ...(test_cmd !== undefined && { test_cmd }),
122
+ ...(integration_cmd != null && { integration_cmd }),
123
+ ...(dirs && { link_dirs: dirs }),
124
+ ...(allow_commands !== undefined && { allow_commands: allow_commands.map((c) => c.trim()) }),
125
+ limits: caps,
126
+ retention: keep,
127
+ ...(models !== undefined && { models: roles }),
128
+ ...(escalation_skip_at !== undefined && { escalation_skip_at }),
129
+ ...(guard_lines !== undefined && { guard_lines }),
130
+ ...(hard_tasks !== undefined && { hard_tasks: hard_tasks }),
131
+ ...(takeover !== undefined && { takeover: takeover }),
132
+ };
133
+ }