forge-delegate 1.0.10 → 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/README.md CHANGED
@@ -30,9 +30,9 @@ so you're picking from actual available models rather than guessing.
30
30
  npx -y forge-delegate setup --model "opencode/deepseek-v4.1-flash-free" --targets "claude,codex,opencode"
31
31
  ```
32
32
 
33
- That's it — npx pulls the latest package and `setup` registers the MCP server with the agents
34
- you chose and sets your default model. Works on Windows (cmd/PowerShell), macOS, and Linux —
35
- no bash needed, and always runs the latest version.
33
+ That's it — npx bootstraps the package, and `setup` registers the MCP server with the agents
34
+ you chose, sets your default model, and installs a small launcher that keeps the server up to
35
+ date. Works on Windows (cmd/PowerShell), macOS, and Linux — no bash needed.
36
36
 
37
37
  Run `setup` **without flags** and it walks you through the choices interactively (defaults in
38
38
  `[brackets]`, Enter accepts); pass any of the flags to skip that prompt. In an automated/non-terminal
@@ -47,6 +47,7 @@ run it just uses sensible defaults.
47
47
  `setup` will:
48
48
  1. Register the MCP server with each agent you chose (`--targets claude,codex,opencode`).
49
49
  2. Set your default model (`--model`) so calls can omit `model`.
50
+ 3. Install the auto-update launcher (published installs; skipped for a git checkout).
50
51
 
51
52
  Routing and cost-policy guidance is injected by the MCP server itself into the agent's context
52
53
  via MCP `instructions` — no memory-file (`CLAUDE.md`/`AGENTS.md`) editing needed.
@@ -177,25 +178,35 @@ forge-delegate --version Print the installed version
177
178
  forge-delegate setup Register with Claude Code / Codex / opencode
178
179
  forge-delegate serve Start the MCP server (used internally)
179
180
  forge-delegate config get | set | path
180
- forge-delegate status Show version, update status, opencode logins, usage
181
+ forge-delegate status Show version, update mode, opencode logins, usage
181
182
  forge-delegate stats Show detailed usage statistics
182
183
  ```
183
184
 
184
- `status` also asks npm for the newest release (cached for 24 hours in
185
- `~/.forge-delegate/update-check.json`) and reports it when a newer version exists. Disable the
186
- check with `FORGE_NO_UPDATE_CHECK=1`. `get_delegate_config` returns the same version and
187
- `updateAvailable` fields, so an agent can answer "is this up to date?" without a CLI call.
185
+ `setup` on a published install writes `~/.forge-delegate/launcher.mjs` and registers that path
186
+ instead of `npx`. The launcher keeps a private install under `~/.forge-delegate/pkg`, asks npm
187
+ for the newest release at most every 6 hours, and installs newer **same-major** versions
188
+ automatically. Installs use `npm install --ignore-scripts`, go through a staging directory, and
189
+ fall back to the last good version if anything fails. A new version lands on the next agent
190
+ start; the download itself happens in the background so startup is never delayed.
188
191
 
189
- Updating an npx install is not automatic: npx keeps using its cached copy. Clear it with
190
- `rm -rf ~/.npm/_npx` and restart your agent, or install globally with `npm i -g forge-delegate`.
192
+ Disable automatic updates with `FORGE_AUTO_UPDATE=0`. A major release (`2.0.0`) is never
193
+ installed automatically — re-run `npx -y forge-delegate setup` to take it deliberately.
194
+
195
+ The launcher is also the CLI entry point: `node ~/.forge-delegate/launcher.mjs status` reports
196
+ the running version and update mode, and `--version` prints the version.
197
+
198
+ npx installs configured by hand are not covered by the launcher: npx keeps its cached copy
199
+ forever, so clear it with `rm -rf ~/.npm/_npx` and restart. Running
200
+ `npx -y forge-delegate@latest setup` migrates an npx install to the launcher; `@latest` gets its
201
+ own npx cache slot, and if it still starts an old version, clear the cache first.
191
202
 
192
203
  ## Publishing (npm)
193
204
 
194
205
  The primary install is `npx -y forge-delegate setup`, so the package must be on npm. Checklist:
195
206
 
196
207
  1. `npm run prepublishOnly` — syntax-checks all entry files (runs automatically on publish).
