cheap-agent 0.0.0-stage → 1.0.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 +151 -2
- package/dist/allow.js +53 -0
- package/dist/cleanup.js +177 -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 +74 -0
- package/dist/jobs.js +1170 -0
- package/dist/login.js +223 -0
- package/dist/opencode.js +258 -0
- package/dist/permissions.js +122 -0
- package/dist/plan.js +117 -0
- package/dist/platform.js +52 -0
- package/dist/report.js +178 -0
- package/dist/research.js +264 -0
- package/dist/routing.js +212 -0
- package/dist/rules.js +25 -0
- package/dist/server.js +203 -0
- package/dist/status.js +105 -0
- package/dist/verify.js +121 -0
- package/dist/worker.js +237 -0
- package/dist/workspace.js +278 -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/dist/login.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { loadConfig, userConfigFile } from "./config.js";
|
|
8
|
+
import { detectProvider, LOGIN_COMMAND as LOGIN, PROVIDERS, providerLabel as label, redact, savedProviders } from "./credentials.js";
|
|
9
|
+
import { ensureOpencode, opencodeBinary, stopOpencode } from "./opencode.js";
|
|
10
|
+
import { isSupported, UNSUPPORTED_REASON } from "./platform.js";
|
|
11
|
+
import { installPlugin, stableNode } from "./install.js";
|
|
12
|
+
import { fullRoute, modelName, presetOf, PRESET_NAMES, PRESETS } from "./routing.js";
|
|
13
|
+
import { runSession } from "./worker.js";
|
|
14
|
+
/**
|
|
15
|
+
* `cheapag login`: the only setup (ADR-0004). Picks a provider, lets the bundled OpenCode's own login take the key
|
|
16
|
+
* (hidden input, saved in OpenCode's store; Cheap Agent never sees it), live-checks it, then offers to register
|
|
17
|
+
* the server with Claude Code at user scope and install the plugin (ticket 31). `npx cheap-agent` in a terminal runs it too.
|
|
18
|
+
*/
|
|
19
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
20
|
+
const CHECK_TIMEOUT_MS = 90_000;
|
|
21
|
+
const CHOICES = [...PRESET_NAMES, "custom"];
|
|
22
|
+
const isChoice = (s) => CHOICES.includes(s);
|
|
23
|
+
export async function login(args = []) {
|
|
24
|
+
const out = (line = "") => process.stdout.write(redact(line) + "\n");
|
|
25
|
+
if (!isSupported()) {
|
|
26
|
+
out(`✗ ${UNSUPPORTED_REASON}`);
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
const flag = args.length === 2 && args[0] === "--preset" ? args[1] : undefined;
|
|
30
|
+
if (args.length && flag === undefined) {
|
|
31
|
+
out(`✗ usage: cheapag login [--preset ${CHOICES.join("|")}]`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
if (flag !== undefined && !isChoice(flag)) {
|
|
35
|
+
out(`✗ "${flag}" is not a preset; use one of ${CHOICES.join(", ")}; nothing changed`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
if (!process.stdin.isTTY) {
|
|
39
|
+
const saved = detectProvider();
|
|
40
|
+
if (!saved) {
|
|
41
|
+
out(`✗ ${LOGIN} needs an interactive terminal (the key is typed into a hidden prompt)`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
// no terminal to type a key into: only the worker models are set, for the provider already logged in (scripts, the benchmark)
|
|
45
|
+
const choice = flag ?? currentPreset(saved.provider) ?? "balanced";
|
|
46
|
+
if (!flag)
|
|
47
|
+
out(`No terminal to ask in: worker models stay ${choice} for ${label(saved.provider)}; pass --preset to choose`);
|
|
48
|
+
return savePreset(saved.provider, choice, out) ? 0 : 1;
|
|
49
|
+
}
|
|
50
|
+
// the live check's OpenCode runs in its own process group; take it down on Ctrl-C too
|
|
51
|
+
process.on("exit", () => stopOpencode(true));
|
|
52
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
53
|
+
process.on(signal, () => process.exit(130));
|
|
54
|
+
out("Cheap Agent login");
|
|
55
|
+
const current = detectProvider();
|
|
56
|
+
if (current)
|
|
57
|
+
out(`Currently using: ${label(current.provider)}. Logging in again replaces or adds a provider.`);
|
|
58
|
+
PROVIDERS.forEach((p, i) => out(` ${i + 1}) ${p.label}`));
|
|
59
|
+
const answer = await ask(`Provider [1-${PROVIDERS.length}]: `);
|
|
60
|
+
const picked = PROVIDERS[Number(answer) - 1];
|
|
61
|
+
if (!picked) {
|
|
62
|
+
out(`✗ "${answer}" is not one of 1-${PROVIDERS.length}; nothing changed`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const preset = currentPreset(picked.id) ?? "balanced";
|
|
66
|
+
const choice = flag ?? ((await ask(`Worker models: cheapest / balanced (recommended) / strongest [${preset}]: `)) || preset);
|
|
67
|
+
if (!isChoice(choice)) {
|
|
68
|
+
out(`✗ "${choice}" is not one of ${CHOICES.join(", ")}; nothing changed`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
// the bundled OpenCode prompts for the key with hidden input and saves it in its own credential store
|
|
72
|
+
const saved = spawnSync(opencodeBinary(), ["auth", "login", "--provider", picked.id], { stdio: "inherit" });
|
|
73
|
+
if (saved.status !== 0 || !savedProviders().includes(picked.id)) {
|
|
74
|
+
out(`✗ ${picked.label} login was not saved; run ${LOGIN} to try again`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
if (!savePreset(picked.id, choice, out))
|
|
78
|
+
return 1;
|
|
79
|
+
const provider = { provider: picked.id, via: "login" };
|
|
80
|
+
const check = await liveCheck(provider);
|
|
81
|
+
out(check.ok ? `✓ ${check.line}` : `✗ ${check.line}`);
|
|
82
|
+
const active = detectProvider();
|
|
83
|
+
if (active.provider !== provider.provider)
|
|
84
|
+
out(`Note: ${label(active.provider)} is also saved and is used first.`);
|
|
85
|
+
if (!check.ok)
|
|
86
|
+
return 1; // the login stays saved; fix the cause and run login again
|
|
87
|
+
await register(out);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
/** The user-level config as written, so saving a preset keeps its other keys; {} when there is none. */
|
|
91
|
+
function readUser() {
|
|
92
|
+
const file = userConfigFile();
|
|
93
|
+
if (!existsSync(file))
|
|
94
|
+
return {};
|
|
95
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
96
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
97
|
+
throw new Error("must hold a JSON object");
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
/** The preset the user's config holds, when it sets `models` (Enter keeps it); a damaged file counts as none. */
|
|
101
|
+
function currentPreset(provider) {
|
|
102
|
+
try {
|
|
103
|
+
const { models } = readUser();
|
|
104
|
+
return models ? presetOf(provider, models) : undefined;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Writes the preset as the user config's `models`, other keys kept; custom writes nothing. */
|
|
111
|
+
function savePreset(provider, choice, out) {
|
|
112
|
+
const file = userConfigFile();
|
|
113
|
+
if (choice === "custom") {
|
|
114
|
+
out(`Worker models: custom, nothing written; set "models" in a repo's cheapag.json, or in ${file} for every repo`);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const config = { ...readUser(), models: PRESETS[provider][choice] };
|
|
119
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
120
|
+
writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
out(`✗ ${file}: ${err.message}; worker models not saved`);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
out(`✓ Worker models: ${choice} (implement ${PRESETS[provider][choice].implement}), saved in ${file}`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/** One tiny real call to the implement model the user's config now picks, through the bundled OpenCode, in a throwaway directory. */
|
|
130
|
+
async function liveCheck(provider) {
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), "cheapag-login-"));
|
|
132
|
+
let name = provider.provider;
|
|
133
|
+
let timer;
|
|
134
|
+
try {
|
|
135
|
+
// the throwaway dir has no cheapag.json, so this is the user's config alone
|
|
136
|
+
const route = fullRoute(provider.provider, loadConfig(dir).models);
|
|
137
|
+
const model = route.implement;
|
|
138
|
+
name = modelName(model);
|
|
139
|
+
// with the route, OpenCode's config pins this model too (on OpenRouter: data collection denied)
|
|
140
|
+
const run = ensureOpencode(provider, dir, route).then((opencode) => runSession({
|
|
141
|
+
opencode,
|
|
142
|
+
directory: dir,
|
|
143
|
+
model: { providerID: model.providerID, id: model.id },
|
|
144
|
+
prompt: "Live check: reply with the single word OK. Do not use any tools.",
|
|
145
|
+
onEvent: () => { },
|
|
146
|
+
}));
|
|
147
|
+
// covers boot as well as the answer; on timeout, stopping OpenCode below ends the session, and login exits
|
|
148
|
+
const timeout = new Promise((_, reject) => (timer = setTimeout(() => reject(new Error("no answer within 90s")), CHECK_TIMEOUT_MS)));
|
|
149
|
+
const result = await Promise.race([run, timeout]);
|
|
150
|
+
if (result.error)
|
|
151
|
+
return { ok: false, line: explain(result.error, name) };
|
|
152
|
+
return { ok: true, line: `${name} answered (cost $${result.usage.costUsd.toFixed(4)})` };
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
return { ok: false, line: explain(err.message, name) };
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
stopOpencode();
|
|
160
|
+
rmSync(dir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** A one-line reason and fix for the common live-check failures. */
|
|
164
|
+
function explain(message, model) {
|
|
165
|
+
const first = message.split("\n")[0];
|
|
166
|
+
if (/\b401\b|unauthori[sz]ed|authentication|invalid.*key|api key/i.test(message))
|
|
167
|
+
return `the key was rejected (${first}); run ${LOGIN} again with the right key`;
|
|
168
|
+
if (/\b402\b|credit|insufficient|balance|quota/i.test(message))
|
|
169
|
+
return `the account has no credit (${first}); top it up, then run ${LOGIN} again`;
|
|
170
|
+
if (/\b404\b|not found|no endpoints|not available/i.test(message))
|
|
171
|
+
return `${model} is unavailable to this account (${first}); run ${LOGIN} and pick another provider`;
|
|
172
|
+
return `live check failed: ${first}`;
|
|
173
|
+
}
|
|
174
|
+
/** Offers user-scope registration with Claude Code, then the plugin; prints the commands whenever it does not run them. */
|
|
175
|
+
async function register(out) {
|
|
176
|
+
const command = serverCommand();
|
|
177
|
+
const manual = `claude mcp add -s user cheapag -- ${command.map(quote).join(" ")}`;
|
|
178
|
+
const existing = spawnSync("claude", ["mcp", "get", "cheapag"], { encoding: "utf8" });
|
|
179
|
+
if (existing.error) {
|
|
180
|
+
out(`Claude Code's CLI was not found. To add cheapag for all your projects, run:\n ${manual}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// only a user-scope entry counts: a local or project one doesn't cover "all your projects"
|
|
184
|
+
// shortcut: an existing user-scope entry is kept even if it points at an older install; re-register by hand after moving it
|
|
185
|
+
if (existing.status === 0 && /Scope:\s*User/i.test(existing.stdout)) {
|
|
186
|
+
out("✓ cheapag is already registered with Claude Code");
|
|
187
|
+
finish(out, installPlugin(out));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (/^n/i.test(await ask("Add cheapag to Claude Code for all your projects? [Y/n] "))) {
|
|
191
|
+
out(`Not registered. To add it later, run:\n ${manual}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const added = spawnSync("claude", ["mcp", "add", "-s", "user", "cheapag", "--", ...command], { encoding: "utf8" });
|
|
195
|
+
if (added.status !== 0) {
|
|
196
|
+
out(`✗ claude mcp add failed (${(added.stderr || added.stdout).trim().split("\n")[0]}). Run it yourself:\n ${manual}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
out("✓ Added cheapag to Claude Code for all your projects");
|
|
200
|
+
finish(out, installPlugin(out));
|
|
201
|
+
}
|
|
202
|
+
/** The last thing setup says: how to start. */
|
|
203
|
+
function finish(out, plugin) {
|
|
204
|
+
out(plugin
|
|
205
|
+
? "\nDone. Open a new Claude Code session in your project and run /cheapag:allow once there; then ask for changes as usual."
|
|
206
|
+
: "\nThe server is set up; add the plugin with the commands above, then open a new Claude Code session.");
|
|
207
|
+
}
|
|
208
|
+
/** How Claude Code should start this installed package: npx when run from npx's cache, else this exact file. */
|
|
209
|
+
function serverCommand() {
|
|
210
|
+
const entry = realpathSync(process.argv[1]);
|
|
211
|
+
return entry.includes("/_npx/") ? ["npx", "-y", `cheap-agent@${version}`] : [stableNode(), entry];
|
|
212
|
+
}
|
|
213
|
+
const quote = (arg) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`);
|
|
214
|
+
/** One question on a fresh readline, closed before anything else (OpenCode's login) reads the terminal. */
|
|
215
|
+
async function ask(question) {
|
|
216
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
217
|
+
try {
|
|
218
|
+
return (await rl.question(question)).trim();
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
rl.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
package/dist/opencode.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { arch } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { baselinePermission } from "./permissions.js";
|
|
8
|
+
import { killProcessGroup, opencodePlatform } from "./platform.js";
|
|
9
|
+
import { fullRoute, modelName, routeModels } from "./routing.js";
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
export const OPENCODE_PIN = require("opencode-ai/package.json").version;
|
|
12
|
+
/**
|
|
13
|
+
* The binary comes from opencode-ai's platform package, never PATH. We skip opencode-ai/bin because
|
|
14
|
+
* its postinstall (which fills it in) does not run under pnpm or npm's install-scripts gate.
|
|
15
|
+
* shortcut: takes the first installed variant, not the avx2/musl-aware pick postinstall makes; mirror it if x64 baseline users report crashes.
|
|
16
|
+
*/
|
|
17
|
+
export function opencodeBinary() {
|
|
18
|
+
const { os, binary } = opencodePlatform();
|
|
19
|
+
const base = `opencode-${os}-${arch()}`;
|
|
20
|
+
const fromPkg = createRequire(require.resolve("opencode-ai/package.json"));
|
|
21
|
+
for (const name of [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]) {
|
|
22
|
+
try {
|
|
23
|
+
const bin = join(dirname(fromPkg.resolve(`${name}/package.json`)), "bin", binary);
|
|
24
|
+
if (existsSync(bin))
|
|
25
|
+
return bin;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// variant not installed for this platform
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`no OpenCode binary installed for ${base}; reinstall cheap-agent`);
|
|
32
|
+
}
|
|
33
|
+
const WORKER_PROMPT = `---
|
|
34
|
+
description: Cheap Agent worker. Implements plan steps in an isolated git worktree.
|
|
35
|
+
mode: primary
|
|
36
|
+
---
|
|
37
|
+
You implement the plan step marked >>> CURRENT STEP(S), in this repository, by editing files yourself.
|
|
38
|
+
- Stay within the step's files: and make precise edits. If you must edit anything else, report it under risks.
|
|
39
|
+
- Never install packages or run install commands. If the step needs a package that is not installed, ask with the question tool, worded exactly: step needs package <name>@<range> (not installed). Install it in the main checkout, then reply "installed", or reply "no" to proceed without. Options: installed, no.
|
|
40
|
+
- Never touch .git, .env* files or lockfiles. Never commit.
|
|
41
|
+
- If the step is ambiguous or conflicts with its dont:, ask with the question tool instead of guessing.
|
|
42
|
+
- When a Source section is given (the user's request or the issue, verbatim), the step is someone's reading of it: check the step against the source before you start. Implement the step as written, but name every point where they disagree (a direction, an order, a value, an approach the source prefers) in finish's disagree. When the source names a preferred approach or links a fix, implement that one and say so in summary.
|
|
43
|
+
- If the step fixes a bug or a wrong behaviour, prove it: first write a small test that reproduces the problem and run it to see it fail, then fix the code and run it again to see it pass. Write the test first, before reading widely. When the step names the exact change (the file and the code), make that change first, then write the test. Keep the test in the change, and run it with the same command the step's checks use (e.g. its test runner plus the test file or id).
|
|
44
|
+
- The reproduction test asserts the exact values, order and types the source or step states or implies. "Does not crash" or "contains X" is not enough when the expected value is known.
|
|
45
|
+
- When done, call the finish tool exactly once, last: summary (at most 160 chars), risks, review (path:start-end, max 5), missing, repro (the command that runs your reproduction test, "none: <why>" if you could not reproduce it, "n/a" if the step is not a fix), disagree (where the step and the source disagree, or "none").
|
|
46
|
+
- If you cannot call finish, end your last message with a fenced cheapag-result block holding the same fields.
|
|
47
|
+
`;
|
|
48
|
+
// The research worker reports in its final text, not a custom tool: its ruleset stays exactly read, glob, grep, list
|
|
49
|
+
const RESEARCH_PROMPT = `---
|
|
50
|
+
description: Cheap Agent research worker. Answers questions about this repository, read-only.
|
|
51
|
+
mode: primary
|
|
52
|
+
---
|
|
53
|
+
You answer one question about the code in this repository. You are read-only: you can read, grep, glob and list, nothing else (no edits, no commands, no questions, no web).
|
|
54
|
+
- Answer from the code itself, never from memory. Cite what you state as path:line or path:start-end, paths relative to the repository root.
|
|
55
|
+
- Stay within the scope and the word limit you are given.
|
|
56
|
+
- End your last message with exactly one fenced block:
|
|
57
|
+
\`\`\`cheapag-research
|
|
58
|
+
status: answer (or unsure, if the code does not settle the question)
|
|
59
|
+
confidence: high (seen directly in code) | medium (inferred) | low (a guess)
|
|
60
|
+
refs: src/a.ts:18-42, src/b.ts:31
|
|
61
|
+
answer:
|
|
62
|
+
<your answer, in prose, within the word limit>
|
|
63
|
+
\`\`\`
|
|
64
|
+
`;
|
|
65
|
+
// Runs inside OpenCode (Bun). Its input is captured from the event stream, so it only has to exist and validate.
|
|
66
|
+
export const FINISH_TOOL_SOURCE = `import { tool } from "@opencode-ai/plugin";
|
|
67
|
+
|
|
68
|
+
export default tool({
|
|
69
|
+
description: "Report the step as done. Call exactly once, as your last action.",
|
|
70
|
+
args: {
|
|
71
|
+
summary: tool.schema.string().max(160).describe("What you did, at most 160 characters"),
|
|
72
|
+
risks: tool.schema.array(tool.schema.string()).describe("Assumptions, unverified conditions, edits outside files:"),
|
|
73
|
+
review: tool.schema.array(tool.schema.string()).max(5).describe("Ranges worth a human look, as path:start-end"),
|
|
74
|
+
missing: tool.schema.array(tool.schema.string()).describe("Anything the step asked for that you did not do"),
|
|
75
|
+
repro: tool.schema.string().describe("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"),
|
|
76
|
+
disagree: tool.schema.string().describe("Where the step's spec and the Source you were given disagree (direction, order, value, approach), or 'none'"),
|
|
77
|
+
},
|
|
78
|
+
async execute() {
|
|
79
|
+
return "Reported. Stop now.";
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
`;
|
|
83
|
+
/**
|
|
84
|
+
* Runs inside OpenCode (Bun), for every session: grep returns matching lines, and the read deny on .env* does not
|
|
85
|
+
* cover it (proven on 1.19, even with an explicit include: ".env*"), so .env* match blocks are cut from grep output
|
|
86
|
+
* before the model sees them. .env.example and .env.sample stay visible. Glob and list only return names.
|
|
87
|
+
*/
|
|
88
|
+
export const SECRETS_PLUGIN = `const SECRET = /(^|[\\\\/])\\.env(?!\\.(example|sample)$)[^\\\\/]*$/;
|
|
89
|
+
|
|
90
|
+
export const CheapagSecrets = async () => ({
|
|
91
|
+
"tool.execute.after": async (input, output) => {
|
|
92
|
+
if (input.tool !== "grep" || typeof output.output !== "string") return;
|
|
93
|
+
let file = "";
|
|
94
|
+
let hidden = 0;
|
|
95
|
+
const kept = [];
|
|
96
|
+
for (const line of output.output.split("\\n")) {
|
|
97
|
+
const header = line.match(/^(\\S.*):$/);
|
|
98
|
+
if (header) file = header[1];
|
|
99
|
+
else if (line === "") file = "";
|
|
100
|
+
if (SECRET.test(file)) {
|
|
101
|
+
if (header) hidden++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
kept.push(line);
|
|
105
|
+
}
|
|
106
|
+
if (hidden) output.output = kept.join("\\n") + "\\n(" + hidden + " .env file(s) hidden by cheapag)";
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
`;
|
|
110
|
+
/** Cheap Agent's own OpenCode config dir: worker and research agents, finish tool, secrets plugin, model pins and options. */
|
|
111
|
+
export function writeConfigDir(dir, config) {
|
|
112
|
+
mkdirSync(join(dir, "agents"), { recursive: true });
|
|
113
|
+
mkdirSync(join(dir, "tools"), { recursive: true });
|
|
114
|
+
mkdirSync(join(dir, "plugins"), { recursive: true });
|
|
115
|
+
writeFileSync(join(dir, "agents", "cheapag-worker.md"), WORKER_PROMPT);
|
|
116
|
+
writeFileSync(join(dir, "agents", "cheapag-research.md"), RESEARCH_PROMPT);
|
|
117
|
+
writeFileSync(join(dir, "tools", "finish.ts"), FINISH_TOOL_SOURCE);
|
|
118
|
+
writeFileSync(join(dir, "plugins", "cheapag-secrets.js"), SECRETS_PLUGIN);
|
|
119
|
+
writeFileSync(join(dir, "opencode.json"), config);
|
|
120
|
+
}
|
|
121
|
+
/** The generated opencode.json for the route's models: their options (e.g. OpenRouter routing) and the small model. */
|
|
122
|
+
function opencodeConfig(provider, route) {
|
|
123
|
+
// the key itself stays in OpenCode's own credential store; the config never names one
|
|
124
|
+
const models = Object.fromEntries(routeModels(route).filter((m) => m.options).map((m) => [m.id, { options: m.options }]));
|
|
125
|
+
const providerConfig = Object.keys(models).length ? { models } : {};
|
|
126
|
+
const config = {
|
|
127
|
+
$schema: "https://opencode.ai/config.json",
|
|
128
|
+
// OpenCode otherwise sends title generation to its own default small model, i.e. another provider the user never chose;
|
|
129
|
+
// the route's implement model is always an allowlisted one
|
|
130
|
+
small_model: modelName(route.implement),
|
|
131
|
+
provider: Object.keys(providerConfig).length ? { [provider.provider]: providerConfig } : {},
|
|
132
|
+
};
|
|
133
|
+
return JSON.stringify(config, null, 2) + "\n";
|
|
134
|
+
}
|
|
135
|
+
let started;
|
|
136
|
+
let child;
|
|
137
|
+
/** The config the running server was booted with. */
|
|
138
|
+
let booted;
|
|
139
|
+
/**
|
|
140
|
+
* Starts (once per process, until stopOpencode) or attaches to the OpenCode server and checks it runs the pinned version.
|
|
141
|
+
* Its generated config lives in `<projectDir>/.cheapag/opencode/`; projectDir only matters on the call that boots.
|
|
142
|
+
* `route` is a job's allowlisted models; OpenCode reads their options only at boot, so a job whose config differs reboots it.
|
|
143
|
+
* Without one (ping, login) any running server will do.
|
|
144
|
+
*/
|
|
145
|
+
let holds = 0;
|
|
146
|
+
/** Marks the server in use for a job's or research call's lifetime, so a config change never reboots it under them. Returns the release. */
|
|
147
|
+
export function holdOpencode() {
|
|
148
|
+
holds++;
|
|
149
|
+
let held = true;
|
|
150
|
+
return () => {
|
|
151
|
+
if (held)
|
|
152
|
+
holds--;
|
|
153
|
+
held = false;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function ensureOpencode(provider, projectDir = process.cwd(), route) {
|
|
157
|
+
const config = opencodeConfig(provider, route ?? fullRoute(provider.provider));
|
|
158
|
+
if (started && route && booted !== config) {
|
|
159
|
+
// a reboot would kill every live session: refuse it while any job or research call holds the server
|
|
160
|
+
// shortcut: one server per config would let both run; worth it only if config changes mid-session become common
|
|
161
|
+
if (holds > 0)
|
|
162
|
+
return Promise.reject(new Error("the model configuration changed (consent or cheapag.json models) while jobs are running; retry once they finish"));
|
|
163
|
+
stopOpencode();
|
|
164
|
+
}
|
|
165
|
+
if (!started)
|
|
166
|
+
booted = config;
|
|
167
|
+
started ??= boot(projectDir, config).catch((err) => {
|
|
168
|
+
stopOpencode();
|
|
169
|
+
started = undefined;
|
|
170
|
+
throw err;
|
|
171
|
+
});
|
|
172
|
+
return started;
|
|
173
|
+
}
|
|
174
|
+
async function boot(projectDir, config) {
|
|
175
|
+
const configDir = join(projectDir, ".cheapag", "opencode");
|
|
176
|
+
writeConfigDir(configDir, config);
|
|
177
|
+
const external = process.env.CHEAPAG_OPENCODE_URL;
|
|
178
|
+
const password = (external && process.env.CHEAPAG_OPENCODE_PASSWORD) || randomBytes(24).toString("base64url");
|
|
179
|
+
const url = external || (await spawnServer(configDir, password));
|
|
180
|
+
const res = await fetch(new URL("/global/health", url), {
|
|
181
|
+
headers: { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` },
|
|
182
|
+
signal: AbortSignal.timeout(5_000),
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok)
|
|
185
|
+
throw new Error(`opencode health check at ${url} answered ${res.status}`);
|
|
186
|
+
const { version } = (await res.json());
|
|
187
|
+
if (version !== OPENCODE_PIN) {
|
|
188
|
+
throw new Error(`opencode version ${version}, expected ${OPENCODE_PIN}; reinstall cheap-agent`);
|
|
189
|
+
}
|
|
190
|
+
return { url, password, version };
|
|
191
|
+
}
|
|
192
|
+
function spawnServer(configDir, password) {
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
// detached: its own process group, so stopOpencode() also takes down anything it spawned
|
|
195
|
+
const proc = spawn(opencodeBinary(), ["serve", "--hostname", "127.0.0.1", "--port", "0"], {
|
|
196
|
+
detached: true,
|
|
197
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
198
|
+
env: {
|
|
199
|
+
...process.env,
|
|
200
|
+
OPENCODE_SERVER_PASSWORD: password,
|
|
201
|
+
OPENCODE_CONFIG_DIR: configDir,
|
|
202
|
+
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
203
|
+
// the server-wide baseline; each worker session also carries the full ruleset, which wins (src/permissions.ts)
|
|
204
|
+
OPENCODE_PERMISSION: JSON.stringify(baselinePermission()),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
child = proc;
|
|
208
|
+
let output = "";
|
|
209
|
+
let listening = false;
|
|
210
|
+
const timer = setTimeout(() => fail(new Error("opencode serve did not start within 15s")), 15_000);
|
|
211
|
+
const fail = (err) => {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
stopOpencode();
|
|
214
|
+
reject(err);
|
|
215
|
+
};
|
|
216
|
+
// keep draining both pipes for the server's lifetime (a full pipe blocks it), but only buffer until it listens
|
|
217
|
+
proc.stderr.on("data", (chunk) => listening || (output += chunk));
|
|
218
|
+
proc.stdout.on("data", (chunk) => {
|
|
219
|
+
if (listening)
|
|
220
|
+
return;
|
|
221
|
+
output += chunk;
|
|
222
|
+
const match = output.match(/opencode server listening on (http:\/\/\S+)/);
|
|
223
|
+
if (match) {
|
|
224
|
+
listening = true;
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
resolve(match[1]);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
proc.on("error", fail);
|
|
230
|
+
proc.on("exit", (code) => {
|
|
231
|
+
if (listening) {
|
|
232
|
+
// a crash after startup: forget the dead server so the next call boots a fresh one. An old server exiting
|
|
233
|
+
// after a reboot is not this boot's failure: it must never stop the server that replaced it.
|
|
234
|
+
if (child === proc)
|
|
235
|
+
started = child = undefined;
|
|
236
|
+
}
|
|
237
|
+
else
|
|
238
|
+
fail(new Error(`opencode serve exited (${code}): ${output.trim().slice(-500)}`));
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Stops the OpenCode server's whole process group. Seen on 1.19.0: a SIGTERM shortly after boot, while it is still
|
|
244
|
+
* busy, can be ignored for good, so a SIGKILL follows if it is still there after a grace period. On process exit
|
|
245
|
+
* (`exiting`) there is no later: it is killed outright (its SQLite WAL survives that).
|
|
246
|
+
*/
|
|
247
|
+
export function stopOpencode(exiting = false) {
|
|
248
|
+
started = undefined; // the next ensureOpencode boots afresh
|
|
249
|
+
const proc = child;
|
|
250
|
+
child = undefined;
|
|
251
|
+
if (proc?.exitCode !== null || !proc.pid)
|
|
252
|
+
return;
|
|
253
|
+
if (exiting)
|
|
254
|
+
return killProcessGroup(proc.pid, "SIGKILL");
|
|
255
|
+
killProcessGroup(proc.pid);
|
|
256
|
+
setTimeout(() => proc.exitCode === null && killProcessGroup(proc.pid, "SIGKILL"), STOP_GRACE_MS).unref();
|
|
257
|
+
}
|
|
258
|
+
const STOP_GRACE_MS = 3_000;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
4
|
+
/** Commands no project script or config may allow: always the last rules, so they always win. */
|
|
5
|
+
export const HARD_DENY = [
|
|
6
|
+
...PACKAGE_MANAGERS.flatMap((pm) => [`${pm} install*`, `${pm} i`, `${pm} i *`, `${pm} add*`, `${pm} ci*`]),
|
|
7
|
+
"pip install*", "pip3 install*", "python -m pip install*", "python3 -m pip install*", "uv add*", "uv pip install*",
|
|
8
|
+
"go get*", "go install*", "cargo add*", "cargo install*", "brew *",
|
|
9
|
+
...["commit", "push", "reset", "checkout", "switch", "rebase", "merge", "stash", "clean"].map((verb) => `git ${verb}*`),
|
|
10
|
+
"rm -rf*", "rm -fr*", "sudo*", "curl*", "wget*", "ssh*", "scp*", "npm publish*", "chmod -R*",
|
|
11
|
+
// seen bypassing allowed prefixes against the real OpenCode: writes past the edit rules, or arbitrary programs
|
|
12
|
+
"*>*", "git *--output*", "git *--ext-diff*", "go test*-exec*", "go test*-toolexec*", "cargo test*--config*",
|
|
13
|
+
"npm exec*", "pnpm dlx*", "yarn dlx*", "bunx*",
|
|
14
|
+
];
|
|
15
|
+
const LOCKFILES = [
|
|
16
|
+
"package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb",
|
|
17
|
+
"Cargo.lock", "poetry.lock", "uv.lock", "Pipfile.lock", "Gemfile.lock", "composer.lock", "go.sum",
|
|
18
|
+
];
|
|
19
|
+
/** Paths the worker may never edit: git internals, Cheap Agent state, env files, lockfiles. */
|
|
20
|
+
export const PROTECTED = [
|
|
21
|
+
".git", ".git/**", "**/.git", "**/.git/**", ".cheapag/**", ".env*", "**/.env*",
|
|
22
|
+
// the worker must not loosen its own (or a later job's) rules
|
|
23
|
+
"cheapag.json",
|
|
24
|
+
...LOCKFILES.flatMap((f) => [f, `**/${f}`]),
|
|
25
|
+
];
|
|
26
|
+
/** Always-allowed commands, each as typed and with arguments: read-only git and the common test runners. */
|
|
27
|
+
const ALWAYS = ["git status", "git diff", "git log", "git show", "pytest", "python -m pytest", "python3 -m pytest", "go test", "cargo test", "bun test"];
|
|
28
|
+
const allow = (permission, pattern = "*") => ({ permission, pattern, action: "allow" });
|
|
29
|
+
const deny = (permission, pattern = "*") => ({ permission, pattern, action: "deny" });
|
|
30
|
+
/** A command allowed as typed, and with any arguments after it. */
|
|
31
|
+
const command = (cmd) => [allow("bash", cmd), allow("bash", `${cmd} *`)];
|
|
32
|
+
/** Secrets: OpenCode's default does not cover .env reads under these rulesets; examples stay readable (last match wins). */
|
|
33
|
+
const ENV_READS = [
|
|
34
|
+
...[".env*", "**/.env*"].map((p) => deny("read", p)),
|
|
35
|
+
...[".env.example", "**/.env.example", ".env.sample", "**/.env.sample"].map((p) => allow("read", p)),
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* The full ruleset for one worker session: catch-all deny first, then explicit allows and denies for every key,
|
|
39
|
+
* then the hard denylist. `scripts` and `allowCommands` are allowed with any arguments; `exact` only as written
|
|
40
|
+
* (make targets, the step's done-when checks); `linked` are dependency dirs in the worktree (readable, never editable).
|
|
41
|
+
*/
|
|
42
|
+
export function workerRuleset(opts = {}) {
|
|
43
|
+
return [
|
|
44
|
+
deny("*"),
|
|
45
|
+
// `finish` is Cheap Agent's own tool (tools/finish.ts): custom tools are permission-checked by name, so the
|
|
46
|
+
// catch-all would otherwise hide it from the worker
|
|
47
|
+
...["read", "glob", "grep", "list", "lsp", "todowrite", "question", "finish"].map((p) => allow(p)),
|
|
48
|
+
...ENV_READS,
|
|
49
|
+
...["external_directory", "webfetch", "websearch", "task", "skill", "doom_loop"].map((p) => deny(p)),
|
|
50
|
+
allow("edit"),
|
|
51
|
+
...PROTECTED.map((p) => deny("edit", p)),
|
|
52
|
+
...(opts.linked ?? []).flatMap((d) => [deny("edit", d), deny("edit", `${d}/**`)]),
|
|
53
|
+
deny("bash"),
|
|
54
|
+
...[...ALWAYS, ...(opts.scripts ?? []), ...(opts.allowCommands ?? [])].flatMap(command),
|
|
55
|
+
...(opts.exact ?? []).map((c) => allow("bash", c)),
|
|
56
|
+
...HARD_DENY.map((p) => deny("bash", p)),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The research worker's ruleset (spec E1, research; ticket 23.4): read, search, and run the same test commands as the
|
|
61
|
+
* implementation worker (to reproduce a failure it is asked about), never edit. It runs in a throwaway worktree (src/research.ts),
|
|
62
|
+
* so a command that writes files cannot touch the developer's tree. Env files are unreadable; outside the repo is denied.
|
|
63
|
+
*/
|
|
64
|
+
export function researchRuleset(opts = {}) {
|
|
65
|
+
return [
|
|
66
|
+
deny("*"),
|
|
67
|
+
...["read", "glob", "grep", "list"].map((p) => allow(p)),
|
|
68
|
+
...ENV_READS,
|
|
69
|
+
...["edit", "question", "lsp", "todowrite", "finish", "external_directory", "webfetch", "websearch", "task", "skill", "doom_loop"].map((p) => deny(p)),
|
|
70
|
+
deny("bash"),
|
|
71
|
+
...[...ALWAYS, ...(opts.scripts ?? []), ...(opts.allowCommands ?? [])].flatMap(command),
|
|
72
|
+
...(opts.exact ?? []).map((c) => allow("bash", c)),
|
|
73
|
+
...HARD_DENY.map((p) => deny("bash", p)),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
/** The same static rules in OpenCode's config form, for the server-wide `OPENCODE_PERMISSION` baseline. */
|
|
77
|
+
export function baselinePermission() {
|
|
78
|
+
const config = {};
|
|
79
|
+
for (const rule of workerRuleset()) {
|
|
80
|
+
if (rule.pattern === "*" && !(rule.permission in config))
|
|
81
|
+
config[rule.permission] = rule.action;
|
|
82
|
+
else {
|
|
83
|
+
const current = config[rule.permission];
|
|
84
|
+
config[rule.permission] = { ...(typeof current === "string" ? { "*": current } : current), [rule.pattern]: rule.action };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The project's own runnable scripts: package.json scripts under each package manager (`scripts`, arguments allowed)
|
|
91
|
+
* and make targets (`exact`: `make -f`/`--eval` would run anything). Names with spaces or wildcards are skipped.
|
|
92
|
+
*/
|
|
93
|
+
export function projectScripts(dir) {
|
|
94
|
+
const commands = [];
|
|
95
|
+
const exact = [];
|
|
96
|
+
const plain = (name) => /^[\w:@./-]+$/.test(name);
|
|
97
|
+
const pkg = join(dir, "package.json");
|
|
98
|
+
if (existsSync(pkg)) {
|
|
99
|
+
try {
|
|
100
|
+
const scripts = Object.keys(JSON.parse(readFileSync(pkg, "utf8")).scripts ?? {}).filter(plain);
|
|
101
|
+
for (const s of scripts)
|
|
102
|
+
commands.push(...PACKAGE_MANAGERS.map((pm) => `${pm} run ${s}`), `yarn ${s}`);
|
|
103
|
+
if (scripts.includes("test"))
|
|
104
|
+
commands.push("npm test", "npm t", "pnpm test", "yarn test", "bun run test");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// an unreadable package.json contributes no scripts
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const name of ["Makefile", "makefile", "GNUmakefile"]) {
|
|
111
|
+
if (!existsSync(join(dir, name)))
|
|
112
|
+
continue;
|
|
113
|
+
const targets = readFileSync(join(dir, name), "utf8").matchAll(/^([A-Za-z0-9][\w.-]*)\s*:(?!=)/gm);
|
|
114
|
+
for (const [, target] of targets)
|
|
115
|
+
if (plain(target))
|
|
116
|
+
exact.push(`make ${target}`);
|
|
117
|
+
exact.push("make");
|
|
118
|
+
}
|
|
119
|
+
return { scripts: [...new Set(commands)], exact: [...new Set(exact)] };
|
|
120
|
+
}
|
|
121
|
+
/** What a worker sees when a permission request is auto-rejected (spec E1). */
|
|
122
|
+
export const rejectionMessage = (what) => `Not allowed in cheapag: \`${what}\`. If you need a dependency, use the question tool. Otherwise finish and list it in risks.`;
|