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 +95 -0
- package/README.md +187 -2
- package/dist/allow.js +48 -0
- package/dist/cleanup.js +173 -0
- package/dist/config.js +133 -0
- package/dist/consent.js +129 -0
- package/dist/credentials.js +37 -0
- package/dist/hook.js +263 -0
- package/dist/index.js +16 -0
- package/dist/install.js +119 -0
- package/dist/jobs.js +1177 -0
- package/dist/login.js +220 -0
- package/dist/opencode.js +276 -0
- package/dist/permissions.js +122 -0
- package/dist/plan.js +117 -0
- package/dist/platform.js +112 -0
- package/dist/report.js +178 -0
- package/dist/research.js +270 -0
- package/dist/routing.js +212 -0
- package/dist/rules.js +25 -0
- package/dist/server.js +214 -0
- package/dist/status.js +101 -0
- package/dist/verify.js +130 -0
- package/dist/worker.js +259 -0
- package/dist/workspace.js +283 -0
- package/package.json +53 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/README.md +38 -0
- package/plugin/hooks/hooks.json +40 -0
- package/plugin/skills/allow/SKILL.md +11 -0
- package/plugin/skills/clean/SKILL.md +11 -0
- package/plugin/skills/hook/SKILL.md +11 -0
- package/plugin/skills/plan-writing/SKILL.md +72 -0
- package/plugin/skills/status/SKILL.md +9 -0
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
|
-
|
|
1
|
+
<h1 align="center">Cheap Agent</h1>
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/cleanup.js
ADDED
|
@@ -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
|
+
}
|