197
- 2. `npm pack --dry-run` — confirm the tarball contains only `cli.mjs`, `server.mjs`, `lib.mjs`,
198
- `install.sh`, `docs/`, `README.md` (via the `files` field), not `node_modules`.
208
+ 2. `npm pack --dry-run` — confirm the tarball contains only `launcher.mjs`, `cli.mjs`, `server.mjs`,
209
+ `lib.mjs`, `install.sh`, `docs/`, `skills/`, `README.md` (via the `files` field), not `node_modules`.
199
210
  3. Bump `version` in `package.json` (and the root `version` in `package-lock.json`).
200
211
  4. `npm login && npm publish` — the `bin` field makes `npx forge-delegate …` work.
201
212
 
package/cli.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from "node:readline/promises";
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
- import { execSync } from "node:child_process";
8
- import { loadConfig, saveConfig, configPath, detectOpencode, loadOpencodeAuth, checkForUpdate, VERSION } from "./lib.mjs";
7
+ import { execSync, execFileSync } from "node:child_process";
8
+ import { loadConfig, saveConfig, configPath, detectOpencode, loadOpencodeAuth, compareVersions, VERSION } from "./lib.mjs";
9
9
 
10
10
  const here = dirname(fileURLToPath(import.meta.url));
11
+ const herePosix = here.replace(/\\/g, "/");
12
+ const ROOT = join(homedir(), ".forge-delegate");
13
+ const LAUNCHER = join(ROOT, "launcher.mjs");
11
14
 
