cheap-agent 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/worker.js ADDED
@@ -0,0 +1,259 @@
1
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2";
2
+ import { runningOpencode } from "./opencode.js";
3
+ import { rejectionMessage } from "./permissions.js";
4
+ import { modelName } from "./routing.js";
5
+ /** Worker: one OpenCode session for one job, reduced to what the job layer needs. */
6
+ export const WORKER_AGENT = "cheapag-worker";
7
+ /** How long an aborted prompt may take to go idle before its run is ended anyway. */
8
+ const ABORT_GRACE_MS = 10_000;
9
+ /**
10
+ * Opens one worker session. `run` prompts it (optionally on another model, e.g. escalation) and resolves when the
11
+ * session goes idle; self-fixes and escalation are further `run`s of the same session.
12
+ */
13
+ export async function openSession(opts) {
14
+ const { opencode, directory } = opts;
15
+ const client = createOpencodeClient({
16
+ baseUrl: opencode.url,
17
+ directory,
18
+ headers: { authorization: `Basic ${Buffer.from(`opencode:${opencode.password}`).toString("base64")}` },
19
+ });
20
+ const created = await client.session.create({
21
+ directory, agent: opts.agent ?? WORKER_AGENT, model: { providerID: opts.model.providerID, id: opts.model.id }, ...(opts.permission && { permission: opts.permission }),
22
+ });
23
+ if (!created.data)
24
+ throw new Error(`could not create a worker session: ${JSON.stringify(created.error)}`);
25
+ const sessionID = created.data.id;
26
+ const calls = new Set();
27
+ const messages = new Map(); // assistant message id → its model and latest running totals
28
+ const rejections = [];
29
+ /**
30
+ * `interrupt` ends this prompt only (the session stays usable, e.g. to hand it to another model); `opts.signal` ends the
31
+ * session. `afterStop` runs one more prompt on a stopped session (the findings note), bounded by `interrupt` alone.
32
+ */
33
+ async function run(prompt, model, interrupt, afterStop = false) {
34
+ const signal = afterStop ? undefined : opts.signal;
35
+ if (signal?.aborted)
36
+ return { text: "", error: "aborted" };
37
+ const abort = new AbortController();
38
+ let backstop;
39
+ const stop = () => {
40
+ void client.session.abort({ sessionID, directory }).catch(() => { });
41
+ // live, an abort ends in session.idle; if it never comes, drop the stream so the run cannot hang
42
+ backstop = setTimeout(() => abort.abort(), ABORT_GRACE_MS);
43
+ };
44
+ // the SDK's stream only connects when first read: wait for server.connected before prompting, or early events are lost
45
+ const { stream } = await client.event.subscribe({ directory }, { signal: abort.signal });
46
+ await stream.next();
47
+ try {
48
+ const texts = new Map(); // text part id → latest text
49
+ let idle = false;
50
+ let finish;
51
+ let error;
52
+ let httpStatus;
53
+ let limit;
54
+ const prompted = await client.session.promptAsync({
55
+ sessionID, directory, parts: [{ type: "text", text: prompt }],
56
+ ...(model && { model: { providerID: model.providerID, modelID: model.id } }),
57
+ });
58
+ if (prompted.error)
59
+ throw new Error(`could not prompt the worker: ${JSON.stringify(prompted.error)}`);
60
+ // after the prompt, so an abort can never race ahead of it
61
+ signal?.addEventListener("abort", stop, { once: true });
62
+ if (signal?.aborted)
63
+ stop();
64
+ interrupt?.addEventListener("abort", stop, { once: true });
65
+ if (interrupt?.aborted)
66
+ stop();
67
+ for await (const raw of stream) {
68
+ const event = raw;
69
+ const props = event.properties;
70
+ if (props.sessionID !== sessionID)
71
+ continue;
72
+ if (event.type === "message.part.updated") {
73
+ const part = event.properties.part;
74
+ if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
75
+ // shortcut: OpenCode 1.19's wording for a call its ruleset denied; update if a new pin rewords it
76
+ if (!calls.has(part.callID) && part.state.status === "error" && part.state.error.startsWith("The user has specified a rule which prevents")) {
77
+ rejections.push({ kind: "denied", permission: part.tool, what: describeCall(part.state.input) });
78
+ }
79
+ calls.add(part.callID);
80
+ if (part.tool === "finish" && part.state.status === "completed")
81
+ finish = asReport(part.state.input);
82
+ }
83
+ if (part.type === "text")
84
+ texts.set(part.id, { messageID: part.messageID, text: part.text });
85
+ }
86
+ else if (event.type === "message.updated" && event.properties.info.role === "assistant") {
87
+ const { id, cost, tokens, error: failed, providerID, modelID } = event.properties.info;
88
+ // seen live on 1.19.0: some failures (e.g. abort) arrive only on the assistant message, with no session.error
89
+ if (failed) {
90
+ error ??= describe(failed);
91
+ httpStatus ??= statusOf(failed);
92
+ }
93
+ messages.set(id, {
94
+ model: `${providerID}/${modelID}`,
95
+ inputTokens: tokens.input, outputTokens: tokens.output, reasoningTokens: tokens.reasoning,
96
+ cacheRead: tokens.cache.read, cacheWrite: tokens.cache.write, costUsd: cost,
97
+ });
98
+ }
99
+ else if (event.type === "permission.asked") {
100
+ // an unanswered ask blocks forever in server mode: reject every one, with a reason the worker sees
101
+ const what = event.properties.patterns.join(" ") || event.properties.permission;
102
+ rejections.push({ kind: "asked", permission: event.properties.permission, what });
103
+ const replied = await client.permission
104
+ .reply({ requestID: event.properties.id, directory, reply: "reject", message: rejectionMessage(what) })
105
+ .catch((err) => ({ error: err.message }));
106
+ // a reject that did not go through would leave the session blocked: end this run as a worker error instead
107
+ if (replied.error) {
108
+ error ??= `could not reject a ${event.properties.permission} permission request: ${JSON.stringify(replied.error)}`;
109
+ break;
110
+ }
111
+ }
112
+ else if (event.type === "question.asked") {
113
+ opts.onEvent(event, calls.size); // logged now: the answer may be a long way off
114
+ const questions = event.properties.questions.map((q) => ({ question: q.question, options: q.options.map((o) => o.label) }));
115
+ const requestID = event.properties.id;
116
+ // a stop (cap or cancel) while waiting ends the wait: the question is rejected and the session aborted
117
+ const stopped = new Promise((resolve) => (signal?.aborted ? resolve(undefined) : signal?.addEventListener("abort", () => resolve(undefined), { once: true })));
118
+ // a question asked after a stop is never put to Claude: the job is ending, not blocked
119
+ const answers = opts.onQuestion && questions.length && !signal?.aborted ? await Promise.race([opts.onQuestion(questions), stopped]) : undefined;
120
+ // nowhere to put the question (a parallel group until early return exists, or the login check): it is rejected, and said so
121
+ if (!opts.onQuestion && questions.length)
122
+ rejections.push({ kind: "asked", permission: "question", what: questions.map((q) => q.question).join(" / ") });
123
+ const replied = await (answers === undefined
124
+ ? client.question.reject({ requestID, directory })
125
+ : client.question.reply({ requestID, directory, answers: answers.map((a) => [a]) })).catch((err) => ({ error: err.message }));
126
+ // an answer that did not go through would leave the session paused forever: end this run as a worker error instead
127
+ if (replied.error) {
128
+ error ??= `could not answer the worker's question: ${JSON.stringify(replied.error)}`;
129
+ break;
130
+ }
131
+ continue;
132
+ }
133
+ else if (event.type === "session.error") {
134
+ error = describe(event.properties.error);
135
+ httpStatus = statusOf(event.properties.error);
136
+ }
137
+ else if (event.type === "session.status" && event.properties.status.type === "retry" && !limit && isLimit(event.properties.status, (model ?? opts.model).providerID)) {
138
+ // a provider limit or rate limit: OpenCode would retry, honouring retry-after, possibly for hours. Stop now, so the
139
+ // job can fall back; this aborts the session directly, not through the job's stop signal (which means a cap)
140
+ const { action, message, next } = event.properties.status;
141
+ limit = { model: modelName(model ?? opts.model), reason: action?.reason, message: action?.message ?? message, next };
142
+ stop();
143
+ }
144
+ opts.onEvent(event, calls.size);
145
+ if (event.type === "session.idle") {
146
+ idle = true;
147
+ break;
148
+ }
149
+ }
150
+ if (!idle)
151
+ error ??= "worker event stream ended before the session went idle";
152
+ // the fallback block is read from this prompt's last message only, so a stale one from earlier cannot win
153
+ const last = [...texts.values()].at(-1)?.messageID;
154
+ const text = [...texts.values()].filter((t) => t.messageID === last).map((t) => t.text).join("\n");
155
+ // an interrupted prompt ends on OpenCode's abort error; that is the hand-over, not a worker failure
156
+ if (interrupt?.aborted && !signal?.aborted)
157
+ return { finish, text, interrupted: true };
158
+ return { finish, text, error, ...(httpStatus && { httpStatus }), ...(limit && { limit }) };
159
+ }
160
+ finally {
161
+ signal?.removeEventListener("abort", stop);
162
+ interrupt?.removeEventListener("abort", stop);
163
+ clearTimeout(backstop);
164
+ abort.abort();
165
+ }
166
+ }
167
+ function totals() {
168
+ const usage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
169
+ const spend = {};
170
+ for (const m of messages.values()) {
171
+ for (const key of Object.keys(usage))
172
+ usage[key] += m[key];
173
+ spend[m.model] = (spend[m.model] ?? 0) + m.costUsd;
174
+ }
175
+ return { sessionID, turns: calls.size, usage, rejections: [...rejections], spend };
176
+ }
177
+ return { sessionID, run, totals };
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
+ }
200
+ /** One prompt in a fresh session (the login live check). */
201
+ export async function runSession(opts) {
202
+ const session = await openSession(opts);
203
+ const result = await session.run(opts.prompt);
204
+ return { ...session.totals(), ...result };
205
+ }
206
+ /** OpenCode validates the tool's args, but the record must not trust a malformed one either. */
207
+ function asReport(input) {
208
+ const list = (v) => (Array.isArray(v) ? v.map(String) : typeof v === "string" && v ? [v] : []);
209
+ return { summary: String(input.summary ?? ""), risks: list(input.risks), review: list(input.review), missing: list(input.missing), ...(typeof input.repro === "string" && input.repro.trim() && { repro: input.repro.trim() }), ...(typeof input.disagree === "string" && input.disagree.trim() && { disagree: input.disagree.trim() }) };
210
+ }
211
+ /**
212
+ * A retry that means a limit, not a blip: a Go account or free-tier limit, any OpenRouter retry (its 429s), or a retry
213
+ * scheduled more than a minute out. A transient 5xx retry on Go or DeepSeek is left to OpenCode's own quick retries.
214
+ */
215
+ function isLimit(status, providerID) {
216
+ return ["account_rate_limit", "free_tier_limit"].includes(status.action?.reason ?? "") || providerID === "openrouter" || status.next - Date.now() > 60_000;
217
+ }
218
+ /** The command or path a tool call targeted, for rejection records. */
219
+ function describeCall(input) {
220
+ return String(input.command ?? input.filePath ?? input.url ?? input.pattern ?? JSON.stringify(input)).slice(0, 200);
221
+ }
222
+ function describe(err) {
223
+ const e = err;
224
+ return e?.data?.message ?? e?.name ?? "unknown worker error";
225
+ }
226
+ /** The HTTP status of a provider error (APIError), e.g. OpenRouter's 402 for insufficient credits. */
227
+ const statusOf = (err) => err?.data?.statusCode;
228
+ const NO_REPORT = { summary: "(worker gave no summary)", risks: ["worker did not report; review diff"], review: [], missing: [] };
229
+ /** The finish tool's report; else the last fenced `cheapag-result` block, parsed leniently; else a placeholder. */
230
+ export function finishReport(result) {
231
+ if (result.finish)
232
+ return result.finish;
233
+ const block = [...result.text.matchAll(/```cheapag-result\s*\n([\s\S]*?)```/g)].at(-1)?.[1];
234
+ if (!block)
235
+ return NO_REPORT;
236
+ const report = { summary: "", risks: [], review: [], missing: [] };
237
+ let list;
238
+ for (const line of block.split("\n")) {
239
+ const kv = line.match(/^(summary|risks|review|missing|repro|disagree):\s*(.*)$/);
240
+ if (kv) {
241
+ const [, key, value] = kv;
242
+ if (key === "summary" || key === "repro" || key === "disagree") {
243
+ if (value.trim())
244
+ report[key] = value.trim();
245
+ list = undefined;
246
+ }
247
+ else {
248
+ list = key;
249
+ // inline values: "review: a.ts:1-2, b.ts:3-4"
250
+ if (value.trim())
251
+ report[list].push(...value.split(",").map((v) => v.trim()).filter(Boolean));
252
+ }
253
+ }
254
+ else if (list && /^\s*-\s+/.test(line)) {
255
+ report[list].push(line.replace(/^\s*-\s+/, "").trim());
256
+ }
257
+ }
258
+ return { ...report, summary: report.summary || NO_REPORT.summary };
259
+ }
@@ -0,0 +1,283 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { copyFileSync, cpSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
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"] : [];
8
+ export function git(cwd, args, env) {
9
+ return execFileSync("git", [...GIT_ARGS, ...args], { cwd, stdio: "pipe", env: env && { ...process.env, ...env }, maxBuffer: 256 * 1024 * 1024 }).toString();
10
+ }
11
+ /** The repository root containing `cwd`; throws when it is not a git repository. */
12
+ export function projectRoot(cwd) {
13
+ try {
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());
16
+ }
17
+ catch {
18
+ throw new Error(`${cwd} is not inside a git repository`);
19
+ }
20
+ }
21
+ /**
22
+ * A detached worktree under `.cheapag/worktrees/<job>` holding the developer's current state (ADR-0003):
23
+ * HEAD, plus uncommitted tracked changes (a `git stash create` snapshot, which leaves the checkout and the stash list
24
+ * alone), plus copies of untracked non-ignored files such as a freshly written plan. Dependency directories from
25
+ * `linkDirs` are linked in from the main checkout. Nothing in the main checkout is modified.
26
+ */
27
+ export function createWorktree(root, job, linkDirs) {
28
+ const head = git(root, ["rev-parse", "HEAD"]).trim();
29
+ const stash = git(root, ["stash", "create"]).trim() || null;
30
+ const path = join(root, ".cheapag", "worktrees", job);
31
+ git(root, ["worktree", "add", "--detach", path, head]);
32
+ const links = [];
33
+ try {
34
+ if (stash)
35
+ git(path, ["stash", "apply", "--quiet", stash]);
36
+ // untracked dependency dirs are linked below, never copied
37
+ const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ".", ...outside(linkDirs)]).split("\0").filter(Boolean);
38
+ // an entry ending in "/" is an untracked nested repository: not part of this repo, so not carried over
39
+ // (git would refuse to index it, or record it as a gitlink that would then land)
40
+ for (const file of untracked.filter((f) => !f.endsWith("/"))) {
41
+ mkdirSync(dirname(join(path, file)), { recursive: true });
42
+ cpSync(join(root, file), join(path, file), { verbatimSymlinks: true });
43
+ }
44
+ for (const dir of linkDirs) {
45
+ const source = join(root, dir);
46
+ const target = join(path, dir);
47
+ // tracked dependency dirs (e.g. a committed vendor/) are already in the worktree, and stay part of the diff
48
+ if (!isDirectory(source) || existsSync(target))
49
+ continue;
50
+ mkdirSync(dirname(target), { recursive: true });
51
+ link(source, target);
52
+ links.push(target);
53
+ }
54
+ }
55
+ catch (err) {
56
+ discard(root, path, links);
57
+ throw err;
58
+ }
59
+ const linked = links.map((l) => normalisePath(relative(path, l)));
60
+ // the carried-over state as a tree object: the job's baseline, without committing anything
61
+ git(path, ["add", "-A", "--", ".", ...outside(linked)]);
62
+ const base = git(path, ["write-tree"]).trim();
63
+ return { path, head, stash, base, links, linked };
64
+ }
65
+ function discard(root, path, links) {
66
+ for (const l of links)
67
+ removeLink(l); // unlink first, so nothing can reach through into the real dirs
68
+ try {
69
+ git(root, ["worktree", "remove", "--force", path]);
70
+ }
71
+ catch {
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 });
76
+ git(root, ["worktree", "prune"]);
77
+ }
78
+ }
79
+ /** Pathspecs that keep Cheap Agent's state and the given dependency dirs out of a git operation. */
80
+ const outside = (dirs) => [".cheapag", ...dirs].map((d) => `:(exclude,top,literal)${d}`);
81
+ const isDirectory = (path) => {
82
+ try {
83
+ return statSync(path).isDirectory();
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ };
89
+ /** Everything the job changed relative to its baseline tree, as a binary patch plus per-file stats. */
90
+ export function worktreeDiff(worktree) {
91
+ const { path, base } = worktree;
92
+ // the worktree's own index: throwaway, never committed. A linked dir is a symlink, which a `node_modules/`
93
+ // ignore rule does not match, so the linked ones (only those: a tracked vendor/ stays in) are excluded.
94
+ git(path, ["add", "-A", "--", ".", ...outside(worktree.linked)]);
95
+ const patch = git(path, ["diff", "--cached", "--binary", base]);
96
+ const stats = git(path, ["diff", "--cached", "--numstat", "-z", "--no-renames", base]).split("\0").filter(Boolean);
97
+ const statuses = new Map();
98
+ const names = git(path, ["diff", "--cached", "--name-status", "-z", "--no-renames", base]).split("\0").filter(Boolean);
99
+ for (let i = 0; i < names.length; i += 2)
100
+ statuses.set(names[i + 1], names[i]);
101
+ const changed = stats.map((line) => {
102
+ const [add, del, file] = line.split("\t");
103
+ // binary files report "-" for both counts
104
+ return { status: statuses.get(file) ?? "M", path: file, add: Number(add) || 0, del: Number(del) || 0 };
105
+ });
106
+ return { patch, changed };
107
+ }
108
+ /** Why the checkout cannot be a job's starting point right now (a merge, rebase… or unresolved conflicts), if it cannot. */
109
+ export function operationInProgress(root) {
110
+ const markers = { MERGE_HEAD: "a merge", "rebase-merge": "a rebase", "rebase-apply": "a rebase", CHERRY_PICK_HEAD: "a cherry-pick", REVERT_HEAD: "a revert" };
111
+ for (const [marker, name] of Object.entries(markers)) {
112
+ if (existsSync(git(root, ["rev-parse", "--path-format=absolute", "--git-path", marker]).trim()))
113
+ return `${name} is in progress`;
114
+ }
115
+ // e.g. after a conflicted `git stash pop`: no marker file, but `git stash create` would fail
116
+ if (git(root, ["ls-files", "--unmerged"]).trim())
117
+ return "there are unresolved conflicts";
118
+ }
119
+ export function removeWorktree(root, worktree) {
120
+ discard(root, worktree.path, worktree.links);
121
+ }
122
+ /** How long landing waits for another holder. `CHEAPAG_LOCK_TIMEOUT_MS` is a test hook (like CHEAPAG_OPENCODE_URL), not a setting. */
123
+ const lockTimeoutMs = () => Number(process.env.CHEAPAG_LOCK_TIMEOUT_MS) || 10 * 60_000;
124
+ /** Serialises landing and integration checks across Cheap Agent processes on one project: `.cheapag/land.lock` holds pid + timestamp + token. */
125
+ export async function withLock(root, fn) {
126
+ const lock = join(root, ".cheapag", "land.lock");
127
+ const token = randomBytes(8).toString("hex");
128
+ // unique per call: parallel groups in one process wait on the lock at once, and must not delete each other's file
129
+ const mine = `${lock}.${process.pid}.${token}`;
130
+ mkdirSync(join(root, ".cheapag"), { recursive: true });
131
+ // written whole, then hard-linked into place: the lock never exists without its contents
132
+ writeFileSync(mine, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }));
133
+ const timeout = lockTimeoutMs();
134
+ const deadline = Date.now() + timeout;
135
+ try {
136
+ for (;;) {
137
+ try {
138
+ linkSync(mine, lock);
139
+ break;
140
+ }
141
+ catch (err) {
142
+ if (err.code !== "EEXIST")
143
+ throw err;
144
+ }
145
+ const holder = readHolder(lock); // gone: released meanwhile, so try again at once
146
+ if (!holder)
147
+ continue;
148
+ if (!(holder.pid > 0 && isAlive(holder.pid)))
149
+ breakStale(lock, holder);
150
+ else if (Date.now() > deadline) {
151
+ const waited = timeout >= 60_000 ? `${Math.round(timeout / 60_000)} min` : `${timeout / 1000} s`;
152
+ throw new Error(`timed out after ${waited} waiting for .cheapag/land.lock (held by pid ${holder.pid} since ${holder.at}); try again once it is released`);
153
+ }
154
+ else
155
+ await new Promise((r) => setTimeout(r, 200));
156
+ }
157
+ }
158
+ finally {
159
+ rmSync(mine, { force: true });
160
+ }
161
+ try {
162
+ return await fn();
163
+ }
164
+ finally {
165
+ // only our own: never a lock someone else holds now
166
+ if (readHolder(lock)?.token === token)
167
+ rmSync(lock, { force: true });
168
+ }
169
+ }
170
+ /**
171
+ * Removes a lock whose holder is dead. The rename (to a name of our own) lets only one waiter break it. Between reading
172
+ * the dead holder and renaming, another waiter may have broken it and a live process taken it: then that lock goes back.
173
+ */
174
+ function breakStale(lock, dead) {
175
+ const stale = `${lock}.stale.${randomBytes(4).toString("hex")}`;
176
+ try {
177
+ renameSync(lock, stale);
178
+ }
179
+ catch {
180
+ return; // another waiter already broke it
181
+ }
182
+ if (readHolder(stale)?.token !== dead.token) {
183
+ try {
184
+ linkSync(stale, lock);
185
+ }
186
+ catch {
187
+ // shortcut: a third process took the free name in the instant before the put-back, so two hold it; needs OS file locks (flock) to close
188
+ }
189
+ }
190
+ rmSync(stale, { force: true });
191
+ }
192
+ /** The lock's holder, or undefined when the file is gone. */
193
+ function readHolder(file) {
194
+ let text;
195
+ try {
196
+ text = readFileSync(file, "utf8");
197
+ }
198
+ catch {
199
+ return undefined;
200
+ }
201
+ try {
202
+ return JSON.parse(text);
203
+ }
204
+ catch {
205
+ return { pid: 0, at: "unknown", token: text }; // never written partly, so not a Cheap Agent lock: no live holder
206
+ }
207
+ }
208
+ const literal = (path) => `:(literal)${path}`;
209
+ /**
210
+ * 3-way applies `patch` to the user's working tree, uncommitted and leaving their index alone.
211
+ * Runs against a throwaway index synced to the working tree, so their unstaged edits are the merge base side.
212
+ * Returns undefined on success, or the conflict text with the tree untouched.
213
+ */
214
+ export function land(root, job, patch, paths) {
215
+ return withLock(root, () => {
216
+ if (!patch.trim())
217
+ return undefined;
218
+ const index = join(root, ".cheapag", "jobs", job, "land.index");
219
+ copyFileSync(git(root, ["rev-parse", "--path-format=absolute", "--git-path", "index"]).trim(), index);
220
+ const env = { GIT_INDEX_FILE: index };
221
+ try {
222
+ // bring exactly the patch's paths into the throwaway index as they are on disk now, untracked files included:
223
+ // the job's baseline had them (carry-over), so the patch expects them
224
+ const tracked = new Set(git(root, ["ls-files", "-z", "--", ...paths.map(literal)], env).split("\0"));
225
+ const present = paths.filter((p) => tracked.has(p) || existsSync(join(root, p)));
226
+ if (present.length)
227
+ git(root, ["add", "-A", "--", ...present.map(literal)], env);
228
+ const check = applyOutput(root, ["apply", "--3way", "--check", "-"], env, patch);
229
+ // shortcut: `apply --3way --check` exits 0 even when it would leave conflict markers, so read its message; upgrade if git changes the wording
230
+ if (check.failed || /with conflicts/.test(check.output))
231
+ return check.output.trim() || "patch does not apply";
232
+ // shortcut: the lock does not stop the developer's editor; an edit to these files in the milliseconds since the check
233
+ // could still leave markers here. Upgrade by applying with --cached to the throwaway index and checking out its paths.
234
+ const applied = applyOutput(root, ["apply", "--3way", "-"], env, patch);
235
+ return applied.failed ? applied.output.trim() : undefined;
236
+ }
237
+ finally {
238
+ rmSync(index, { force: true });
239
+ }
240
+ });
241
+ }
242
+ /** git apply's verdict lives in stderr even when it exits 0. */
243
+ function applyOutput(root, args, env, patch) {
244
+ const r = spawnSync("git", [...GIT_ARGS, ...args], { cwd: root, env: { ...process.env, ...env }, input: patch, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
245
+ return { failed: r.status !== 0, output: `${r.stdout}${r.stderr}` };
246
+ }
247
+ /** The paths a failed landing names as conflicting, from git apply's messages. */
248
+ export function conflictPaths(text) {
249
+ const named = [...text.matchAll(/^Applied patch to '(.+)' with conflicts\.$|^error: (.+?): (?:does not exist in index|does not match index|already exists in (?:index|working directory)|patch does not apply)$/gm)];
250
+ return [...new Set(named.map((m) => m[1] ?? m[2]))];
251
+ }
252
+ /**
253
+ * For a redo: 3-way applies a job's earlier patch onto its fresh worktree, so clashing hunks are left there as conflict
254
+ * markers for the worker to reconcile. Returns the paths left conflicted. Only the worktree (and its own index) changes.
255
+ */
256
+ export function reapply(worktree, patch) {
257
+ applyOutput(worktree.path, ["apply", "--3way", "-"], {}, patch); // a patch that cannot apply at all leaves the worktree as it was
258
+ return git(worktree.path, ["diff", "--name-only", "-z", "--diff-filter=U"]).split("\0").filter(Boolean);
259
+ }
260
+ /** Paths whose changes still add conflict marker lines; run after `worktreeDiff`, which stages the worktree. */
261
+ export function leftoverMarkers(worktree) {
262
+ const r = spawnSync("git", [...GIT_ARGS, "diff", "--cached", "--check", worktree.base], { cwd: worktree.path, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
263
+ return [...new Set([...r.stdout.matchAll(/^(.+?):\d+: leftover conflict marker$/gm)].map((m) => m[1]))];
264
+ }
265
+ /**
266
+ * The worktree a finished job left behind, for `land`: always `.cheapag/worktrees/<job>` (never a path read from a
267
+ * record), and only a real directory. The `linkDirs` entries that are links in it are its dependency links.
268
+ */
269
+ export function jobWorktree(root, job, base, linkDirs) {
270
+ const path = join(root, ".cheapag", "worktrees", job);
271
+ if (!lstatOf(path)?.isDirectory())
272
+ return undefined;
273
+ const links = linkDirs.map((d) => join(path, d)).filter((l) => lstatOf(l)?.isSymbolicLink());
274
+ return { path, head: "", stash: null, base, links, linked: links.map((l) => normalisePath(relative(path, l))) };
275
+ }
276
+ const lstatOf = (path) => {
277
+ try {
278
+ return lstatSync(path);
279
+ }
280
+ catch {
281
+ return undefined;
282
+ }
283
+ };
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "cheap-agent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Cut your Claude Code usage: Claude plans, a cheap model writes the code. One command to install.",
5
+ "license": "Elastic-2.0",
6
+ "author": "muaaz9329",
7
+ "homepage": "https://github.com/muaaz9329/cheap-ag#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/muaaz9329/cheap-ag.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/muaaz9329/cheap-ag/issues"
14
+ },
15
+ "keywords": [
16
+ "claude-code",
17
+ "claude",
18
+ "mcp",
19
+ "ai-agents",
20
+ "deepseek",
21
+ "openrouter",
22
+ "cost",
23
+ "delegation"
24
+ ],
25
+ "type": "module",
26
+ "bin": {
27
+ "cheapag": "dist/index.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "plugin",
32
+ "LICENSE",
33
+ "README.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20.11"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "typecheck": "tsc --noEmit",
41
+ "pretest": "npm run build",
42
+ "test": "vitest run"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.32.1",
46
+ "@opencode-ai/sdk": "1.19.0",
47
+ "opencode-ai": "1.19.0",
48
+ "zod": "^4.6.7"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^26.6.5",
52
+ "typescript": "^7.0.2",
53
+ "vitest": "^5.0.4"
54
+ }
55
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "cheapag",
3
+ "displayName": "Cheap Agent",
4
+ "version": "1.1.0",
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
+ "author": { "name": "muaaz9329" },
7
+ "keywords": ["delegation", "opencode", "mcp", "cost"]
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "cheapag": {
4
+ "command": "npx",
5
+ "args": ["-y", "cheap-agent@1.1.0"]
6
+ }
7
+ }
8
+ }