cheap-agent 1.0.0 → 1.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.
- package/README.md +61 -26
- package/dist/allow.js +0 -5
- package/dist/cleanup.js +1 -5
- package/dist/hook.js +3 -3
- package/dist/install.js +61 -16
- package/dist/jobs.js +11 -4
- package/dist/login.js +10 -13
- package/dist/opencode.js +29 -11
- package/dist/platform.js +70 -10
- package/dist/research.js +11 -5
- package/dist/server.js +29 -18
- package/dist/status.js +1 -5
- package/dist/verify.js +12 -3
- package/dist/worker.js +22 -0
- package/dist/workspace.js +16 -9
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/hooks/hooks.json +3 -3
- package/plugin/skills/allow/SKILL.md +2 -2
- package/plugin/skills/clean/SKILL.md +2 -2
- package/plugin/skills/hook/SKILL.md +2 -2
- package/plugin/skills/status/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -31,14 +31,68 @@ But that's exactly what happens every time Opus writes your code: the most expen
|
|
|
31
31
|
|
|
32
32
|
**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.
|
|
33
33
|
|
|
34
|
+
## Why I built this
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
**Easiest:** paste this into Claude Code. It asks what you want, then gives you one command to run.
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
Set up Cheap Agent (npm: cheap-agent) for me.
|
|
48
|
+
1. Ask me: which provider (OpenRouter recommended, DeepSeek or OpenCode Go), and which
|
|
49
|
+
models (balanced is the default; cheapest, strongest, or my own provider/model).
|
|
50
|
+
2. Check that node is 20+ and the claude CLI is installed.
|
|
51
|
+
3. If I picked my own model, save {"models":{"implement":"<model>"}} in
|
|
52
|
+
~/.config/cheapag/config.json, keeping what else is there.
|
|
53
|
+
4. Give me the command to run in my terminal: npx cheap-agent login --preset <my choice,
|
|
54
|
+
or custom>, and which number to pick for my provider (1 OpenCode Go, 2 OpenRouter,
|
|
55
|
+
3 DeepSeek). I run it and type my API key there. Never ask for my key.
|
|
56
|
+
5. When I say done, check `claude mcp list` shows cheapag, then tell me to run
|
|
57
|
+
/cheapag:allow in a new Claude Code session in my project.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Or do it yourself:**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx cheap-agent
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
Then, in each project, once:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
/cheapag:allow
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**That's it.** Ask Claude for changes like you always do.
|
|
75
|
+
|
|
76
|
+
**You need:**
|
|
77
|
+
- [Claude Code](https://claude.com/claude-code)
|
|
78
|
+
- [Node.js](https://nodejs.org) 20+
|
|
79
|
+
- 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)
|
|
80
|
+
|
|
81
|
+
A few dollars lasts hundreds of tasks. It runs on macOS and Linux.
|
|
82
|
+
|
|
83
|
+
**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:
|
|
84
|
+
- The very first job can take about 2 minutes while the cheap model's engine (OpenCode) sets itself up. Later jobs start in seconds.
|
|
85
|
+
- 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.
|
|
86
|
+
- The cheap model can't read your dependency folders (like `node_modules`) inside its copy of the project. Your tests still run normally.
|
|
87
|
+
|
|
34
88
|
## The numbers
|
|
35
89
|
|
|
90
|
+
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.
|
|
91
|
+
|
|
36
92
|
<p align="center">
|
|
37
93
|
<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.">
|
|
38
94
|
</p>
|
|
39
95
|
|
|
40
|
-
Measured in Claude Code with Opus 5.5: the same agent and the same prompts, with and without Cheap Agent. The bug fixes are real issues from real open-source projects ([SWE-bench Verified](https://www.swebench.com)), graded by the official harness.
|
|
41
|
-
|
|
42
96
|
**What I won't hide:**
|
|
43
97
|
- On the very hardest bugs it solved 5 of 9 where plain Opus solved 7.
|
|
44
98
|
- On tiny jobs (a few lines, or a whole small app from nothing), Opus is already quick, so there's nothing to save.
|
|
@@ -56,7 +110,11 @@ Cheap Agent shines on the work in between: real features and real fixes in real
|
|
|
56
110
|
| Who wrote the code | Opus | a cheap model, for **$0.005** |
|
|
57
111
|
| Tests | 33 passing | **38 passing** |
|
|
58
112
|
|
|
59
|
-
Opus
|
|
113
|
+
Opus wrote one task. The cheap model did the rest, tests included.
|
|
114
|
+
|
|
115
|
+
## Built with itself
|
|
116
|
+
|
|
117
|
+
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.
|
|
60
118
|
|
|
61
119
|
## How it works
|
|
62
120
|
|
|
@@ -68,29 +126,6 @@ Opus read the request, wrote one task and read a ten-line result. That's all it
|
|
|
68
126
|
- **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.
|
|
69
127
|
- **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.
|
|
70
128
|
|
|
71
|
-
## Install
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
npx cheap-agent
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
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.
|
|
78
|
-
|
|
79
|
-
Then, in each project, once:
|
|
80
|
-
|
|
81
|
-
```
|
|
82
|
-
/cheapag:allow
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
**That's it.** Ask Claude for changes like you always do.
|
|
86
|
-
|
|
87
|
-
**You need:**
|
|
88
|
-
- [Claude Code](https://claude.com/claude-code)
|
|
89
|
-
- [Node.js](https://nodejs.org) 20+
|
|
90
|
-
- 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)
|
|
91
|
-
|
|
92
|
-
A few dollars lasts hundreds of tasks. It runs on macOS and Linux; on Windows, use WSL.
|
|
93
|
-
|
|
94
129
|
## Commands
|
|
95
130
|
|
|
96
131
|
| Command | What it does |
|
package/dist/allow.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { consentGate, describe, writeConsent } from "./consent.js";
|
|
2
2
|
import { detectProvider, LOGIN_COMMAND, providerLabel, redact } from "./credentials.js";
|
|
3
|
-
import { isSupported, UNSUPPORTED_REASON } from "./platform.js";
|
|
4
3
|
import { modelName, routeModels } from "./routing.js";
|
|
5
4
|
import { projectRoot } from "./workspace.js";
|
|
6
5
|
const USAGE = "usage: cheapag allow [--zdr-only | --models <provider/model,…>]";
|
|
@@ -11,10 +10,6 @@ const USAGE = "usage: cheapag allow [--zdr-only | --models <provider/model,…>]
|
|
|
11
10
|
*/
|
|
12
11
|
export function allow(args) {
|
|
13
12
|
const out = (line) => process.stdout.write(redact(line) + "\n");
|
|
14
|
-
if (!isSupported()) {
|
|
15
|
-
out(`✗ ${UNSUPPORTED_REASON}`);
|
|
16
|
-
return 1;
|
|
17
|
-
}
|
|
18
13
|
let consent;
|
|
19
14
|
if (!args.length)
|
|
20
15
|
consent = { mode: "all" };
|
package/dist/cleanup.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
4
|
import { redact } from "./credentials.js";
|
|
5
5
|
import { recoverJobs } from "./jobs.js";
|
|
6
|
-
import { isAlive,
|
|
6
|
+
import { isAlive, removeLink } from "./platform.js";
|
|
7
7
|
import { git, projectRoot } from "./workspace.js";
|
|
8
8
|
/**
|
|
9
9
|
* Cleanup (spec: Concurrency, platform, cleanup; A20), part of the Workspace module: at server start and on
|
|
@@ -119,10 +119,6 @@ export function cleanProject(cwd, all = false) {
|
|
|
119
119
|
/** `cheapag clean [--all]`: non-interactive, so the plugin's `/cheapag:clean` can wrap it; prints what went and what stayed. */
|
|
120
120
|
export function clean(args) {
|
|
121
121
|
const out = (line) => process.stdout.write(redact(line) + "\n");
|
|
122
|
-
if (!isSupported()) {
|
|
123
|
-
out(`✗ ${UNSUPPORTED_REASON}`);
|
|
124
|
-
return 1;
|
|
125
|
-
}
|
|
126
122
|
if (args.length > 1 || (args.length && args[0] !== "--all")) {
|
|
127
123
|
out("✗ usage: cheapag clean [--all] (--all: remove everything not in flight)");
|
|
128
124
|
return 1;
|
package/dist/hook.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
4
|
import { projectRoot } from "./workspace.js";
|
|
5
5
|
const USAGE = "usage: cheapag hook [on | off]";
|
|
@@ -221,7 +221,7 @@ function check(input) {
|
|
|
221
221
|
writeFileSync(state, JSON.stringify(turn));
|
|
222
222
|
}
|
|
223
223
|
/** Plans live in .plan/ and are how Claude delegates, so the guard never blocks them. */
|
|
224
|
-
const isPlan = (root, file) => file.startsWith(join(root, ".plan") +
|
|
224
|
+
const isPlan = (root, file) => file.startsWith(join(root, ".plan") + sep);
|
|
225
225
|
/** A Bash command is denied when it writes a file inside the repo (any size: the lines can't be counted), unless the file is a plan. */
|
|
226
226
|
function bashCheck(cwd, command) {
|
|
227
227
|
const root = projectRoot(cwd);
|
|
@@ -232,7 +232,7 @@ function bashCheck(cwd, command) {
|
|
|
232
232
|
for (const m of command.matchAll(re))
|
|
233
233
|
if (m.groups?.path)
|
|
234
234
|
targets.push(m.groups.path);
|
|
235
|
-
const inRepo = (p) => { const abs = resolve(cwd, p); return abs.startsWith(root +
|
|
235
|
+
const inRepo = (p) => { const abs = resolve(cwd, p); return abs.startsWith(root + sep) && !isPlan(root, abs); };
|
|
236
236
|
const written = targets.filter((p) => p !== "/dev/null" && inRepo(p));
|
|
237
237
|
if (written.length)
|
|
238
238
|
return `Bash writing ${written[0]}: ${REDIRECT}`;
|
package/dist/install.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { cpSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { createRequire } from "node:module";
|
|
4
3
|
import { delimiter, dirname, join } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
5
|
import { userConfigFile } from "./config.js";
|
|
6
|
+
import { normalisePath, runCli, which } from "./platform.js";
|
|
7
7
|
/**
|
|
8
8
|
* The Claude Code plugin (hooks and slash commands), installed by setup (ticket 31). It is generated from this package's
|
|
9
9
|
* own plugin/ so it always matches the installed version, needs no public marketplace, and leaves out .mcp.json (setup
|
|
@@ -17,22 +17,64 @@ export const MARKETPLACE = "cheapag";
|
|
|
17
17
|
/** Where setup keeps the launcher and the generated marketplace: next to the user config. */
|
|
18
18
|
export const installPaths = () => {
|
|
19
19
|
const home = dirname(userConfigFile());
|
|
20
|
-
return { launcher: join(home, "bin", "cheapag"), marketplace: join(home, "marketplace") };
|
|
20
|
+
return { launcher: join(home, "bin", "cheapag.cjs"), marketplace: join(home, "marketplace") };
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* The launcher every hook and command runs as `node <launcher> <args>`. Windows has no /bin/sh, so one CommonJS file
|
|
24
|
+
* runs on every platform: it gates the guard hook (exit 0 unless the repo opted in), then runs this install when it is
|
|
25
|
+
* still there, else the pinned npx.
|
|
26
|
+
*/
|
|
27
|
+
const launcherScript = (entry) => `// cheapag: gates the guard hook, runs this install, else the fallback npx -y cheap-agent@${version}
|
|
28
|
+
"use strict";
|
|
29
|
+
const { execFileSync, spawnSync } = require("node:child_process");
|
|
30
|
+
const { existsSync } = require("node:fs");
|
|
31
|
+
const { join } = require("node:path");
|
|
32
|
+
const { pathToFileURL } = require("node:url");
|
|
33
|
+
const entry = ${JSON.stringify(entry)};
|
|
34
|
+
if (process.argv[2] === "hook" && process.argv[3] === "guard") {
|
|
35
|
+
let root;
|
|
36
|
+
try {
|
|
37
|
+
root = execFileSync("git", ["rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
38
|
+
} catch {
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
if (!existsSync(join(root, ".cheapag", "hook.json")) && !existsSync(join(root, ".cheapag", "hook", "takeover"))) process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
if (existsSync(entry)) {
|
|
44
|
+
process.argv[1] = entry;
|
|
45
|
+
import(pathToFileURL(entry).href);
|
|
46
|
+
} else {
|
|
47
|
+
const r = spawnSync("npx", ["-y", "cheap-agent@${version}", ...process.argv.slice(2)], { stdio: "inherit", shell: process.platform === "win32" });
|
|
48
|
+
process.exit(r.status ?? 1);
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
22
51
|
/** Writes the launcher and the plugin's marketplace; returns the marketplace directory. */
|
|
23
|
-
export function writePlugin(entry,
|
|
52
|
+
export function writePlugin(entry, _node, platform = process.platform) {
|
|
24
53
|
const { launcher, marketplace } = installPaths();
|
|
25
54
|
mkdirSync(dirname(launcher), { recursive: true });
|
|
26
|
-
|
|
27
|
-
|
|
55
|
+
writeFileSync(launcher, launcherScript(entry));
|
|
56
|
+
// Windows commands run in Git Bash or PowerShell, so they take double quotes and forward slashes; POSIX keeps single quotes
|
|
57
|
+
const ref = platform === "win32" ? `node "${normalisePath(launcher)}"` : `node '${launcher}'`;
|
|
28
58
|
rmSync(marketplace, { recursive: true, force: true });
|
|
29
59
|
const plugin = join(marketplace, "cheapag");
|
|
30
60
|
cpSync(PLUGIN_SRC, plugin, { recursive: true });
|
|
31
61
|
rmSync(join(plugin, ".mcp.json"), { force: true });
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
62
|
+
const skills = readdirSync(join(plugin, "skills")).map((s) => join(plugin, "skills", s, "SKILL.md"));
|
|
63
|
+
for (const f of skills)
|
|
64
|
+
writeFileSync(f, readFileSync(f, "utf8").replaceAll(NPX, ref));
|
|
65
|
+
const hooksFile = join(plugin, "hooks", "hooks.json");
|
|
66
|
+
if (platform === "win32") {
|
|
67
|
+
// rewrite the commands as JSON: a raw replace would put unescaped double quotes inside the strings; no sh prefix, since the launcher gates and exits 0
|
|
68
|
+
const json = JSON.parse(readFileSync(hooksFile, "utf8"));
|
|
69
|
+
const command = (action) => `${ref} hook ${action}`;
|
|
70
|
+
json.hooks.PreToolUse[0].hooks[0].command = command("guard");
|
|
71
|
+
json.hooks.PostToolUse[0].hooks[0].command = command("result");
|
|
72
|
+
json.hooks.SessionStart[0].hooks[0].command = command("context");
|
|
73
|
+
writeFileSync(hooksFile, JSON.stringify(json, null, 2) + "\n");
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
writeFileSync(hooksFile, readFileSync(hooksFile, "utf8").replaceAll(NPX, ref));
|
|
77
|
+
}
|
|
36
78
|
mkdirSync(join(marketplace, ".claude-plugin"), { recursive: true });
|
|
37
79
|
writeFileSync(join(marketplace, ".claude-plugin", "marketplace.json"), JSON.stringify({
|
|
38
80
|
name: MARKETPLACE, owner: { name: "muaaz9329" }, description: "Cheap Agent, generated by its setup from the installed package",
|
|
@@ -43,16 +85,16 @@ export function writePlugin(entry, node) {
|
|
|
43
85
|
/** Generates the plugin and adds it to Claude Code (or updates it); prints the manual commands when a step fails. */
|
|
44
86
|
export function installPlugin(out) {
|
|
45
87
|
const marketplace = writePlugin(realpathSync(process.argv[1]), stableNode());
|
|
46
|
-
const claude = (...args) =>
|
|
47
|
-
const listed = (args, name) => (claude(...args)
|
|
88
|
+
const claude = (...args) => runCli("claude", ["plugin", ...args], { encoding: "utf8" });
|
|
89
|
+
const listed = (args, name) => (claude(...args)?.stdout ?? "").includes(name);
|
|
48
90
|
const steps = [
|
|
49
91
|
listed(["marketplace", "list"], MARKETPLACE) ? ["marketplace", "update", MARKETPLACE] : ["marketplace", "add", marketplace],
|
|
50
92
|
listed(["list"], `cheapag@${MARKETPLACE}`) ? ["update", `cheapag@${MARKETPLACE}`] : ["install", `cheapag@${MARKETPLACE}`],
|
|
51
93
|
];
|
|
52
94
|
for (const step of steps) {
|
|
53
95
|
const r = claude(...step);
|
|
54
|
-
if (r.status !== 0) {
|
|
55
|
-
out(`✗ claude plugin ${step.join(" ")} failed (${(r
|
|
96
|
+
if (!r || r.status !== 0) {
|
|
97
|
+
out(`✗ claude plugin ${step.join(" ")} failed (${((r?.stderr || r?.stdout) ?? "").trim().split("\n")[0]}). Run these yourself:`);
|
|
56
98
|
out(` claude plugin marketplace add ${marketplace}\n claude plugin install cheapag@${MARKETPLACE}`);
|
|
57
99
|
return false;
|
|
58
100
|
}
|
|
@@ -64,11 +106,14 @@ export function installPlugin(out) {
|
|
|
64
106
|
export function stableNode() {
|
|
65
107
|
const self = realpathSync(process.execPath);
|
|
66
108
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
109
|
+
if (!dir)
|
|
110
|
+
continue;
|
|
111
|
+
const candidate = which("node", process.platform, { PATH: dir, PATHEXT: process.env.PATHEXT });
|
|
67
112
|
try {
|
|
68
|
-
if (
|
|
69
|
-
return
|
|
113
|
+
if (candidate && realpathSync(candidate) === self)
|
|
114
|
+
return candidate;
|
|
70
115
|
}
|
|
71
|
-
catch { } //
|
|
116
|
+
catch { } // vanished between lookup and realpath
|
|
72
117
|
}
|
|
73
118
|
return process.execPath;
|
|
74
119
|
}
|
package/dist/jobs.js
CHANGED
|
@@ -10,7 +10,7 @@ import { projectScripts, workerRuleset } from "./permissions.js";
|
|
|
10
10
|
import { consentGate } from "./consent.js";
|
|
11
11
|
import { chain, DIFFICULTIES, escalationSkip, modelName, recordSpend, runRouted, tierModel, usedBalance } from "./routing.js";
|
|
12
12
|
import { errorTail, runVerify, sameFailure, verifyCommands } from "./verify.js";
|
|
13
|
-
import { finishReport, openSession } from "./worker.js";
|
|
13
|
+
import { finishReport, openSession, releaseDirectory } from "./worker.js";
|
|
14
14
|
import { conflictPaths, createWorktree, git, jobWorktree, land, leftoverMarkers, operationInProgress, projectRoot, reapply, removeWorktree, withLock, worktreeDiff, } from "./workspace.js";
|
|
15
15
|
import { isAlive, normalisePath } from "./platform.js";
|
|
16
16
|
/** Jobs: runs each group of plan steps as one job, start to landing, under per-job caps (several groups make a batch); lists, shows and cancels jobs. */
|
|
@@ -343,6 +343,7 @@ function runGroup(g) {
|
|
|
343
343
|
const redo = async (patch, clash, work) => {
|
|
344
344
|
redone = 1;
|
|
345
345
|
write("conflict.patch", patch); // log copy of the change that clashed
|
|
346
|
+
await releaseDirectory(worktree.path); // release before the path is removed, then recreated
|
|
346
347
|
removeWorktree(root, worktree);
|
|
347
348
|
// the same path, so the session's directory stays valid
|
|
348
349
|
worktree = createWorktree(root, job, config.link_dirs);
|
|
@@ -369,6 +370,7 @@ function runGroup(g) {
|
|
|
369
370
|
landing = true;
|
|
370
371
|
pauseClock();
|
|
371
372
|
if (!(await land(root, job, diff.patch, diff.changed.map((c) => c.path)))) {
|
|
373
|
+
await releaseDirectory(worktree.path);
|
|
372
374
|
removeWorktree(root, worktree);
|
|
373
375
|
return;
|
|
374
376
|
}
|
|
@@ -508,8 +510,10 @@ function runGroup(g) {
|
|
|
508
510
|
landing = true; // from here on, caps and cancel no longer apply (until a redo works again)
|
|
509
511
|
pauseClock();
|
|
510
512
|
const clash = await land(root, job, diff.patch, diff.changed.map((c) => c.path));
|
|
511
|
-
if (!clash)
|
|
513
|
+
if (!clash) {
|
|
514
|
+
await releaseDirectory(worktree.path);
|
|
512
515
|
removeWorktree(root, worktree);
|
|
516
|
+
}
|
|
513
517
|
else
|
|
514
518
|
await redo(diff.patch, clash, work);
|
|
515
519
|
}
|
|
@@ -1025,6 +1029,7 @@ async function landOne(root, config, id, action) {
|
|
|
1025
1029
|
writeWhole(join(dir, "result.txt"), redact(text + "\n"));
|
|
1026
1030
|
};
|
|
1027
1031
|
if (action === "discard") {
|
|
1032
|
+
await releaseDirectory(worktree.path); // release before the worktree is removed
|
|
1028
1033
|
removeWorktree(root, worktree); // dependency links are unlinked first
|
|
1029
1034
|
save({ discarded: true });
|
|
1030
1035
|
return `OK ${id} discarded`;
|
|
@@ -1038,6 +1043,7 @@ async function landOne(root, config, id, action) {
|
|
|
1038
1043
|
const files = conflictPaths(clash);
|
|
1039
1044
|
throw new Error(`landing conflict${files.length ? ` in ${files.join(", ")}` : ""}, working tree untouched; discard it, or fix the overlap and delegate again`);
|
|
1040
1045
|
}
|
|
1046
|
+
await releaseDirectory(worktree.path); // release before the worktree is removed
|
|
1041
1047
|
removeWorktree(root, worktree);
|
|
1042
1048
|
save({ landed: true, changed: diff.changed });
|
|
1043
1049
|
const add = diff.changed.reduce((n, c) => n + c.add, 0);
|
|
@@ -1156,8 +1162,9 @@ function reviewRanges(review, base) {
|
|
|
1156
1162
|
.slice(0, 5);
|
|
1157
1163
|
}
|
|
1158
1164
|
const inside = (root, path) => {
|
|
1159
|
-
|
|
1160
|
-
|
|
1165
|
+
// normalised: relative() gives "..\\x" on Windows
|
|
1166
|
+
const rel = normalisePath(relative(root, path));
|
|
1167
|
+
return rel !== "" && !rel.startsWith("../") && rel !== ".." && !isAbsolute(rel);
|
|
1161
1168
|
};
|
|
1162
1169
|
/**
|
|
1163
1170
|
* Writes a file whole: to a temporary name, then renamed into place. Records are rewritten on every turn while other
|
package/dist/login.js
CHANGED
|
@@ -2,12 +2,12 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
5
|
+
import { dirname, join, sep } from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import { loadConfig, userConfigFile } from "./config.js";
|
|
8
8
|
import { detectProvider, LOGIN_COMMAND as LOGIN, PROVIDERS, providerLabel as label, redact, savedProviders } from "./credentials.js";
|
|
9
9
|
import { ensureOpencode, opencodeBinary, stopOpencode } from "./opencode.js";
|
|
10
|
-
import {
|
|
10
|
+
import { runCli } from "./platform.js";
|
|
11
11
|
import { installPlugin, stableNode } from "./install.js";
|
|
12
12
|
import { fullRoute, modelName, presetOf, PRESET_NAMES, PRESETS } from "./routing.js";
|
|
13
13
|
import { runSession } from "./worker.js";
|
|
@@ -22,10 +22,6 @@ const CHOICES = [...PRESET_NAMES, "custom"];
|
|
|
22
22
|
const isChoice = (s) => CHOICES.includes(s);
|
|
23
23
|
export async function login(args = []) {
|
|
24
24
|
const out = (line = "") => process.stdout.write(redact(line) + "\n");
|
|
25
|
-
if (!isSupported()) {
|
|
26
|
-
out(`✗ ${UNSUPPORTED_REASON}`);
|
|
27
|
-
return 1;
|
|
28
|
-
}
|
|
29
25
|
const flag = args.length === 2 && args[0] === "--preset" ? args[1] : undefined;
|
|
30
26
|
if (args.length && flag === undefined) {
|
|
31
27
|
out(`✗ usage: cheapag login [--preset ${CHOICES.join("|")}]`);
|
|
@@ -175,8 +171,8 @@ function explain(message, model) {
|
|
|
175
171
|
async function register(out) {
|
|
176
172
|
const command = serverCommand();
|
|
177
173
|
const manual = `claude mcp add -s user cheapag -- ${command.map(quote).join(" ")}`;
|
|
178
|
-
const existing =
|
|
179
|
-
if (existing
|
|
174
|
+
const existing = runCli("claude", ["mcp", "get", "cheapag"], { encoding: "utf8" });
|
|
175
|
+
if (!existing) {
|
|
180
176
|
out(`Claude Code's CLI was not found. To add cheapag for all your projects, run:\n ${manual}`);
|
|
181
177
|
return;
|
|
182
178
|
}
|
|
@@ -191,9 +187,9 @@ async function register(out) {
|
|
|
191
187
|
out(`Not registered. To add it later, run:\n ${manual}`);
|
|
192
188
|
return;
|
|
193
189
|
}
|
|
194
|
-
const added =
|
|
195
|
-
if (added.status !== 0) {
|
|
196
|
-
out(`✗ claude mcp add failed (${(added
|
|
190
|
+
const added = runCli("claude", ["mcp", "add", "-s", "user", "cheapag", "--", ...command], { encoding: "utf8" });
|
|
191
|
+
if (!added || added.status !== 0) {
|
|
192
|
+
out(`✗ claude mcp add failed (${((added?.stderr || added?.stdout) ?? "").trim().split("\n")[0]}). Run it yourself:\n ${manual}`);
|
|
197
193
|
return;
|
|
198
194
|
}
|
|
199
195
|
out("✓ Added cheapag to Claude Code for all your projects");
|
|
@@ -208,9 +204,10 @@ function finish(out, plugin) {
|
|
|
208
204
|
/** How Claude Code should start this installed package: npx when run from npx's cache, else this exact file. */
|
|
209
205
|
function serverCommand() {
|
|
210
206
|
const entry = realpathSync(process.argv[1]);
|
|
211
|
-
return entry.includes("
|
|
207
|
+
return entry.split(sep).includes("_npx") ? ["npx", "-y", `cheap-agent@${version}`] : [stableNode(), entry];
|
|
212
208
|
}
|
|
213
|
-
|
|
209
|
+
// double quotes on Windows: cmd and PowerShell don't treat single quotes as quoting
|
|
210
|
+
const quote = (arg) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : process.platform === "win32" ? `"${arg.replace(/"/g, '\\"')}"` : `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
214
211
|
/** One question on a fresh readline, closed before anything else (OpenCode's login) reads the terminal. */
|
|
215
212
|
async function ask(question) {
|
|
216
213
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
package/dist/opencode.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
import { arch } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { baselinePermission } from "./permissions.js";
|
|
8
|
-
import { killProcessGroup, opencodePlatform } from "./platform.js";
|
|
8
|
+
import { gitBash, killProcessGroup, opencodePlatform } from "./platform.js";
|
|
9
9
|
import { fullRoute, modelName, routeModels } from "./routing.js";
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
export const OPENCODE_PIN = require("opencode-ai/package.json").version;
|
|
@@ -63,22 +63,21 @@ answer:
|
|
|
63
63
|
\`\`\`
|
|
64
64
|
`;
|
|
65
65
|
// Runs inside OpenCode (Bun). Its input is captured from the event stream, so it only has to exist and validate.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
export default tool({
|
|
66
|
+
// It imports nothing (OpenCode accepts a plain { description, args, execute } with JSON Schema args), so OpenCode has nothing to install.
|
|
67
|
+
export const FINISH_TOOL_SOURCE = `export default {
|
|
69
68
|
description: "Report the step as done. Call exactly once, as your last action.",
|
|
70
69
|
args: {
|
|
71
|
-
summary:
|
|
72
|
-
risks:
|
|
73
|
-
review:
|
|
74
|
-
missing:
|
|
75
|
-
repro:
|
|
76
|
-
disagree:
|
|
70
|
+
summary: { type: "string", maxLength: 160, description: "What you did, at most 160 characters" },
|
|
71
|
+
risks: { type: "array", items: { type: "string" }, description: "Assumptions, unverified conditions, edits outside files:" },
|
|
72
|
+
review: { type: "array", items: { type: "string" }, maxItems: 5, description: "Ranges worth a human look, as path:start-end" },
|
|
73
|
+
missing: { type: "array", items: { type: "string" }, description: "Anything the step asked for that you did not do" },
|
|
74
|
+
repro: { type: "string", description: "The exact command that runs your reproduction test (fails on the original code, passes now); 'none: <why>' if you could not reproduce the problem; 'n/a' if the step is not a fix" },
|
|
75
|
+
disagree: { type: "string", description: "Where the step's spec and the Source you were given disagree (direction, order, value, approach), or 'none'" },
|
|
77
76
|
},
|
|
78
77
|
async execute() {
|
|
79
78
|
return "Reported. Stop now.";
|
|
80
79
|
},
|
|
81
|
-
}
|
|
80
|
+
};
|
|
82
81
|
`;
|
|
83
82
|
/**
|
|
84
83
|
* Runs inside OpenCode (Bun), for every session: grep returns matching lines, and the read deny on .env* does not
|
|
@@ -112,6 +111,9 @@ export function writeConfigDir(dir, config) {
|
|
|
112
111
|
mkdirSync(join(dir, "agents"), { recursive: true });
|
|
113
112
|
mkdirSync(join(dir, "tools"), { recursive: true });
|
|
114
113
|
mkdirSync(join(dir, "plugins"), { recursive: true });
|
|
114
|
+
// node_modules + package-lock.json listing @opencode-ai/plugin make OpenCode skip its npm install of it into this dir (core/src/npm.ts); neither generated file imports it
|
|
115
|
+
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
|
116
|
+
writeFileSync(join(dir, "package-lock.json"), JSON.stringify({ name: "cheapag-opencode", lockfileVersion: 3, requires: true, packages: { "": { dependencies: { "@opencode-ai/plugin": OPENCODE_PIN } } } }));
|
|
115
117
|
writeFileSync(join(dir, "agents", "cheapag-worker.md"), WORKER_PROMPT);
|
|
116
118
|
writeFileSync(join(dir, "agents", "cheapag-research.md"), RESEARCH_PROMPT);
|
|
117
119
|
writeFileSync(join(dir, "tools", "finish.ts"), FINISH_TOOL_SOURCE);
|
|
@@ -153,6 +155,15 @@ export function holdOpencode() {
|
|
|
153
155
|
held = false;
|
|
154
156
|
};
|
|
155
157
|
}
|
|
158
|
+
/** The running server, if one is up; never boots one. */
|
|
159
|
+
export async function runningOpencode() {
|
|
160
|
+
try {
|
|
161
|
+
return await started;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return undefined; // a failed boot leaves nothing to reach
|
|
165
|
+
}
|
|
166
|
+
}
|
|
156
167
|
export function ensureOpencode(provider, projectDir = process.cwd(), route) {
|
|
157
168
|
const config = opencodeConfig(provider, route ?? fullRoute(provider.provider));
|
|
158
169
|
if (started && route && booted !== config) {
|
|
@@ -190,16 +201,23 @@ async function boot(projectDir, config) {
|
|
|
190
201
|
return { url, password, version };
|
|
191
202
|
}
|
|
192
203
|
function spawnServer(configDir, password) {
|
|
204
|
+
// on Windows point the worker at Git Bash, so it runs and parses its commands as bash, like the rest of cheapag
|
|
205
|
+
const bash = process.platform === "win32" ? gitBash() : undefined;
|
|
193
206
|
return new Promise((resolve, reject) => {
|
|
194
207
|
// detached: its own process group, so stopOpencode() also takes down anything it spawned
|
|
195
208
|
const proc = spawn(opencodeBinary(), ["serve", "--hostname", "127.0.0.1", "--port", "0"], {
|
|
196
209
|
detached: true,
|
|
210
|
+
// a detached console child would open a window on Windows
|
|
211
|
+
windowsHide: true,
|
|
197
212
|
stdio: ["ignore", "pipe", "pipe"],
|
|
198
213
|
env: {
|
|
199
214
|
...process.env,
|
|
200
215
|
OPENCODE_SERVER_PASSWORD: password,
|
|
201
216
|
OPENCODE_CONFIG_DIR: configDir,
|
|
202
217
|
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
218
|
+
// OpenCode watches each session dir's .git; cheapag uses none of its events, and on Windows the open handle blocks removing the worktree
|
|
219
|
+
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1",
|
|
220
|
+
...(bash ? { SHELL: bash } : {}),
|
|
203
221
|
// the server-wide baseline; each worker session also carries the full ruleset, which wins (src/permissions.ts)
|
|
204
222
|
OPENCODE_PERMISSION: JSON.stringify(baselinePermission()),
|
|
205
223
|
},
|
package/dist/platform.js
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, symlinkSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { delimiter, dirname, join, sep } from "node:path";
|
|
3
4
|
/**
|
|
4
|
-
* Platform: everything OS-specific lives here (spec A18).
|
|
5
|
-
*
|
|
5
|
+
* Platform: everything OS-specific lives here (spec A18). Windows is supported through junctions, taskkill and
|
|
6
|
+
* PATHEXT lookup here; callers stay platform-free.
|
|
6
7
|
*/
|
|
7
|
-
|
|
8
|
-
export const UNSUPPORTED = `FAIL reason: ${UNSUPPORTED_REASON}`;
|
|
9
|
-
/** macOS and Linux (WSL reports linux) are supported; native Windows is not yet. */
|
|
10
|
-
export const isSupported = (platform = process.platform) => platform !== "win32";
|
|
11
|
-
/** Links a dependency directory into a worktree. */
|
|
8
|
+
/** Links a dependency directory into a worktree. Junctions need no privilege on Windows, unlike directory symlinks. */
|
|
12
9
|
export function link(target, path) {
|
|
13
|
-
symlinkSync(target, path, "dir");
|
|
10
|
+
symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir");
|
|
14
11
|
}
|
|
15
12
|
/** Removes a link made by `link`, never what it points to; does nothing if `path` is not a link. */
|
|
16
13
|
export function removeLink(path) {
|
|
@@ -43,6 +40,11 @@ export function opencodePlatform() {
|
|
|
43
40
|
export function killProcessGroup(pid, signal = "SIGTERM") {
|
|
44
41
|
if (!pid)
|
|
45
42
|
return;
|
|
43
|
+
if (process.platform === "win32") {
|
|
44
|
+
// no process groups on Windows; taskkill walks the tree instead and ignores the signal
|
|
45
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
46
48
|
try {
|
|
47
49
|
process.kill(-pid, signal);
|
|
48
50
|
}
|
|
@@ -50,3 +52,61 @@ export function killProcessGroup(pid, signal = "SIGTERM") {
|
|
|
50
52
|
// already gone
|
|
51
53
|
}
|
|
52
54
|
}
|
|
55
|
+
/** The first executable named `name` on `env`'s PATH, or undefined. On Windows it tries PATHEXT extensions, bare name last. */
|
|
56
|
+
export function which(name, platform = process.platform, env = process.env) {
|
|
57
|
+
const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
58
|
+
const candidates = platform === "win32"
|
|
59
|
+
? [...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((ext) => name + ext), name]
|
|
60
|
+
: [name];
|
|
61
|
+
for (const dir of dirs) {
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (platform !== "win32") {
|
|
64
|
+
const full = join(dir, candidate);
|
|
65
|
+
if (existsSync(full))
|
|
66
|
+
return full;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// Windows filesystems are case-insensitive; read the dir so a case difference still matches
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = readdirSync(dir);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
const hit = entries.find((entry) => entry.toLowerCase() === candidate.toLowerCase());
|
|
78
|
+
if (hit)
|
|
79
|
+
return join(dir, hit);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Git for Windows' bash, for running bash-syntax commands on Windows. Never a bare `bash` from PATH: on Windows that
|
|
86
|
+
* can be WSL's System32\bash.exe, which resolves Windows paths differently.
|
|
87
|
+
*/
|
|
88
|
+
export function gitBash(platform = process.platform, env = process.env) {
|
|
89
|
+
if (platform !== "win32")
|
|
90
|
+
return undefined;
|
|
91
|
+
// Claude Code's own variable; trust it when it points at a real file
|
|
92
|
+
const override = env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
93
|
+
if (override && existsSync(override))
|
|
94
|
+
return override;
|
|
95
|
+
const git = which("git", platform, env);
|
|
96
|
+
if (!git)
|
|
97
|
+
return undefined;
|
|
98
|
+
// Git for Windows: git.exe in <Git>\cmd, bash.exe in <Git>\bin
|
|
99
|
+
const bash = join(dirname(git), "..", "bin", "bash.exe");
|
|
100
|
+
return existsSync(bash) ? bash : undefined;
|
|
101
|
+
}
|
|
102
|
+
/** Double-quotes an argument Node will re-split under `shell: true` on Windows. */
|
|
103
|
+
const quoteWinArg = (arg) => (/[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg);
|
|
104
|
+
export function runCli(name, args, opts) {
|
|
105
|
+
const file = which(name);
|
|
106
|
+
if (!file)
|
|
107
|
+
return undefined;
|
|
108
|
+
if (/\.(cmd|bat)$/i.test(file)) {
|
|
109
|
+
return spawnSync(file, args.map(quoteWinArg), { ...opts, shell: true, windowsHide: true });
|
|
110
|
+
}
|
|
111
|
+
return spawnSync(file, args, opts);
|
|
112
|
+
}
|
package/dist/research.js
CHANGED
|
@@ -10,7 +10,7 @@ import { projectScripts, researchRuleset } from "./permissions.js";
|
|
|
10
10
|
import { normalisePath } from "./platform.js";
|
|
11
11
|
import { failBlock, renderResearch } from "./report.js";
|
|
12
12
|
import { chain, modelName, recordSpend, runRouted } from "./routing.js";
|
|
13
|
-
import { openSession } from "./worker.js";
|
|
13
|
+
import { openSession, releaseDirectory } from "./worker.js";
|
|
14
14
|
import { createWorktree, projectRoot, removeWorktree } from "./workspace.js";
|
|
15
15
|
/**
|
|
16
16
|
* Research: one question about the codebase, answered by a worker that reads, searches and runs test commands but
|
|
@@ -32,13 +32,13 @@ export async function research(input, ctx) {
|
|
|
32
32
|
return gate.block;
|
|
33
33
|
const scope = [];
|
|
34
34
|
for (const path of (input.paths ?? "").split(",").map((p) => p.trim()).filter(Boolean)) {
|
|
35
|
-
const rel = relative(root, join(root, path));
|
|
35
|
+
const rel = normalisePath(relative(root, join(root, path)));
|
|
36
36
|
if (isAbsolute(path) || rel === ".." || rel.startsWith("../"))
|
|
37
37
|
return failBlock(`path ${path} must be inside the repository, relative to its root`);
|
|
38
38
|
if (!existsSync(join(root, path)))
|
|
39
39
|
return failBlock(`path ${path} not found`);
|
|
40
40
|
if (rel)
|
|
41
|
-
scope.push(
|
|
41
|
+
scope.push(rel); // "." is the whole repo: no scope
|
|
42
42
|
}
|
|
43
43
|
let config;
|
|
44
44
|
try {
|
|
@@ -127,8 +127,10 @@ export async function research(input, ctx) {
|
|
|
127
127
|
}
|
|
128
128
|
finally {
|
|
129
129
|
release(); // the session is done: a config change may reboot OpenCode now
|
|
130
|
-
if (worktree)
|
|
130
|
+
if (worktree) {
|
|
131
|
+
await releaseDirectory(worktree.path); // release before the worktree is removed
|
|
131
132
|
removeWorktree(root, worktree);
|
|
133
|
+
}
|
|
132
134
|
}
|
|
133
135
|
const answer = stopped ? { status: stopped.status, answer: "", refs: [] }
|
|
134
136
|
: budget ? { status: "BUDGET", answer: "", refs: [] }
|
|
@@ -212,13 +214,17 @@ export function parseAnswer(text, root, scope, maxWords) {
|
|
|
212
214
|
deadEnds: fields.deadEnds?.trim() && !/^none\.?$/i.test(fields.deadEnds.trim()) ? fields.deadEnds.trim() : undefined,
|
|
213
215
|
};
|
|
214
216
|
}
|
|
217
|
+
/** The raw `path:line(-line)` candidates in text, before validation. Accepts a Windows drive letter and backslashes. */
|
|
218
|
+
export function refTokens(text) {
|
|
219
|
+
return [...text.matchAll(/((?:[A-Za-z]:)?[\w./\\@-]+):(\d+)(?:-(\d+))?/g)].map(([, path, from, to]) => ({ path, from, to }));
|
|
220
|
+
}
|
|
215
221
|
/**
|
|
216
222
|
* Validated `path:line(-line)` refs from free text, at most 5: the file exists in the main checkout, inside the repo
|
|
217
223
|
* (and the scope, when there is one), and has those lines. Absolute paths inside the repo are made relative.
|
|
218
224
|
*/
|
|
219
225
|
function refs(text, root, scope) {
|
|
220
226
|
const out = [];
|
|
221
|
-
for (const
|
|
227
|
+
for (const { path, from, to } of refTokens(text)) {
|
|
222
228
|
const abs = isAbsolute(path) ? path : join(root, path);
|
|
223
229
|
const rel = normalisePath(relative(root, abs));
|
|
224
230
|
const start = Number(from);
|
package/dist/server.js
CHANGED
|
@@ -9,7 +9,6 @@ import { detectProvider, NO_PROVIDER, redact } from "./credentials.js";
|
|
|
9
9
|
import { delegate, jobs, landJobs, recoverJobs, reply } from "./jobs.js";
|
|
10
10
|
import { ensureOpencode, stopOpencode } from "./opencode.js";
|
|
11
11
|
import { PLAN_FORMAT } from "./plan.js";
|
|
12
|
-
import { isSupported, UNSUPPORTED } from "./platform.js";
|
|
13
12
|
import { failBlock } from "./report.js";
|
|
14
13
|
import { MAX_WORDS, research } from "./research.js";
|
|
15
14
|
import { DIFFICULTIES } from "./routing.js";
|
|
@@ -64,15 +63,13 @@ export async function serve() {
|
|
|
64
63
|
const hint = gitignoreHint(process.cwd());
|
|
65
64
|
return hint ? `${text}\n\n${hint}` : text;
|
|
66
65
|
};
|
|
67
|
-
/** Answers every tool with the unsupported-platform FAIL on native Windows (spec A18). */
|
|
68
|
-
const supported = (handler) => (...args) => (isSupported() ? handler(...args) : Promise.resolve(safeText(UNSUPPORTED)));
|
|
69
66
|
/** Worker progress as MCP progress notifications to the calling request, when it asked for them. */
|
|
70
67
|
const progressTo = (extra) => (progress, message) => {
|
|
71
68
|
const progressToken = extra._meta?.progressToken;
|
|
72
69
|
if (progressToken !== undefined)
|
|
73
70
|
void extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, message } });
|
|
74
71
|
};
|
|
75
|
-
server.registerTool("ping", { description: "Check that the Cheap Agent server is running and report its version." },
|
|
72
|
+
server.registerTool("ping", { description: "Check that the Cheap Agent server is running and report its version." }, async () => {
|
|
76
73
|
const provider = detectProvider();
|
|
77
74
|
if (!provider)
|
|
78
75
|
return safeText(NO_PROVIDER);
|
|
@@ -83,7 +80,7 @@ export async function serve() {
|
|
|
83
80
|
catch (err) {
|
|
84
81
|
return safeText(`FAIL reason: ${err.message}`);
|
|
85
82
|
}
|
|
86
|
-
})
|
|
83
|
+
});
|
|
87
84
|
server.registerTool("delegate", {
|
|
88
85
|
description: `Make any code change: use this for every edit, including small, mechanical, "quick" or multi-file ones; those are exactly what the cheap worker is for, and your tokens cost the user far more than its. One call: task = what to change, which files, how to check. The worker edits an isolated worktree, runs the checks, and lands an OK result (uncommitted) in your working tree; read the result block instead of re-checking. ${DIFFICULTY_HINT} For multi-step work pass plan + steps instead of task. ${PLAN_FORMAT}`,
|
|
89
86
|
inputSchema: {
|
|
@@ -99,7 +96,7 @@ export async function serve() {
|
|
|
99
96
|
wait: z.boolean().optional().describe('Default true: return when every group settles or one is BLOCKED. false: return RUNNING at once; then jobs(action="wait")'),
|
|
100
97
|
},
|
|
101
98
|
_meta: ALWAYS_LOAD,
|
|
102
|
-
},
|
|
99
|
+
}, async (input, extra) => {
|
|
103
100
|
const provider = detectProvider();
|
|
104
101
|
if (!provider)
|
|
105
102
|
return safeText(NO_PROVIDER);
|
|
@@ -109,7 +106,7 @@ export async function serve() {
|
|
|
109
106
|
catch (err) {
|
|
110
107
|
return safeText(failBlock(err.message));
|
|
111
108
|
}
|
|
112
|
-
})
|
|
109
|
+
});
|
|
113
110
|
server.registerTool("research", {
|
|
114
111
|
description: "Find and explain code in this repository: asks the cheap read-only research worker a question (it reads the working tree, uncommitted changes included). Returns a short answer with path:line refs and a confidence; verify low-confidence answers before acting.",
|
|
115
112
|
inputSchema: {
|
|
@@ -118,7 +115,7 @@ export async function serve() {
|
|
|
118
115
|
max_words: z.number().int().positive().optional().describe(`Answer length cap in words; default 200, at most ${MAX_WORDS}`),
|
|
119
116
|
},
|
|
120
117
|
_meta: ALWAYS_LOAD,
|
|
121
|
-
},
|
|
118
|
+
}, async (input, extra) => {
|
|
122
119
|
const provider = detectProvider();
|
|
123
120
|
if (!provider)
|
|
124
121
|
return safeText(NO_PROVIDER);
|
|
@@ -128,14 +125,14 @@ export async function serve() {
|
|
|
128
125
|
catch (err) {
|
|
129
126
|
return safeText(failBlock(err.message));
|
|
130
127
|
}
|
|
131
|
-
})
|
|
128
|
+
});
|
|
132
129
|
server.registerTool("reply", {
|
|
133
130
|
description: "Answer a BLOCKED job's question and resume its worker in the same session. Returns the job's next result block (OK, FAIL, or BLOCKED again); for a job in a batch, the batch block at its next settlement.",
|
|
134
131
|
inputSchema: {
|
|
135
132
|
job: z.string().describe('The BLOCKED job id, e.g. "j6a90"'),
|
|
136
133
|
answer: z.string().max(500).describe('An option letter (a-d), or a short free-text answer; for several numbered questions, one answer each: "1a, 2b" or "1a, 2: use redis"'),
|
|
137
134
|
},
|
|
138
|
-
},
|
|
135
|
+
}, async (input, extra) => {
|
|
139
136
|
if (!detectProvider())
|
|
140
137
|
return safeText(NO_PROVIDER);
|
|
141
138
|
try {
|
|
@@ -144,14 +141,14 @@ export async function serve() {
|
|
|
144
141
|
catch (err) {
|
|
145
142
|
return safeText(failBlock(err.message));
|
|
146
143
|
}
|
|
147
|
-
})
|
|
144
|
+
});
|
|
148
145
|
server.registerTool("jobs", {
|
|
149
146
|
description: "List jobs, show a job's last result block again, wait for a job or batch to settle, or cancel a running job (keeps its worktree).",
|
|
150
147
|
inputSchema: {
|
|
151
148
|
action: z.enum(["list", "show", "wait", "cancel"]).describe("list: one line per job; show: a job's last block; wait: block until the job's or batch's next result, then return it; cancel: stop a running job"),
|
|
152
149
|
job: z.string().optional().describe("Job id, e.g. j7f3a (show, wait, cancel), or batch id, e.g. b4k1x (wait)"),
|
|
153
150
|
},
|
|
154
|
-
},
|
|
151
|
+
}, async (input, extra) => {
|
|
155
152
|
if (!detectProvider())
|
|
156
153
|
return safeText(NO_PROVIDER);
|
|
157
154
|
try {
|
|
@@ -160,14 +157,14 @@ export async function serve() {
|
|
|
160
157
|
catch (err) {
|
|
161
158
|
return safeText(failBlock(err.message));
|
|
162
159
|
}
|
|
163
|
-
})
|
|
160
|
+
});
|
|
164
161
|
server.registerTool("land", {
|
|
165
162
|
description: "Land or discard finished jobs that did not land (PARTIAL, FAIL, CONFLICT…). apply: 3-way applies a job's worktree changes to your working tree, uncommitted (a conflict leaves the tree untouched); discard: deletes its worktree. One line per job.",
|
|
166
163
|
inputSchema: {
|
|
167
164
|
job: z.string().describe('Job id, or several comma-separated in landing order, e.g. "j3d55" or "j3d54,j3d55" (apply stops at the first that fails)'),
|
|
168
165
|
action: z.enum(["apply", "discard"]).describe("apply: land the job's changes; discard: delete its worktree"),
|
|
169
166
|
},
|
|
170
|
-
},
|
|
167
|
+
}, async (input, extra) => {
|
|
171
168
|
const provider = detectProvider();
|
|
172
169
|
if (!provider)
|
|
173
170
|
return safeText(NO_PROVIDER);
|
|
@@ -178,7 +175,7 @@ export async function serve() {
|
|
|
178
175
|
catch (err) {
|
|
179
176
|
return safeText(failBlock(err.message));
|
|
180
177
|
}
|
|
181
|
-
})
|
|
178
|
+
});
|
|
182
179
|
// jobs a previous server left running or blocked can never finish now
|
|
183
180
|
try {
|
|
184
181
|
recoverJobs(process.cwd());
|
|
@@ -194,10 +191,24 @@ export async function serve() {
|
|
|
194
191
|
catch {
|
|
195
192
|
// not a repository, or a bad config (delegate reports that): nothing to clean
|
|
196
193
|
}
|
|
197
|
-
|
|
194
|
+
// CHEAPAG_DEBUG_SHUTDOWN=1 traces each shutdown point to stderr (CI logs); silent otherwise
|
|
195
|
+
const shutdownLog = (line) => {
|
|
196
|
+
if (process.env.CHEAPAG_DEBUG_SHUTDOWN === "1")
|
|
197
|
+
process.stderr.write(`cheapag-shutdown: ${line}\n`);
|
|
198
|
+
};
|
|
199
|
+
process.on("exit", () => {
|
|
200
|
+
shutdownLog("exit handler, stopping opencode");
|
|
201
|
+
stopOpencode(true);
|
|
202
|
+
});
|
|
198
203
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
199
|
-
process.on(signal, () =>
|
|
204
|
+
process.on(signal, () => {
|
|
205
|
+
shutdownLog(`signal ${signal}`);
|
|
206
|
+
process.exit();
|
|
207
|
+
});
|
|
200
208
|
// Claude Code closing our stdin is the normal shutdown
|
|
201
|
-
process.stdin.on("end", () =>
|
|
209
|
+
process.stdin.on("end", () => {
|
|
210
|
+
shutdownLog("stdin end");
|
|
211
|
+
process.exit();
|
|
212
|
+
});
|
|
202
213
|
await server.connect(new StdioServerTransport());
|
|
203
214
|
}
|
package/dist/status.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { describe, presetHere, readConsent, routeMoved } from "./consent.js";
|
|
6
6
|
import { detectProvider, LOGIN_COMMAND, providerLabel, redact } from "./credentials.js";
|
|
7
7
|
import { blockedFor, records } from "./jobs.js";
|
|
8
|
-
import { isAlive
|
|
8
|
+
import { isAlive } from "./platform.js";
|
|
9
9
|
import { renderJob } from "./report.js";
|
|
10
10
|
import { spendWindows } from "./routing.js";
|
|
11
11
|
import { projectRoot } from "./workspace.js";
|
|
@@ -17,10 +17,6 @@ const RECENT = 5;
|
|
|
17
17
|
*/
|
|
18
18
|
export function status(args) {
|
|
19
19
|
const out = (line) => process.stdout.write(redact(line) + "\n");
|
|
20
|
-
if (!isSupported()) {
|
|
21
|
-
out(`✗ ${UNSUPPORTED_REASON}`);
|
|
22
|
-
return 1;
|
|
23
|
-
}
|
|
24
20
|
if (args.length) {
|
|
25
21
|
out("✗ usage: cheapag status");
|
|
26
22
|
return 1;
|
package/dist/verify.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { killProcessGroup } from "./platform.js";
|
|
2
|
+
import { gitBash, killProcessGroup, normalisePath } from "./platform.js";
|
|
3
3
|
// a backstop per command; the job's minute cap (its `signal`) bounds the whole job
|
|
4
4
|
const COMMAND_TIMEOUT_MS = 5 * 60_000;
|
|
5
5
|
const OUTPUT_LIMIT = 1024 * 1024;
|
|
@@ -28,8 +28,13 @@ export async function runVerify(cwd, cmds, onStart, signal) {
|
|
|
28
28
|
function runOne(cwd, cmd, signal) {
|
|
29
29
|
const started = Date.now();
|
|
30
30
|
return new Promise((resolve) => {
|
|
31
|
+
// checks are written in bash syntax; on Windows run them through Git Bash when we have it, else let Node pick the shell
|
|
32
|
+
const bash = process.platform === "win32" ? gitBash() : undefined;
|
|
33
|
+
const env = { ...process.env, CI: "1", FORCE_COLOR: "0" };
|
|
31
34
|
// its own process group, so everything it spawned can be stopped with it
|
|
32
|
-
const child =
|
|
35
|
+
const child = bash
|
|
36
|
+
? spawn(bash, ["-c", cmd], { cwd, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env })
|
|
37
|
+
: spawn(cmd, { shell: true, cwd, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env });
|
|
33
38
|
const killGroup = () => killProcessGroup(child.pid, "SIGKILL");
|
|
34
39
|
let output = "";
|
|
35
40
|
let code = null;
|
|
@@ -76,9 +81,11 @@ const MAX_LINES = 25;
|
|
|
76
81
|
* Falls back to the last lines when no failure marker is found.
|
|
77
82
|
*/
|
|
78
83
|
export function errorTail(run, root) {
|
|
84
|
+
// Windows output can hold root natively (`C:\r\repo`) or forward-slash; strip it and forward-slash the rest
|
|
85
|
+
const rootRe = [root, normalisePath(root)].map(escapeRe).join("|");
|
|
79
86
|
const lines = run.output
|
|
80
87
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
81
|
-
.
|
|
88
|
+
.replace(new RegExp(`(?:${rootRe})[\\\\/]([^\\s:'"\\[\\]()]*)`, "g"), (_, rest) => rest.replace(/\\/g, "/"))
|
|
82
89
|
.split(/\r?\n/)
|
|
83
90
|
.map((l) => l.replace(/\t/g, " ").trimEnd())
|
|
84
91
|
.filter((l) => l.trim() && (MARKER.test(l) || !NOISE.test(l)));
|
|
@@ -119,3 +126,5 @@ export function errorTail(run, root) {
|
|
|
119
126
|
export const sameFailure = (a, b) => a.join("\n").replace(/\d+/g, "#") === b.join("\n").replace(/\d+/g, "#");
|
|
120
127
|
/** At most 200 chars, eliding the middle. */
|
|
121
128
|
const clip = (line) => (line.length <= 200 ? line : `${line.slice(0, 120)} … ${line.slice(-77)}`);
|
|
129
|
+
/** Escapes a string for literal use inside a RegExp. */
|
|
130
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
package/dist/worker.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
|
|
2
|
+
import { runningOpencode } from "./opencode.js";
|
|
2
3
|
import { rejectionMessage } from "./permissions.js";
|
|
3
4
|
import { modelName } from "./routing.js";
|
|
4
5
|
/** Worker: one OpenCode session for one job, reduced to what the job layer needs. */
|
|
@@ -175,6 +176,27 @@ export async function openSession(opts) {
|
|
|
175
176
|
}
|
|
176
177
|
return { sessionID, run, totals };
|
|
177
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Disposes the OpenCode instance for `directory`: nothing it still holds open then keeps the directory from being
|
|
181
|
+
* removed (Windows cannot delete one), and a long-lived server does not keep a watcher per past job. Best effort:
|
|
182
|
+
* no running server, or any error, changes nothing.
|
|
183
|
+
*/
|
|
184
|
+
export async function releaseDirectory(directory) {
|
|
185
|
+
const opencode = await runningOpencode();
|
|
186
|
+
if (!opencode)
|
|
187
|
+
return;
|
|
188
|
+
try {
|
|
189
|
+
const client = createOpencodeClient({
|
|
190
|
+
baseUrl: opencode.url,
|
|
191
|
+
directory,
|
|
192
|
+
headers: { authorization: `Basic ${Buffer.from(`opencode:${opencode.password}`).toString("base64")}` },
|
|
193
|
+
});
|
|
194
|
+
await client.instance.dispose({ directory }, { signal: AbortSignal.timeout(10_000) });
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// best effort: a failed dispose must never fail a job
|
|
198
|
+
}
|
|
199
|
+
}
|
|
178
200
|
/** One prompt in a fresh session (the login live check). */
|
|
179
201
|
export async function runSession(opts) {
|
|
180
202
|
const session = await openSession(opts);
|
package/dist/workspace.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { copyFileSync, cpSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { isAlive, link, normalisePath, removeLink } from "./platform.js";
|
|
6
|
+
// Git for Windows refuses paths over 260 chars unless long paths are on; the flag is a no-op elsewhere
|
|
7
|
+
const GIT_ARGS = process.platform === "win32" ? ["-c", "core.longpaths=true"] : [];
|
|
6
8
|
export function git(cwd, args, env) {
|
|
7
|
-
return execFileSync("git", args, { cwd, stdio: "pipe", env: env && { ...process.env, ...env }, maxBuffer: 256 * 1024 * 1024 }).toString();
|
|
9
|
+
return execFileSync("git", [...GIT_ARGS, ...args], { cwd, stdio: "pipe", env: env && { ...process.env, ...env }, maxBuffer: 256 * 1024 * 1024 }).toString();
|
|
8
10
|
}
|
|
9
11
|
/** The repository root containing `cwd`; throws when it is not a git repository. */
|
|
10
12
|
export function projectRoot(cwd) {
|
|
11
13
|
try {
|
|
12
|
-
|
|
14
|
+
// resolved: git prints forward slashes on Windows (C:/x/y); path.* gives the platform's own separators
|
|
15
|
+
return resolve(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
13
16
|
}
|
|
14
17
|
catch {
|
|
15
18
|
throw new Error(`${cwd} is not inside a git repository`);
|
|
@@ -55,7 +58,7 @@ export function createWorktree(root, job, linkDirs) {
|
|
|
55
58
|
}
|
|
56
59
|
const linked = links.map((l) => normalisePath(relative(path, l)));
|
|
57
60
|
// the carried-over state as a tree object: the job's baseline, without committing anything
|
|
58
|
-
git(path, ["add", "-A", "--", ".", ...outside(linked)]);
|
|
61
|
+
git(path, ["add", "-A", "--", ".", ...outside(notIgnored(path, linked))]);
|
|
59
62
|
const base = git(path, ["write-tree"]).trim();
|
|
60
63
|
return { path, head, stash, base, links, linked };
|
|
61
64
|
}
|
|
@@ -66,13 +69,17 @@ function discard(root, path, links) {
|
|
|
66
69
|
git(root, ["worktree", "remove", "--force", path]);
|
|
67
70
|
}
|
|
68
71
|
catch {
|
|
69
|
-
// git no longer knows it (pruned by hand): rm never follows symlinks
|
|
70
|
-
|
|
72
|
+
// git no longer knows it (pruned by hand): rm never follows symlinks.
|
|
73
|
+
// OpenCode releases a disposed directory's handles asynchronously, and Windows refuses to
|
|
74
|
+
// delete a directory that is still open; rmSync only retries EBUSY/EPERM-type errors.
|
|
75
|
+
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
71
76
|
git(root, ["worktree", "prune"]);
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
/** Pathspecs that keep Cheap Agent's state and the given dependency dirs out of a git operation. */
|
|
75
80
|
const outside = (dirs) => [".cheapag", ...dirs].map((d) => `:(exclude,top,literal)${d}`);
|
|
81
|
+
// git 2.55 rejects a pathspec naming an ignored path, even as an exclusion; an already-ignored dir needs no exclusion
|
|
82
|
+
const notIgnored = (dir, paths) => paths.filter((p) => spawnSync("git", [...GIT_ARGS, "check-ignore", "-q", "--", p], { cwd: dir, stdio: "pipe" }).status !== 0);
|
|
76
83
|
const isDirectory = (path) => {
|
|
77
84
|
try {
|
|
78
85
|
return statSync(path).isDirectory();
|
|
@@ -86,7 +93,7 @@ export function worktreeDiff(worktree) {
|
|
|
86
93
|
const { path, base } = worktree;
|
|
87
94
|
// the worktree's own index: throwaway, never committed. A linked dir is a symlink, which a `node_modules/`
|
|
88
95
|
// ignore rule does not match, so the linked ones (only those: a tracked vendor/ stays in) are excluded.
|
|
89
|
-
git(path, ["add", "-A", "--", ".", ...outside(worktree.linked)]);
|
|
96
|
+
git(path, ["add", "-A", "--", ".", ...outside(notIgnored(path, worktree.linked))]);
|
|
90
97
|
const patch = git(path, ["diff", "--cached", "--binary", base]);
|
|
91
98
|
const stats = git(path, ["diff", "--cached", "--numstat", "-z", "--no-renames", base]).split("\0").filter(Boolean);
|
|
92
99
|
const statuses = new Map();
|
|
@@ -236,7 +243,7 @@ export function land(root, job, patch, paths) {
|
|
|
236
243
|
}
|
|
237
244
|
/** git apply's verdict lives in stderr even when it exits 0. */
|
|
238
245
|
function applyOutput(root, args, env, patch) {
|
|
239
|
-
const r = spawnSync("git", args, { cwd: root, env: { ...process.env, ...env }, input: patch, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
246
|
+
const r = spawnSync("git", [...GIT_ARGS, ...args], { cwd: root, env: { ...process.env, ...env }, input: patch, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
240
247
|
return { failed: r.status !== 0, output: `${r.stdout}${r.stderr}` };
|
|
241
248
|
}
|
|
242
249
|
/** The paths a failed landing names as conflicting, from git apply's messages. */
|
|
@@ -254,7 +261,7 @@ export function reapply(worktree, patch) {
|
|
|
254
261
|
}
|
|
255
262
|
/** Paths whose changes still add conflict marker lines; run after `worktreeDiff`, which stages the worktree. */
|
|
256
263
|
export function leftoverMarkers(worktree) {
|
|
257
|
-
const r = spawnSync("git", ["diff", "--cached", "--check", worktree.base], { cwd: worktree.path, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
264
|
+
const r = spawnSync("git", [...GIT_ARGS, "diff", "--cached", "--check", worktree.base], { cwd: worktree.path, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
258
265
|
return [...new Set([...r.stdout.matchAll(/^(.+?):\d+: leftover conflict marker$/gm)].map((m) => m[1]))];
|
|
259
266
|
}
|
|
260
267
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cheapag",
|
|
3
3
|
"displayName": "Cheap Agent",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.1.1",
|
|
5
5
|
"description": "Claude plans and reviews while a cheaper model implements: the cheapag MCP server, the plan-writing skill, status, allow, hook and clean commands, and an opt-in edit guard.",
|
|
6
6
|
"author": { "name": "muaaz9329" },
|
|
7
7
|
"keywords": ["delegation", "opencode", "mcp", "cost"]
|
package/plugin/.mcp.json
CHANGED
package/plugin/hooks/hooks.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "r=$(git rev-parse --show-toplevel 2>/dev/null) && { [ -f \"$r/.cheapag/hook.json\" ] || [ -f \"$r/.cheapag/hook/takeover\" ]; } && npx -y cheap-agent@1.
|
|
10
|
+
"command": "r=$(git rev-parse --show-toplevel 2>/dev/null) && { [ -f \"$r/.cheapag/hook.json\" ] || [ -f \"$r/.cheapag/hook/takeover\" ]; } && npx -y cheap-agent@1.1.1 hook guard; exit 0",
|
|
11
11
|
"timeout": 60
|
|
12
12
|
}
|
|
13
13
|
]
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"hooks": [
|
|
20
20
|
{
|
|
21
21
|
"type": "command",
|
|
22
|
-
"command": "npx -y cheap-agent@1.
|
|
22
|
+
"command": "npx -y cheap-agent@1.1.1 hook result; exit 0",
|
|
23
23
|
"timeout": 60
|
|
24
24
|
}
|
|
25
25
|
]
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"hooks": [
|
|
31
31
|
{
|
|
32
32
|
"type": "command",
|
|
33
|
-
"command": "npx -y cheap-agent@1.
|
|
33
|
+
"command": "npx -y cheap-agent@1.1.1 hook context",
|
|
34
34
|
"timeout": 60
|
|
35
35
|
}
|
|
36
36
|
]
|
|
@@ -3,9 +3,9 @@ name: allow
|
|
|
3
3
|
description: Opt this repository in to sending code to Cheap Agent's worker models - all models, --zdr-only (zero-retention only), or --models provider/model,...
|
|
4
4
|
argument-hint: "[--zdr-only | --models provider/model,...]"
|
|
5
5
|
disable-model-invocation: true
|
|
6
|
-
allowed-tools: Bash(npx -y cheap-agent@1.
|
|
6
|
+
allowed-tools: Bash(npx -y cheap-agent@1.1.1 allow) Bash(npx -y cheap-agent@1.1.1 allow *)
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
!`npx -y cheap-agent@1.
|
|
9
|
+
!`npx -y cheap-agent@1.1.1 allow $ARGUMENTS`
|
|
10
10
|
|
|
11
11
|
The developer ran this consent command themselves; its output is above. Report it in one line. Do not run `cheapag allow` or change consent in any other way.
|
|
@@ -3,9 +3,9 @@ name: clean
|
|
|
3
3
|
description: Remove Cheap Agent's old worktrees and job logs now; --all removes everything not in flight.
|
|
4
4
|
argument-hint: "[--all]"
|
|
5
5
|
disable-model-invocation: true
|
|
6
|
-
allowed-tools: Bash(npx -y cheap-agent@1.
|
|
6
|
+
allowed-tools: Bash(npx -y cheap-agent@1.1.1 clean) Bash(npx -y cheap-agent@1.1.1 clean *)
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
!`npx -y cheap-agent@1.
|
|
9
|
+
!`npx -y cheap-agent@1.1.1 clean $ARGUMENTS`
|
|
10
10
|
|
|
11
11
|
Report the result above to the user in one or two lines.
|
|
@@ -3,9 +3,9 @@ name: hook
|
|
|
3
3
|
description: Turn Cheap Agent's edit guard on or off for this repo - when on, Claude's large edits (or a 3rd edit to one file in a turn) are denied and redirected to delegate.
|
|
4
4
|
argument-hint: "[on | off]"
|
|
5
5
|
disable-model-invocation: true
|
|
6
|
-
allowed-tools: Bash(npx -y cheap-agent@1.
|
|
6
|
+
allowed-tools: Bash(npx -y cheap-agent@1.1.1 hook) Bash(npx -y cheap-agent@1.1.1 hook *)
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
!`npx -y cheap-agent@1.
|
|
9
|
+
!`npx -y cheap-agent@1.1.1 hook $ARGUMENTS`
|
|
10
10
|
|
|
11
11
|
The developer ran this command themselves; its output is above. Report it in one line. Do not turn the edit guard on or off in any other way.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: status
|
|
3
3
|
description: Show Cheap Agent's status for this repo - running jobs, recent results, consent, and tracked spend against OpenCode Go windows.
|
|
4
|
-
allowed-tools: Bash(npx -y cheap-agent@1.
|
|
4
|
+
allowed-tools: Bash(npx -y cheap-agent@1.1.1 status)
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
!`npx -y cheap-agent@1.
|
|
7
|
+
!`npx -y cheap-agent@1.1.1 status`
|
|
8
8
|
|
|
9
9
|
Show the status above to the user as is. Add a line only if something needs their action (a BLOCKED job to reply to, a missing consent, a Go window near its cap).
|