12
15
  function parseArgs(argv) {
13
16
  const args = {};
@@ -25,14 +28,60 @@ function parseArgs(argv) {
25
28
  return args;
26
29
  }
27
30
 
28
- function launchCommand() {
29
- const isPublished = here.includes(join("node_modules", "forge-delegate")) || here.includes("/.npm/_npx/");
30
- return isPublished ? "npx -y forge-delegate serve" : `node ${join(here, "cli.mjs")} serve`;
31
+ function isPublishedInstall() {
32
+ return herePosix.includes("node_modules/forge-delegate") || herePosix.includes("/.npm/_npx/");
31
33
  }
32
34
 
33
- function launchArray() {
34
- const isPublished = here.includes(join("node_modules", "forge-delegate")) || here.includes("/.npm/_npx/");
35
- return isPublished ? ["npx", "-y", "forge-delegate", "serve"] : [process.execPath, join(here, "cli.mjs"), "serve"];
35
+ // How this copy was installed matters to the user: only the launcher path can
36
+ // update itself.
37
+ function installKind() {
38
+ if (herePosix.includes("/.forge-delegate/pkg/")) return "launcher";
39
+ if (herePosix.includes("/.npm/_npx/") || herePosix.includes("node_modules/forge-delegate")) return "package";
40
+ return "checkout";
41
+ }
42
+
43
+ function readLauncherState() {
44
+ try {
45
+ return JSON.parse(readFileSync(join(ROOT, "launcher-state.json"), "utf8"));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // Copy the launcher out of the package to a path that never changes, then
52
+ // pre-install this version so the first client launch is fast. A failure here
53
+ // is not fatal: the launcher installs on demand.
54
+ function ensureLauncher() {
55
+ const src = join(here, "launcher.mjs");
56
+ if (!existsSync(src)) return null;
57
+ try {
58
+ mkdirSync(ROOT, { recursive: true });
59
+ const want = readFileSync(src, "utf8");
60
+ let have = null;
61
+ try {
62
+ have = readFileSync(LAUNCHER, "utf8");
63
+ } catch {}
64
+ if (have !== want) writeFileSync(LAUNCHER, want);
65
+ } catch {
66
+ return null;
67
+ }
68
+ try {
69
+ execFileSync(process.execPath, [LAUNCHER, "--install-only", VERSION], {
70
+ stdio: "ignore",
71
+ timeout: 180_000,
72
+ });
73
+ } catch {}
74
+ return LAUNCHER;
75
+ }
76
+
77
+ function launchForThisInstall() {
78
+ if (!isPublishedInstall()) {
79
+ const cli = join(here, "cli.mjs");
80
+ return { command: `node "${cli}"`, array: ["node", cli, "serve"] };
81
+ }
82
+ const launcher = ensureLauncher();
83
+ if (launcher) return { command: `node "${launcher}"`, array: ["node", launcher] };
84
+ return { command: "npx -y forge-delegate serve", array: ["npx", "-y", "forge-delegate", "serve"] };
36
85
  }
37
86
 
38
87
  // The MCP server's `instructions` are injected once at session start and decay as the
@@ -143,8 +192,12 @@ async function setup() {
143
192
  projectDir = projectDir ?? process.cwd();
144
193
  targets = targets.split(",").map((s) => s.trim().toLowerCase());
145
194
 
146
- const launch = launchCommand();
147
- const launchArr = launchArray();
195
+ const { command: launch, array: launchArr } = launchForThisInstall();
196
+ if (launch.includes("launcher.mjs")) console.log(` ✓ automatic updates (launcher: ${LAUNCHER})`);
197
+ // 1.0.x wrote this cache; the launcher keeps its own state elsewhere.
198
+ try {
199
+ rmSync(join(ROOT, "update-check.json"), { force: true });
200
+ } catch {}
148
201
 
149
202
  // 1. Set the default model first so it's saved even if registration hits an issue
150
203
  if (model && model !== current.defaultModel) {
@@ -210,14 +263,30 @@ function configCmd(argv) {
210
263
  }
211
264
 
212
265
  async function status() {
213
- const update = await checkForUpdate();
214
266
  const oc = await detectOpencode();
267
+ const kind = installKind();
215
268
  let versionNote;
216
- if (update.disabled) versionNote = " (update check disabled)";
217
- else if (update.updateAvailable) versionNote = ` \u2192 ${update.latest} available`;
218
- else if (update.error && !update.latest) versionNote = " (update check unavailable)";
219
- else if (update.error) versionNote = ` (latest known: ${update.latest}; check failed)`;
220
- else versionNote = " (up to date)";
269
+ if (kind === "launcher") {
270
+ const state = readLauncherState();
271
+ versionNote = state?.autoUpdate === false ? " (auto-update off)" : " (auto-update on)";
272
+ if (state?.autoUpdate !== false) {
273
+ try {
274
+ const latest = JSON.parse(readFileSync(join(ROOT, "latest.json"), "utf8"));
275
+ if (latest?.version && compareVersions(latest.version, VERSION) > 0) {
276
+ const latestMajor = String(latest.version).split(".")[0];
277
+ const currentMajor = String(VERSION).split(".")[0];
278
+ versionNote +=
279
+ latestMajor === currentMajor
280
+ ? ` — ${latest.version} pending`
281
+ : ` — ${latest.version} requires a manual upgrade`;
282
+ }
283
+ } catch {}
284
+ }
285
+ } else if (kind === "package") {
286
+ versionNote = " (npx install — manual updates)";
287
+ } else {
288
+ versionNote = " (checkout)";
289
+ }
221
290
  console.log(`\nforge-delegate: ${VERSION}${versionNote}`);
222
291
  console.log(
223
292
  `opencode: ${oc.found ? (oc.version?.raw ?? "unknown version") : "not found on PATH"}` +
@@ -235,10 +304,9 @@ async function status() {
235
304
  console.log(`recorded delegations: ${lines} (see 'forge-delegate stats')`);
236
305
  }
237
306
  } catch {}
238
- if (update.updateAvailable) {
239
- console.log(`\nUpdate to ${update.latest}:`);
240
- console.log(" npx install: rm -rf ~/.npm/_npx (then restart your agent)");
241
- console.log(" global: npm i -g forge-delegate");
307
+ if (kind === "package") {
308
+ console.log("\nThis npx install never refreshes itself.");
309
+ console.log("Switch to automatic updates: npx -y forge-delegate@latest setup");
242
310
  }
243
311
  }
244
312
 
@@ -291,9 +359,12 @@ Usage:
291
359
  forge-delegate config set --model <m> [--agent <a>] [--variant <v>]
292
360
  [--timeout <s>] [--dir <path>] [--autoApprove true|false]
293
361
  forge-delegate config path Print config file path
294
- forge-delegate status Show version, update status, opencode logins, usage
362
+ forge-delegate status Show version, update mode, opencode logins, usage
295
363
  forge-delegate stats Show detailed usage statistics
296
364
 
365
+ Automatic updates (launcher installs) are on by default. Disable with:
366
+ FORGE_AUTO_UPDATE=0
367
+
297
368
  Models come from your opencode installation:
298
369
  opencode auth login <provider> add a provider
299
370
  opencode models list available models
package/launcher.mjs ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ // forge-delegate launcher.
3
+ //
4
+ // Clients run this file instead of npx. It keeps a private install under
5
+ // ~/.forge-delegate/pkg, refreshes it from npm when a newer same-major release
6
+ // exists, then starts `node cli.mjs serve`.
7
+ //
8
+ // An update never blocks startup: the new version is fetched in the background
9
+ // and used on the next launch. If the registry or npm is unavailable, the last
10
+ // good version runs. A cold start with nothing installed falls back to npx.
11
+ //
12
+ // `--install-only <version>` exists for `setup`, which pre-installs the running
13
+ // version so a client's first launch does not have to wait for npm.
14
+
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const SELF = fileURLToPath(import.meta.url);
22
+ const ROOT = join(homedir(), ".forge-delegate");
23
+ const PKG = join(ROOT, "pkg");
24
+ const STATE = join(ROOT, "launcher-state.json");
25
+ const LATEST = join(ROOT, "latest.json");
26
+ const SKILL = join(homedir(), ".claude", "skills", "delegate-work", "SKILL.md");
27
+ const REGISTRY = process.env.FORGE_REGISTRY ?? "https://registry.npmjs.org";
28
+ const TTL = 6 * 3600 * 1000;
29
+ const AUTO =
30
+ process.env.FORGE_AUTO_UPDATE !== "0" &&
31
+ process.env.FORGE_AUTO_UPDATE !== "false" &&
32
+ process.env.FORGE_NO_AUTO_UPDATE !== "1";
33
+
34
+ function parseVersion(v) {
35
+ const m = String(v ?? "").match(/^(\d+)\.(\d+)\.(\d+)/);
36
+ return m ? [+m[1], +m[2], +m[3]] : null;
37
+ }
38
+
39
+ function compareVersions(a, b) {
40
+ const pa = parseVersion(a);
41
+ const pb = parseVersion(b);
42
+ if (!pa || !pb) return 0;
43
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
44
+ return 0;
45
+ }
46
+
47
+ function readJson(path) {
48
+ try {
49
+ return JSON.parse(readFileSync(path, "utf8"));
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function cliPath(version) {
56
+ if (!version) return null;
57
+ const cli = join(PKG, version, "node_modules", "forge-delegate", "cli.mjs");
58
+ return existsSync(cli) ? cli : null;
59
+ }
60
+
61
+ // Newest first. A version directory only counts once its cli.mjs exists, so a
62
+ // half-finished install can never be selected.
63
+ function installedVersions() {
64
+ try {
65
+ return readdirSync(PKG)
66
+ .filter((name) => /^\d+\.\d+\.\d+$/.test(name) && cliPath(name))
67
+ .sort((a, b) => compareVersions(b, a));
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
73
+ async function latestVersion() {
74
+ const cached = readJson(LATEST);
75
+ if (cached?.version && Number.isFinite(cached.checkedAt) && Date.now() - cached.checkedAt < TTL) {
76
+ return cached.version;
77
+ }
78
+ try {
79
+ const res = await fetch(`${REGISTRY}/forge-delegate/latest`, { signal: AbortSignal.timeout(2000) });
80
+ if (!res.ok) return null;
81
+ const { version } = await res.json();
82
+ if (!parseVersion(version)) return null;
83
+ mkdirSync(ROOT, { recursive: true });
84
+ writeFileSync(LATEST, JSON.stringify({ version, checkedAt: Date.now() }));
85
+ return version;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // Install into a staging directory and rename it into place, so a failed or
92
+ // interrupted download never becomes the version the launcher picks.
93
+ function installVersion(version) {
94
+ if (cliPath(version)) return true;
95
+ mkdirSync(PKG, { recursive: true });
96
+ const stage = join(PKG, `.tmp-${process.pid}-${Date.now().toString(36)}`);
97
+ try {
98
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
99
+ const res = spawnSync(
100
+ npm,
101
+ [
102
+ "install",
103
+ "--prefix",
104
+ stage,
105
+ "--ignore-scripts",
106
+ "--no-audit",
107
+ "--no-fund",
108
+ "--silent",
109
+ `forge-delegate@${version}`,
110
+ ],
111
+ { stdio: "ignore", timeout: 180_000 }
112
+ );
113
+ if (res.status !== 0 || !existsSync(join(stage, "node_modules", "forge-delegate", "cli.mjs"))) return false;
114
+ try {
115
+ renameSync(stage, join(PKG, version));
116
+ } catch {
117
+ // Another process won the race; its copy is complete because it also
118
+ // installed into a staging directory before renaming.
119
+ }
120
+ return Boolean(cliPath(version));
121
+ } finally {
122
+ try {
123
+ rmSync(stage, { recursive: true, force: true });
124
+ } catch {}
125
+ }
126
+ }
127
+
128
+ // Keep the selected version plus one fallback.
129
+ function prune(keep) {
130
+ const versions = installedVersions();
131
+ const keepSet = new Set([keep, versions.find((v) => v !== keep)]);
132
+ for (const version of versions) {
133
+ if (keepSet.has(version)) continue;
134
+ try {
135
+ rmSync(join(PKG, version), { recursive: true, force: true });
136
+ } catch {}
137
+ }
138
+ }
139
+
140
+ function installOnly(version) {
141
+ if (!parseVersion(version)) return false;
142
+ const ok = installVersion(version);
143
+ if (ok) prune(version);
144
+ return ok;
145
+ }
146
+
147
+ // The skill is a file on disk that only `setup` rewrites. Refresh it from the
148
+ // version being launched, but never create one the user did not ask for.
149
+ function syncSkill(cli) {
150
+ try {
151
+ const src = join(dirname(cli), "skills", "delegate-work", "SKILL.md");
152
+ if (!existsSync(src) || !existsSync(SKILL)) return;
153
+ const want = readFileSync(src, "utf8");
154
+ if (readFileSync(SKILL, "utf8") !== want) writeFileSync(SKILL, want);
155
+ } catch {}
156
+ }
157
+
158
+ // `setup` copies this file once, but a later release may fix update logic.
159
+ // Refresh it from the version being launched; the running copy is already in
160
+ // memory, so replacing the file on disk is safe.
161
+ function syncLauncher(cli) {
162
+ try {
163
+ const src = join(dirname(cli), "launcher.mjs");
164
+ if (!existsSync(src) || !existsSync(SELF)) return;
165
+ const want = readFileSync(src, "utf8");
166
+ if (readFileSync(SELF, "utf8") !== want) writeFileSync(SELF, want);
167
+ } catch {}
168
+ }
169
+
170
+ if (process.argv[2] === "--install-only") {
171
+ process.exit(installOnly(process.argv[3]) ? 0 : 1);
172
+ }
173
+
174
+ const current = installedVersions()[0] ?? null;
175
+ let target = current;
176
+
177
+ if (AUTO) {
178
+ const latest = await latestVersion();
179
+ if (latest) {
180
+ const latestMajor = parseVersion(latest)[0];
181
+ const currentMajor = current ? parseVersion(current)[0] : latestMajor;
182
+ if (latestMajor === currentMajor && compareVersions(latest, current ?? "0.0.0") > 0) {
183
+ if (cliPath(latest)) {
184
+ target = latest;
185
+ } else if (current) {
186
+ // Update in the background and run the current version now, so an
187
+ // npm install can never hold up the MCP handshake.
188
+ const child = spawn(process.execPath, [SELF, "--install-only", latest], {
189
+ detached: true,
190
+ stdio: "ignore",
191
+ });
192
+ child.unref();
193
+ } else if (installVersion(latest)) {
194
+ // Nothing usable installed yet — this one has to be synchronous.
195
+ target = latest;
196
+ }
197
+ } else if (current && latestMajor > currentMajor) {
198
+ process.stderr.write(
199
+ `forge-delegate: ${latest} is a major update and needs a manual upgrade; running ${current}\n`
200
+ );
201
+ }
202
+ }
203
+ }
204
+
205
+ const cli = cliPath(target);
206
+ if (cli) {
207
+ syncLauncher(cli);
208
+ syncSkill(cli);
209
+ try {
210
+ mkdirSync(ROOT, { recursive: true });
211
+ writeFileSync(STATE, JSON.stringify({ version: target, autoUpdate: AUTO, updatedAt: Date.now() }));
212
+ } catch {}
213
+ }
214
+
215
+ const npx = process.platform === "win32" ? "npx.cmd" : "npx";
216
+ // No args means "serve" (the MCP entry point). Any other arg is forwarded, so
217
+ // `node ~/.forge-delegate/launcher.mjs status` works without a global binary.
218
+ const args = process.argv.slice(2);
219
+ const cliArgs = args.length ? args : ["serve"];
220
+ const child = cli
221
+ ? spawn(process.execPath, [cli, ...cliArgs], { stdio: "inherit" })
222
+ : spawn(npx, ["-y", "forge-delegate", ...cliArgs], { stdio: "inherit" });
223
+
224
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
225
+ process.on(signal, () => child.kill(signal));
226
+ }
227
+
228
+ child.on("exit", (code, signal) => {
229
+ if (signal) process.kill(process.pid, signal);
230
+ else process.exit(code ?? 0);
231
+ });
package/lib.mjs CHANGED
@@ -53,8 +53,6 @@ const AUTH_PATH = join(homedir(), ".local/share/opencode/auth.json");
53
53
  const OPENCODE_CONFIG_PATH = join(homedir(), ".config/opencode/opencode.json");
54
54
  const CATALOG_CACHE = join(CONFIG_DIR, "models-dev.json");
55
55
  const CATALOG_TTL = 24 * 3600 * 1000;
56
- const UPDATE_CACHE = join(CONFIG_DIR, "update-check.json");
57
- const UPDATE_TTL = 24 * 3600 * 1000;
58
56
  const OLLAMA_BASE = process.env.OLLAMA_URL ?? "http://localhost:11434";
59
57
 
60
58
  function parseVersion(v) {
@@ -70,62 +68,6 @@ export function compareVersions(a, b) {
70
68
  return 0;
71
69
  }
72
70
 
73
- function readUpdateCache() {
74
- try {
75
- const cached = JSON.parse(readFileSync(UPDATE_CACHE, "utf8"));
76
- if (cached?.latest && Number.isFinite(cached.checkedAt)) return cached;
77
- } catch {}
78
- return null;
79
- }
80
-
81
- // An installed copy cannot see the registry on its own, and npx happily reuses a
82
- // cached install forever — which is how this server ran 1.0.7 while 1.0.9 was
83
- // published. Check once a day, cache the answer, and never let a network failure
84
- // break the caller. Opt out with FORGE_NO_UPDATE_CHECK=1 (or NO_UPDATE_NOTIFIER=1).
85
- export async function checkForUpdate({ force = false, timeoutMs = 3000 } = {}) {
86
- const cached = readUpdateCache();
87
- if (!force && cached && Date.now() - cached.checkedAt < UPDATE_TTL) {
88
- return {
89
- current: VERSION,
90
- latest: cached.latest,
91
- updateAvailable: compareVersions(cached.latest, VERSION) > 0,
92
- checkedAt: cached.checkedAt,
93
- };
94
- }
95
-
96
- if (process.env.FORGE_NO_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) {
97
- return { current: VERSION, latest: null, updateAvailable: false, checkedAt: null, disabled: true };
98
- }
99
-
100
- try {
101
- const res = await fetch("https://registry.npmjs.org/forge-delegate/latest", {
102
- signal: AbortSignal.timeout(timeoutMs),
103
- });
104
- if (!res.ok) throw new Error(`registry returned ${res.status}`);
105
- const { version } = await res.json();
106
- if (!parseVersion(version)) throw new Error("registry returned no version");
107
- const checkedAt = Date.now();
108
- try {
109
- mkdirSync(CONFIG_DIR, { recursive: true });
110
- writeFileSync(UPDATE_CACHE, JSON.stringify({ latest: version, checkedAt }));
111
- } catch {}
112
- return {
113
- current: VERSION,
114
- latest: version,
115
- updateAvailable: compareVersions(version, VERSION) > 0,
116
- checkedAt,
117
- };
118
- } catch (e) {
119
- return {
120
- current: VERSION,
121
- latest: cached?.latest ?? null,
122
- updateAvailable: cached ? compareVersions(cached.latest, VERSION) > 0 : false,
123
- checkedAt: cached?.checkedAt ?? null,
124
- error: e.message,
125
- };
126
- }
127
- }
128
-
129
71
  function execFileText(cmd, args, opts = {}) {
130
72
  return new Promise((resolve, reject) => {
131
73
  execFile(cmd, args, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024, ...opts }, (err, stdout) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-delegate",
3
- "version": "1.0.10",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server that relays coding work to other AI models (DeepSeek, Gemini, OpenAI, Ollama) so Claude Code saves tokens",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,9 +9,10 @@
9
9
  "scripts": {
10
10
  "setup": "node cli.mjs setup",
11
11
  "start": "node cli.mjs serve",
12
- "prepublishOnly": "node --check cli.mjs && node --check server.mjs && node --check lib.mjs && node test-handshake.mjs"
12
+ "prepublishOnly": "node --check launcher.mjs && node --check cli.mjs && node --check server.mjs && node --check lib.mjs && node test-handshake.mjs"
13
13
  },
14
14
  "files": [
15
+ "launcher.mjs",
15
16
  "cli.mjs",
16
17
  "server.mjs",
17
18
  "lib.mjs",
package/server.mjs CHANGED
@@ -21,13 +21,22 @@ import {
21
21
  saveConfig,
22
22
  configPath,
23
23
  resolveConfig,
24
- checkForUpdate,
25
24
  VERSION,
26
25
  } from "./lib.mjs";
27
26
 
28
27
  const execFileAsync = promisify(execFile);
29
28
  const RUNS_DIR = join(homedir(), ".forge-delegate", "runs");
30
29
  const here = dirname(fileURLToPath(import.meta.url));
30
+ const herePosix = here.replace(/\\/g, "/");
31
+
32
+ // Only a launcher install can refresh itself; the report tells the user (and
33
+ // the model) which kind they have.
34
+ function installKind() {
35
+ if (herePosix.includes("/.forge-delegate/pkg/")) return "launcher (automatic updates)";
36
+ if (herePosix.includes("/.npm/_npx/") || herePosix.includes("node_modules/forge-delegate"))
37
+ return "npx/package (manual updates)";
38
+ return "checkout";
39
+ }
31
40
 
32
41
  // The installed skill is a file on disk that only `setup` rewrites, so a published
33
42
  // skill change goes unnoticed. Compare the two and say so, rather than running
@@ -287,27 +296,19 @@ server.tool(
287
296
 
288
297
  server.tool(
289
298
  "get_delegate_config",
290
- "Read the current forge-delegate configuration: defaultModel, defaultAgent, defaultVariant, autoApprove, timeoutMs, defaultDirectory, and named profiles — plus the installed version and whether a newer release is published. Call this to see what will be used when a model/directory is omitted, or when the user asks whether forge-delegate is up to date.",
299
+ "Read the current forge-delegate configuration: defaultModel, defaultAgent, defaultVariant, autoApprove, timeoutMs, defaultDirectory, and named profiles — plus the installed version and whether it updates automatically. Call this to see what will be used when a model/directory is omitted, or when the user asks which version is running.",
291
300
  {},
292
301
  async () => {
293
302
  const cfg = loadConfig();
294
- const update = await checkForUpdate();
295
303
  const { profiles, ...rest } = cfg;
296
304
  const lines = Object.entries(rest).map(([k, v]) => ` ${k}: ${v ?? "(unset)"}`);
297
305
  const prof = Object.entries(profiles).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n") || " (none)";
298
306
  const skill = SKILL_STATUS ? `\nskill (delegate-work): ${SKILL_STATUS.detail}${SKILL_STATUS.state === "ok" ? "" : " — run: npx -y forge-delegate setup"}` : "";
299
- const updateLine = update.disabled
300
- ? "updateAvailable: unknown (check disabled)"
301
- : update.error && !update.latest
302
- ? `updateAvailable: unknown (${update.error})`
303
- : update.updateAvailable
304
- ? `updateAvailable: true (latest ${update.latest}, running ${VERSION})`
305
- : "updateAvailable: false";
306
307
  return {
307
308
  content: [
308
309
  {
309
310
  type: "text",
310
- text: `version: ${VERSION}\n${updateLine}\nconfig: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}${skill}`,
311
+ text: `version: ${VERSION}\ninstall: ${installKind()}\nconfig: ${configPath()}\n${lines.join("\n")}\nprofiles:\n${prof}${skill}`,
311
312
  },
312
313
  ],
313
314
  };
@@ -339,7 +340,3 @@ server.tool(
339
340
 
340
341
  const transport = new StdioServerTransport();
341
342
  await server.connect(transport);
342
-
343
- // Warm the update cache after the handshake — a registry call must never delay
344
- // the MCP connection. get_delegate_config reads whatever this leaves behind.
345
- checkForUpdate().catch(() => {});