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 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 read the request, wrote one task and read a ten-line result. That's all it did. The cheap model explored the code, wrote a test that failed, made it pass, and handed back a change that works. Same feature, a third of the Opus bill.
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, isSupported, removeLink, UNSUPPORTED_REASON } from "./platform.js";
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 + "/") && !isPlan(root, abs); };
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, node) {
52
+ export function writePlugin(entry, _node, platform = process.platform) {
24
53
  const { launcher, marketplace } = installPaths();
25
54
  mkdirSync(dirname(launcher), { recursive: true });
26
- const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
27
- writeFileSync(launcher, `#!/bin/sh\n# Cheap Agent's hooks and commands run through here: this install, or npx once it is gone\n[ -f ${q(entry)} ] && exec ${q(node)} ${q(entry)} "$@"\nexec ${NPX} "$@"\n`, { mode: 0o755 });
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
- // shortcut: a home directory with a space breaks the unquoted launcher path in hooks and allowed-tools; quote it per context if anyone hits that
33
- const files = [join(plugin, "hooks", "hooks.json"), ...readdirSync(join(plugin, "skills")).map((s) => join(plugin, "skills", s, "SKILL.md"))];
34
- for (const f of files)
35
- writeFileSync(f, readFileSync(f, "utf8").replaceAll(NPX, launcher));
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) => spawnSync("claude", ["plugin", ...args], { encoding: "utf8" });
47
- const listed = (args, name) => (claude(...args).stdout ?? "").includes(name);
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.stderr || r.stdout || "").trim().split("\n")[0]}). Run these yourself:`);
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 (dir && realpathSync(join(dir, "node")) === self)
69
- return join(dir, "node");
113
+ if (candidate && realpathSync(candidate) === self)
114
+ return candidate;
70
115
  }
71
- catch { } // no node in this PATH entry
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
- const rel = relative(root, path);
1160
- return rel !== "" && !rel.startsWith(`..${"/"}`) && rel !== ".." && !isAbsolute(rel);
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 { isSupported, UNSUPPORTED_REASON } from "./platform.js";
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 = spawnSync("claude", ["mcp", "get", "cheapag"], { encoding: "utf8" });
179
- if (existing.error) {
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 = 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}`);
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("/_npx/") ? ["npx", "-y", `cheap-agent@${version}`] : [stableNode(), entry];
207
+ return entry.split(sep).includes("_npx") ? ["npx", "-y", `cheap-agent@${version}`] : [stableNode(), entry];
212
208
  }
213
- const quote = (arg) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`);
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
- export const FINISH_TOOL_SOURCE = `import { tool } from "@opencode-ai/plugin";
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: 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'"),
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 { lstatSync, symlinkSync, unlinkSync } from "node:fs";
2
- import { sep } from "node:path";
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). POSIX only in v1; a Windows port swaps in directory
5
- * junctions, long-path handling and `taskkill /T` behind these same functions.
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
- export const UNSUPPORTED_REASON = "Windows not supported yet; use WSL";
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(normalisePath(rel)); // "." is the whole repo: no scope
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 [, path, from, to] of text.matchAll(/([\w./@-]+):(\d+)(?:-(\d+))?/g)) {
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." }, supported(async () => {
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
- }, supported(async (input, extra) => {
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
- }, supported(async (input, extra) => {
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
- }, supported(async (input, extra) => {
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
- }, supported(async (input, extra) => {
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
- }, supported(async (input, extra) => {
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
- process.on("exit", () => stopOpencode(true));
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, () => process.exit());
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", () => process.exit());
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, isSupported, UNSUPPORTED_REASON } from "./platform.js";
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 = spawn("sh", ["-c", cmd], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, CI: "1", FORCE_COLOR: "0" } });
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
- .split(`${root}/`).join("")
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
- return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
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
- rmSync(path, { recursive: true, force: true });
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,6 +1,6 @@
1
1
  {
2
2
  "name": "cheap-agent",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Cut your Claude Code usage: Claude plans, a cheap model writes the code. One command to install.",
5
5
  "license": "Elastic-2.0",
6
6
  "author": "muaaz9329",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cheapag",
3
3
  "displayName": "Cheap Agent",
4
- "version": "1.0.0",
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
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "cheapag": {
4
4
  "command": "npx",
5
- "args": ["-y", "cheap-agent@1.0.0"]
5
+ "args": ["-y", "cheap-agent@1.1.1"]
6
6
  }
7
7
  }
8
8
  }
@@ -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.0.0 hook guard; exit 0",
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.0.0 hook result; exit 0",
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.0.0 hook context",
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.0.0 allow) Bash(npx -y cheap-agent@1.0.0 allow *)
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.0.0 allow $ARGUMENTS`
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.0.0 clean) Bash(npx -y cheap-agent@1.0.0 clean *)
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.0.0 clean $ARGUMENTS`
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.0.0 hook) Bash(npx -y cheap-agent@1.0.0 hook *)
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.0.0 hook $ARGUMENTS`
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.0.0 status)
4
+ allowed-tools: Bash(npx -y cheap-agent@1.1.1 status)
5
5
  ---
6
6
 
7
- !`npx -y cheap-agent@1.0.0 status`
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).