remote-agents 0.1.1 → 0.1.3

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.
Files changed (4) hide show
  1. package/README.md +15 -2
  2. package/install.js +32 -7
  3. package/package.json +3 -3
  4. package/run.js +183 -35
package/README.md CHANGED
@@ -14,7 +14,7 @@ npm install -g remote-agents
14
14
  npx remote-agents --help
15
15
  ```
16
16
 
17
- Supported platforms: linux x64, macOS x64/arm64, windows x64.
17
+ Supported platforms: linux x64/arm64, macOS x64/arm64, windows x64.
18
18
 
19
19
  ## Use as an MCP server
20
20
 
@@ -35,9 +35,22 @@ Supported platforms: linux x64, macOS x64/arm64, windows x64.
35
35
  ```
36
36
 
37
37
  `remote-agents <subcommand>` forwards to the native binary (`run`, `mcp`,
38
- `install`, …). See the [project README](https://github.com/ObsidianMotorman/tunshell_mcp_agents#readme)
38
+ `install`, …). See the [project README](https://github.com/47-ronn/tunshell_mcp_agents#readme)
39
39
  for the full documentation.
40
40
 
41
+ ## Update checks
42
+
43
+ When started in a long-running mode (`run` / `mcp` / `hybrid`), the launcher does
44
+ a best-effort check against the npm registry and logs a notice if a newer
45
+ version is published. It is **notify-only** — the agent never self-updates, so a
46
+ running task is never interrupted. Update at your convenience with:
47
+
48
+ ```bash
49
+ npm i -g remote-agents@latest
50
+ ```
51
+
52
+ Set `REMOTE_AGENTS_NO_UPDATE_CHECK=1` to disable the check entirely.
53
+
41
54
  ## License
42
55
 
43
56
  MIT
package/install.js CHANGED
@@ -7,12 +7,11 @@ const path = require("path");
7
7
  const https = require("https");
8
8
  const { version } = require("./package.json");
9
9
 
10
- const REPO = "ObsidianMotorman/tunshell_mcp_agents";
10
+ const REPO = "47-ronn/tunshell_mcp_agents";
11
11
 
12
- function target() {
13
- const p = process.platform;
14
- const a = process.arch;
12
+ function target(p = process.platform, a = process.arch) {
15
13
  if (p === "linux" && a === "x64") return { triple: "x86_64-unknown-linux-musl", exe: "" };
14
+ if (p === "linux" && a === "arm64") return { triple: "aarch64-unknown-linux-musl", exe: "" };
16
15
  if (p === "darwin" && a === "x64") return { triple: "x86_64-apple-darwin", exe: "" };
17
16
  if (p === "darwin" && a === "arm64") return { triple: "aarch64-apple-darwin", exe: "" };
18
17
  if (p === "win32" && a === "x64") return { triple: "x86_64-pc-windows-msvc", exe: ".exe" };
@@ -41,6 +40,27 @@ function download(url, dest, redirects = 0) {
41
40
  });
42
41
  }
43
42
 
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+
45
+ // Retry transient download failures (flaky networks / CDN hiccups) with a small
46
+ // linear backoff. `doDownload`/`wait` are injectable for testing.
47
+ async function downloadWithRetry(url, dest, attempts = 3, doDownload = download, wait = sleep) {
48
+ let lastErr;
49
+ for (let i = 0; i < attempts; i++) {
50
+ try {
51
+ await doDownload(url, dest);
52
+ return;
53
+ } catch (e) {
54
+ lastErr = e;
55
+ if (i < attempts - 1) {
56
+ console.error(`remote-agents: download attempt ${i + 1} failed (${e.message}); retrying…`);
57
+ await wait(500 * (i + 1));
58
+ }
59
+ }
60
+ }
61
+ throw lastErr;
62
+ }
63
+
44
64
  async function main() {
45
65
  // Escape hatch for CI / source installs where no matching release exists.
46
66
  if (process.env.REMOTE_AGENTS_SKIP_DOWNLOAD) {
@@ -52,7 +72,7 @@ async function main() {
52
72
  if (!t) {
53
73
  console.error(
54
74
  `remote-agents: no prebuilt binary for ${process.platform}/${process.arch}.\n` +
55
- ` Supported: linux x64, macOS x64/arm64, windows x64.\n` +
75
+ ` Supported: linux x64/arm64, macOS x64/arm64, windows x64.\n` +
56
76
  ` Build from source: https://github.com/${REPO}`
57
77
  );
58
78
  process.exit(1);
@@ -66,7 +86,7 @@ async function main() {
66
86
 
67
87
  console.log(`remote-agents: downloading ${asset} (v${version})…`);
68
88
  try {
69
- await download(url, dest);
89
+ await downloadWithRetry(url, dest);
70
90
  if (!t.exe) fs.chmodSync(dest, 0o755);
71
91
  console.log(`remote-agents: installed ${dest}`);
72
92
  } catch (e) {
@@ -77,4 +97,9 @@ async function main() {
77
97
  }
78
98
  }
79
99
 
80
- main();
100
+ // Only download when run as the postinstall script, not when require()'d by tests.
101
+ if (require.main === module) {
102
+ main();
103
+ }
104
+
105
+ module.exports = { target, downloadWithRetry };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-agents",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Unified MCP server for controlling fleets of remote machines through AI agents (Claude, opencode)",
5
5
  "keywords": [
6
6
  "mcp",
@@ -11,10 +11,10 @@
11
11
  "ssh",
12
12
  "devops"
13
13
  ],
14
- "homepage": "https://github.com/ObsidianMotorman/tunshell_mcp_agents#readme",
14
+ "homepage": "https://github.com/47-ronn/tunshell_mcp_agents#readme",
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "git+https://github.com/ObsidianMotorman/tunshell_mcp_agents.git"
17
+ "url": "git+https://github.com/47-ronn/tunshell_mcp_agents.git"
18
18
  },
19
19
  "license": "MIT",
20
20
  "author": "47-ron",
package/run.js CHANGED
@@ -2,50 +2,198 @@
2
2
  // Launcher: exec the downloaded native `remote-agent` binary, forwarding args,
3
3
  // stdin/stdout/stderr, termination signals, and exit code.
4
4
  // `remote-agents <cmd>` == `remote-agent <cmd>`.
5
+ //
6
+ // On startup of a long-running mode it also does a best-effort npm-registry
7
+ // version check and logs a notice if a newer release is published. This is
8
+ // NOTIFY-ONLY: the agent never self-updates (updating mid-task would interrupt
9
+ // it); the operator runs `npm i -g remote-agents@latest` when convenient.
5
10
 
6
11
  const path = require("path");
7
12
  const fs = require("fs");
8
13
  const os = require("os");
14
+ const https = require("https");
9
15
  const { spawn } = require("child_process");
16
+ const { version } = require("./package.json");
10
17
 
11
- const exe = process.platform === "win32" ? ".exe" : "";
12
- const bin = path.join(__dirname, "bin", `remote-agent${exe}`);
13
-
14
- if (!fs.existsSync(bin)) {
15
- console.error(
16
- "remote-agents: native binary not found.\n" +
17
- " Reinstall it with: npm install -g remote-agents (re-runs the download)."
18
- );
19
- process.exit(1);
20
- }
21
-
22
- const child = spawn(bin, process.argv.slice(2), { stdio: "inherit" });
23
-
24
- // Forward termination signals so stopping this launcher also stops the agent
25
- // (spawnSync used to orphan the native process).
26
- const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
27
- for (const sig of SIGNALS) {
28
- process.on(sig, () => {
29
- if (!child.killed) {
30
- try {
31
- child.kill(sig);
32
- } catch {
33
- /* child already gone */
18
+ const REGISTRY_URL = "https://registry.npmjs.org/remote-agents/latest";
19
+ // Only the long-running modes are worth a version check; one-shot subcommands
20
+ // (init/config/install/...) start and exit, so a notice there is just noise.
21
+ const LONG_RUNNING = new Set(["run", "mcp", "hybrid"]);
22
+
23
+ // True if dotted version string `latest` is strictly greater than `current`.
24
+ // Non-numeric / missing segments are treated as 0 (best-effort, no semver dep).
25
+ function isNewer(latest, current) {
26
+ const parse = (s) => String(s).split(".").map((n) => parseInt(n, 10) || 0);
27
+ const a = parse(latest);
28
+ const b = parse(current);
29
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
30
+ const x = a[i] || 0;
31
+ const y = b[i] || 0;
32
+ if (x > y) return true;
33
+ if (x < y) return false;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ // A human-readable upgrade notice if `latest` is newer than `current`, else null.
39
+ function updateNotice(current, latest) {
40
+ if (latest && isNewer(latest, current)) {
41
+ return (
42
+ `remote-agents: a newer version is available (${current} -> ${latest}). ` +
43
+ `Update with: npm i -g remote-agents@latest`
44
+ );
45
+ }
46
+ return null;
47
+ }
48
+
49
+ // Best-effort GET of the registry's `latest` document. Resolves the parsed JSON
50
+ // or rejects; callers swallow errors (an update check must never break startup).
51
+ function httpGetJson(url, timeoutMs) {
52
+ return new Promise((resolve, reject) => {
53
+ const req = https.get(
54
+ url,
55
+ { headers: { "User-Agent": "remote-agents-updatecheck" } },
56
+ (res) => {
57
+ if (res.statusCode !== 200) {
58
+ res.resume();
59
+ return reject(new Error(`HTTP ${res.statusCode}`));
60
+ }
61
+ let data = "";
62
+ res.setEncoding("utf8");
63
+ res.on("data", (c) => (data += c));
64
+ res.on("end", () => {
65
+ try {
66
+ resolve(JSON.parse(data));
67
+ } catch (e) {
68
+ reject(e);
69
+ }
70
+ });
34
71
  }
35
- }
72
+ );
73
+ req.on("error", reject);
74
+ req.setTimeout(timeoutMs, () => req.destroy(new Error("timeout")));
36
75
  });
37
76
  }
38
77
 
39
- child.on("error", (err) => {
40
- console.error(`remote-agents: failed to launch binary: ${err.message}`);
41
- process.exit(1);
42
- });
78
+ // Resolve the latest published version string, or null on any failure. `doGet`
79
+ // is injectable for tests.
80
+ function fetchLatestVersion(doGet = httpGetJson, timeoutMs = 3000) {
81
+ return doGet(REGISTRY_URL, timeoutMs)
82
+ .then((json) => (json && typeof json.version === "string" ? json.version : null))
83
+ .catch(() => null);
84
+ }
85
+
86
+ // Platform data directory, mirroring Rust's `dirs::data_dir()` so the native
87
+ // agent reads the same cache file: Linux XDG, macOS Application Support,
88
+ // Windows Roaming AppData.
89
+ function dataDir() {
90
+ if (process.platform === "win32") {
91
+ return process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
92
+ }
93
+ if (process.platform === "darwin") {
94
+ return path.join(os.homedir(), "Library", "Application Support");
95
+ }
96
+ return process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
97
+ }
43
98
 
44
- child.on("exit", (code, signal) => {
45
- if (signal) {
46
- // Mirror the child's signal in our exit status (128 + signum).
47
- const num = (os.constants.signals && os.constants.signals[signal]) || 15;
48
- process.exit(128 + num);
99
+ // Where the latest-known version is cached for the native agent to read and
100
+ // surface as AgentInfo.update_available.
101
+ function latestVersionPath() {
102
+ return path.join(dataDir(), "remote-agents", "latest-version");
103
+ }
104
+
105
+ // Best-effort write of the update-available cache for the native agent to read
106
+ // (surfaced as AgentInfo.update_available). The launcher owns the comparison —
107
+ // it knows the accurate INSTALLED version (the compiled-in Cargo version can lag
108
+ // the npm release) — so it writes the newer version when an upgrade exists and
109
+ // clears the file (empty) once up to date. Never throws. `writeFile`/`mkdir`
110
+ // are injectable for tests.
111
+ function cacheUpdateAvailable(
112
+ latest,
113
+ current = version,
114
+ file = latestVersionPath(),
115
+ writeFile = fs.writeFileSync,
116
+ mkdir = fs.mkdirSync
117
+ ) {
118
+ const body = latest && isNewer(latest, current) ? String(latest) : "";
119
+ try {
120
+ mkdir(path.dirname(file), { recursive: true });
121
+ writeFile(file, body);
122
+ } catch {
123
+ /* cache is best-effort; the notify-only log still fires */
49
124
  }
50
- process.exit(code === null ? 1 : code);
51
- });
125
+ }
126
+
127
+ // Fire-and-forget: log an upgrade notice for long-running modes. Never throws,
128
+ // never blocks the agent. Disable with REMOTE_AGENTS_NO_UPDATE_CHECK=1.
129
+ async function maybeNotifyUpdate(subcommand, fetchLatest = fetchLatestVersion) {
130
+ if (process.env.REMOTE_AGENTS_NO_UPDATE_CHECK) return;
131
+ if (!LONG_RUNNING.has(subcommand)) return;
132
+ const latest = await fetchLatest();
133
+ // Cache the result so the native agent can surface it as
134
+ // AgentInfo.update_available (visible in list_agents).
135
+ cacheUpdateAvailable(latest);
136
+ const notice = updateNotice(version, latest);
137
+ if (notice) console.error(notice);
138
+ }
139
+
140
+ function main() {
141
+ const exe = process.platform === "win32" ? ".exe" : "";
142
+ const bin = path.join(__dirname, "bin", `remote-agent${exe}`);
143
+
144
+ if (!fs.existsSync(bin)) {
145
+ console.error(
146
+ "remote-agents: native binary not found.\n" +
147
+ " Reinstall it with: npm install -g remote-agents (re-runs the download)."
148
+ );
149
+ process.exit(1);
150
+ }
151
+
152
+ const child = spawn(bin, process.argv.slice(2), { stdio: "inherit" });
153
+
154
+ // Best-effort, non-blocking update check (notify-only).
155
+ maybeNotifyUpdate(process.argv[2]).catch(() => {});
156
+
157
+ // Forward termination signals so stopping this launcher also stops the agent
158
+ // (spawnSync used to orphan the native process).
159
+ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
160
+ for (const sig of SIGNALS) {
161
+ process.on(sig, () => {
162
+ if (!child.killed) {
163
+ try {
164
+ child.kill(sig);
165
+ } catch {
166
+ /* child already gone */
167
+ }
168
+ }
169
+ });
170
+ }
171
+
172
+ child.on("error", (err) => {
173
+ console.error(`remote-agents: failed to launch binary: ${err.message}`);
174
+ process.exit(1);
175
+ });
176
+
177
+ child.on("exit", (code, signal) => {
178
+ if (signal) {
179
+ // Mirror the child's signal in our exit status (128 + signum).
180
+ const num = (os.constants.signals && os.constants.signals[signal]) || 15;
181
+ process.exit(128 + num);
182
+ }
183
+ process.exit(code === null ? 1 : code);
184
+ });
185
+ }
186
+
187
+ // Only launch when invoked directly, not when require()'d by tests.
188
+ if (require.main === module) {
189
+ main();
190
+ }
191
+
192
+ module.exports = {
193
+ isNewer,
194
+ updateNotice,
195
+ fetchLatestVersion,
196
+ maybeNotifyUpdate,
197
+ cacheUpdateAvailable,
198
+ latestVersionPath,
199
+ };