agentroam 0.2.0-preview.7 → 0.2.0-preview.9

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 (44) hide show
  1. package/README.md +42 -8
  2. package/bin/agentroam.mjs +10 -1
  3. package/bin/node-preflight.mjs +138 -0
  4. package/dist/args.js +14 -1
  5. package/dist/args.test.js +27 -0
  6. package/dist/cli.js +59 -7
  7. package/dist/cloudflared/bundled-asset.test.js +2 -2
  8. package/dist/codex-runtime-manager.js +293 -0
  9. package/dist/codex-runtime-manager.test.js +192 -0
  10. package/dist/install-script-contract.test.js +41 -0
  11. package/dist/node-preflight.test.js +109 -0
  12. package/dist/node-runtime-manager.js +251 -0
  13. package/dist/node-runtime-manager.test.js +184 -0
  14. package/dist/opencode-runtime-manager.js +207 -0
  15. package/dist/opencode-runtime-manager.test.js +79 -0
  16. package/dist/platform-packages.js +1 -1
  17. package/dist/platform.js +8 -4
  18. package/dist/platform.test.js +17 -2
  19. package/dist/power/sleep-inhibitor.js +151 -0
  20. package/dist/power/sleep-inhibitor.test.js +104 -0
  21. package/dist/runtime-manager.js +50 -2
  22. package/dist/runtime-manager.test.js +38 -0
  23. package/dist/service/macos-launch-agent.js +250 -0
  24. package/dist/service/macos-launch-agent.test.js +197 -0
  25. package/dist/service/runtime-state.js +40 -0
  26. package/dist/service/runtime-state.test.js +43 -0
  27. package/dist/service/service-command.js +100 -0
  28. package/dist/service/service-command.test.js +102 -0
  29. package/dist/service/service-controller.js +27 -0
  30. package/dist/service/service-files.js +78 -0
  31. package/dist/service/service-files.test.js +48 -0
  32. package/dist/service/windows-service-host.js +53 -0
  33. package/dist/service/windows-service-host.test.js +46 -0
  34. package/dist/service/windows-task-service.js +275 -0
  35. package/dist/service/windows-task-service.test.js +95 -0
  36. package/dist/tunnel/cloudflare-provider.js +4 -1
  37. package/dist/tunnel/cloudflare-provider.test.js +13 -1
  38. package/dist/tunnel/public-readiness.js +1 -1
  39. package/dist/tunnel/relay-orchestrator.js +4 -0
  40. package/dist/tunnel/relay-orchestrator.test.js +11 -0
  41. package/install/README.md +45 -0
  42. package/install/install-agentroam.ps1 +175 -0
  43. package/install/install-agentroam.sh +199 -0
  44. package/package.json +8 -7
package/README.md CHANGED
@@ -20,20 +20,20 @@ npx agentroam@preview
20
20
  Or install from tarball:
21
21
 
22
22
  ```bash
23
- npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.7.tgz \
24
- ./agentroam-runtime-darwin-arm64-0.2.0-preview.7.tgz \
25
- ./agentroam-tui-darwin-arm64-0.2.0-preview.7.tgz \
26
- ./agentroam-0.2.0-preview.7.tgz
23
+ npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.9.tgz \
24
+ ./agentroam-runtime-darwin-arm64-0.2.0-preview.9.tgz \
25
+ ./agentroam-tui-darwin-arm64-0.2.0-preview.9.tgz \
26
+ ./agentroam-0.2.0-preview.9.tgz
27
27
  npx agentroam
28
28
  ```
29
29
 
30
30
  Windows PowerShell:
31
31
 
32
32
  ```powershell
33
- npm install ./agentroam-cloudflared-win32-x64-0.2.0-preview.7.tgz `
34
- ./agentroam-runtime-win32-x64-0.2.0-preview.7.tgz `
35
- ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.7.tgz `
36
- ./agentroam-0.2.0-preview.7.tgz
33
+ npm install ./agentroam-cloudflared-win32-x64-0.2.0-preview.9.tgz `
34
+ ./agentroam-runtime-win32-x64-0.2.0-preview.9.tgz `
35
+ ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.9.tgz `
36
+ ./agentroam-0.2.0-preview.9.tgz
37
37
  npx agentroam
38
38
  ```
39
39
 
@@ -53,6 +53,40 @@ agentroam version
53
53
  agent-tui # start the bundled terminal Agent UI
54
54
  ```
55
55
 
56
+ ## Install and run in the background
57
+
58
+ Run the versioned Shell installer on macOS or PowerShell installer on Windows
59
+ from the project directory AgentRoam should expose. The installer verifies or
60
+ installs Node 22 and the matching CLI, registers the current-user service, starts
61
+ it, checks readiness, and prints the access URL. Set `AGENTROAM_ROOT` to select a
62
+ different root explicitly. An implicit root equal to the home directory is
63
+ rejected.
64
+
65
+ The service keeps running after the terminal closes, restarts after failure, and
66
+ starts again when the user logs in. macOS uses a user LaunchAgent; Windows uses a
67
+ current-user Task Scheduler task. Neither requires administrator privileges.
68
+
69
+ ```bash
70
+ agentroam service status
71
+ agentroam service start
72
+ agentroam service stop
73
+ agentroam service url
74
+ agentroam service logs
75
+ agentroam service restart
76
+ agentroam service uninstall
77
+ ```
78
+
79
+ The current phone URL is stored privately at `~/.agentroam/tunnel.url`.
80
+ Cloudflared keeps the same Quick Tunnel hostname while its process reconnects;
81
+ a full service or computer restart creates a new random hostname. Re-run the
82
+ versioned installer after moving or replacing Node/npm so the service receives
83
+ the new absolute executable paths.
84
+
85
+ Both foreground `agentroam start` and the background service prevent idle system
86
+ sleep for as long as they run. The display can still dim and turn off normally.
87
+ Stopping AgentRoam releases the assertion. Lid close, explicit sleep,
88
+ hibernation, shutdown, and low-battery forced sleep remain controlled by the OS.
89
+
56
90
  ## Options
57
91
 
58
92
  | Flag | Description |
package/bin/agentroam.mjs CHANGED
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import("../dist/cli.js").then(({ main }) => main(process.argv.slice(2))).catch((error) => {
2
+ import("./node-preflight.mjs").then(async ({ runNodePreflight }) => {
3
+ const result = await runNodePreflight();
4
+ if (result.handled) {
5
+ if (result.signal) process.kill(process.pid, result.signal);
6
+ else process.exitCode = result.exitCode ?? 1;
7
+ return;
8
+ }
9
+ const { main } = await import("../dist/cli.js");
10
+ await main(process.argv.slice(2));
11
+ }).catch((error) => {
3
12
  console.error(`agentroam: ${error?.message ?? error}`);
4
13
  process.exitCode = error?.exitCode ?? 1;
5
14
  });
@@ -0,0 +1,138 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { delimiter, dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+ const MANAGED_NODE_VERSION = "22.22.0";
10
+ const RELEASE_VERSION = "0.2.0-preview.9";
11
+ const RELEASE_BASE_URL = `https://gitee.com/caoqu/team-agent/releases/download/v${RELEASE_VERSION}`;
12
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
13
+
14
+ export async function runNodePreflight(options = {}) {
15
+ const argv = options.argv ?? process.argv.slice(2);
16
+ const nodeVersion = options.nodeVersion ?? process.versions.node;
17
+ const platform = options.platform ?? process.platform;
18
+ const arch = options.arch ?? process.arch;
19
+ const launcherPath = options.launcherPath ?? fileURLToPath(new URL("./agentroam.mjs", import.meta.url));
20
+ const environment = options.environment ?? process.env;
21
+ const processHost = options.processHost ?? process;
22
+ const major = Number(nodeVersion.split(".")[0]);
23
+
24
+ if (major === 22) return { handled: false };
25
+ if (environment.AGENTROAM_MANAGED_NODE) {
26
+ throw cliError(
27
+ `managed Node.js re-entry failed: expected 22, got ${nodeVersion} (${environment.AGENTROAM_MANAGED_NODE})`,
28
+ );
29
+ }
30
+ if (!Number.isFinite(major) || major < 18) {
31
+ throw cliError(standaloneInstallerMessage(platform));
32
+ }
33
+
34
+ const target = detectManagedTarget(platform, arch);
35
+ const dataDir = parsePreflightDataDir(argv, environment, options.homeDir ?? homedir());
36
+ const findSystemNode22 = options.findSystemNode22 ?? defaultFindSystemNode22;
37
+ const systemNode = await findSystemNode22(environment, platform, options.currentExecutable ?? process.execPath);
38
+ let executable = systemNode;
39
+
40
+ if (!executable) {
41
+ const ensureNode = options.ensureNode ?? (async (installOptions) => {
42
+ const { ensureManagedNode } = await import("../dist/node-runtime-manager.js");
43
+ return ensureManagedNode(installOptions);
44
+ });
45
+ const resolution = await ensureNode({
46
+ dataDir,
47
+ target,
48
+ platform,
49
+ arch,
50
+ onProgress: options.onProgress ?? ((message) => processHost.stderr.write(`${message}\n`)),
51
+ });
52
+ executable = resolution.executable;
53
+ }
54
+
55
+ const spawnChild = options.spawnChild ?? defaultSpawnChild;
56
+ const child = spawnChild(executable, [launcherPath, ...argv], {
57
+ stdio: "inherit",
58
+ env: { ...environment, AGENTROAM_MANAGED_NODE: MANAGED_NODE_VERSION },
59
+ });
60
+ const result = await waitForChild(child, processHost);
61
+ return { handled: true, ...result };
62
+ }
63
+
64
+ export function parsePreflightDataDir(argv, environment = process.env, homeDir = homedir()) {
65
+ const index = argv.indexOf("--data-dir");
66
+ if (index >= 0) {
67
+ const value = argv[index + 1];
68
+ if (!value || value.startsWith("--")) throw cliError("missing value for --data-dir");
69
+ return resolve(value);
70
+ }
71
+ return resolve(environment.AGENTROAM_DATA_DIR?.trim() || homeDir, environment.AGENTROAM_DATA_DIR?.trim() ? "" : ".agentroam");
72
+ }
73
+
74
+ function detectManagedTarget(platform, arch) {
75
+ if (platform === "darwin" && arch === "arm64") return "darwin-arm64";
76
+ if (platform === "win32" && arch === "x64") return "windows-amd64";
77
+ throw cliError(`managed Node.js is unsupported on ${platform}-${arch}`);
78
+ }
79
+
80
+ async function defaultFindSystemNode22(environment, platform, currentExecutable) {
81
+ const executableName = platform === "win32" ? "node.exe" : "node";
82
+ const pathDelimiter = platform === "win32" ? ";" : delimiter;
83
+ const currentDirectory = dirname(currentExecutable);
84
+ for (const directory of (environment.PATH ?? "").split(pathDelimiter).filter(Boolean)) {
85
+ if (resolve(directory) === resolve(currentDirectory)) continue;
86
+ const candidate = resolve(directory, executableName);
87
+ try {
88
+ await access(candidate);
89
+ const { stdout } = await execFileAsync(candidate, ["-p", "process.versions.node"], {
90
+ encoding: "utf8",
91
+ timeout: 5_000,
92
+ windowsHide: true,
93
+ });
94
+ if (Number(stdout.trim().split(".")[0]) === 22) return candidate;
95
+ } catch {
96
+ // Ignore broken or incompatible PATH entries and continue to the private runtime.
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ async function defaultSpawnChild(executable, args, options) {
103
+ const { spawn } = await import("node:child_process");
104
+ return spawn(executable, args, options);
105
+ }
106
+
107
+ function waitForChild(childPromise, processHost) {
108
+ return Promise.resolve(childPromise).then((child) => new Promise((resolveChild, rejectChild) => {
109
+ const listeners = new Map();
110
+ const cleanup = () => {
111
+ for (const [signal, listener] of listeners) processHost.off(signal, listener);
112
+ };
113
+ for (const signal of FORWARDED_SIGNALS) {
114
+ const listener = () => child.kill(signal);
115
+ listeners.set(signal, listener);
116
+ processHost.on(signal, listener);
117
+ }
118
+ child.once("error", (error) => {
119
+ cleanup();
120
+ rejectChild(error);
121
+ });
122
+ child.once("exit", (exitCode, signal) => {
123
+ cleanup();
124
+ resolveChild({ exitCode, signal });
125
+ });
126
+ }));
127
+ }
128
+
129
+ function standaloneInstallerMessage(platform) {
130
+ if (platform === "win32") {
131
+ return `Node.js 18+ is required for automatic bootstrap. Run: irm ${RELEASE_BASE_URL}/install-agentroam.ps1 | iex`;
132
+ }
133
+ return `Node.js 18+ is required for automatic bootstrap. Run: curl -fsSL ${RELEASE_BASE_URL}/install-agentroam.sh | sh`;
134
+ }
135
+
136
+ function cliError(message) {
137
+ return Object.assign(new Error(message), { exitCode: 2 });
138
+ }
package/dist/args.js CHANGED
@@ -3,10 +3,23 @@ import { resolve } from "node:path";
3
3
  export function parseArgs(argv) {
4
4
  const values = [...argv];
5
5
  let command = "start";
6
- if (values[0] && ["start", "doctor", "version"].includes(values[0]))
6
+ if (values[0] && ["start", "doctor", "version", "service"].includes(values[0])) {
7
7
  command = values.shift();
8
+ }
9
+ let serviceAction = null;
10
+ if (command === "service") {
11
+ const action = values.shift();
12
+ if (!action || !["install", "start", "stop", "status", "url", "logs", "restart", "uninstall"].includes(action)) {
13
+ throw cliError("service requires install, start, stop, status, url, logs, restart, or uninstall");
14
+ }
15
+ serviceAction = action;
16
+ if (serviceAction !== "install" && values.length > 0) {
17
+ throw cliError(`service ${serviceAction} does not accept options`);
18
+ }
19
+ }
8
20
  const options = {
9
21
  command,
22
+ serviceAction,
10
23
  roots: [],
11
24
  port: null,
12
25
  relay: "auto",
package/dist/args.test.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { resolve } from "node:path";
1
2
  import { describe, expect, it } from "vitest";
2
3
  import { parseArgs } from "./args.js";
3
4
  describe("parseArgs", () => {
4
5
  it("uses auto relay and the current directory by default", () => {
5
6
  const value = parseArgs([]);
6
7
  expect(value.command).toBe("start");
8
+ expect(value.serviceAction).toBeNull();
7
9
  expect(value.roots).toEqual([process.cwd()]);
8
10
  expect(value.relay).toBe("auto");
9
11
  });
@@ -24,4 +26,29 @@ describe("parseArgs", () => {
24
26
  expect(() => parseArgs(["--port", "70000"])).toThrow();
25
27
  expect(() => parseArgs(["--relay", "unknown"])).toThrow("relay must be auto");
26
28
  });
29
+ it("parses every service action", () => {
30
+ for (const action of ["install", "start", "stop", "status", "url", "logs", "restart", "uninstall"]) {
31
+ const value = parseArgs(["service", action]);
32
+ expect(value.command).toBe("service");
33
+ expect(value.serviceAction).toBe(action);
34
+ }
35
+ });
36
+ it("parses service install using the existing start options", () => {
37
+ const value = parseArgs([
38
+ "service", "install", "--root", ".", "--root", "..", "--port", "3210", "--relay", "cloudflare",
39
+ "--data-dir", "./service-data", "--no-qr",
40
+ ]);
41
+ expect(value.roots).toHaveLength(2);
42
+ expect(value.port).toBe(3210);
43
+ expect(value.relay).toBe("cloudflare");
44
+ expect(value.dataDir).toBe(resolve(process.cwd(), "service-data"));
45
+ expect(value.qr).toBe(false);
46
+ });
47
+ it("rejects missing service actions and options on non-install actions", () => {
48
+ expect(() => parseArgs(["service"])).toThrow("service requires");
49
+ expect(() => parseArgs(["service", "unknown"])).toThrow("service requires");
50
+ expect(() => parseArgs(["service", "status", "--root", "."])).toThrow("does not accept options");
51
+ expect(() => parseArgs(["service", "start", "--port", "3000"])).toThrow("does not accept options");
52
+ expect(() => parseArgs(["service", "stop", "--local-only"])).toThrow("does not accept options");
53
+ });
27
54
  });
package/dist/cli.js CHANGED
@@ -1,17 +1,31 @@
1
1
  import { createRequire } from "node:module";
2
+ import { fileURLToPath } from "node:url";
2
3
  import { parseArgs } from "./args.js";
3
4
  import { ensureCloudflared } from "./cloudflared/installer.js";
5
+ import { resolveCodexRuntime } from "./codex-runtime-manager.js";
4
6
  import { repairNativeRuntimePermissions } from "./native-runtime.js";
5
7
  import { findLanUrl } from "./network.js";
6
8
  import { createPairingSecret } from "./pairing.js";
7
9
  import { detectPlatform } from "./platform.js";
8
10
  import { AGENTROAM_VERSION, resolvePlatformRuntime, resolvePlatformTui } from "./platform-packages.js";
11
+ import { acquireSleepInhibitor } from "./power/sleep-inhibitor.js";
9
12
  import { renderQr } from "./qr.js";
10
13
  import { RuntimeManager } from "./runtime-manager.js";
14
+ import { ServiceRuntimeReporter } from "./service/runtime-state.js";
15
+ import { runServiceCommand } from "./service/service-command.js";
16
+ import { resolveServicePaths } from "./service/service-files.js";
11
17
  import { selectRelay } from "./tunnel/relay-orchestrator.js";
12
18
  const VERSION = AGENTROAM_VERSION;
13
19
  export async function main(argv) {
14
20
  const options = parseArgs(argv);
21
+ if (options.command === "service") {
22
+ await runServiceCommand(options, {
23
+ nodePath: process.execPath,
24
+ cliPath: fileURLToPath(new URL("../bin/agentroam.mjs", import.meta.url)),
25
+ version: VERSION,
26
+ });
27
+ return;
28
+ }
15
29
  const target = detectPlatform();
16
30
  if (options.command === "version") {
17
31
  console.log(`agentroam ${VERSION}`);
@@ -22,11 +36,15 @@ export async function main(argv) {
22
36
  return;
23
37
  }
24
38
  console.log(`AgentRoam ${VERSION}\n✓ Node ${process.versions.node} · ${target}`);
39
+ const sleepInhibitor = await acquireSleepInhibitor();
25
40
  const pairing = createPairingSecret();
26
41
  const controller = new AbortController();
27
42
  let runtime = null;
28
43
  let relay = null;
29
44
  let closing = false;
45
+ const serviceReporter = process.env.AGENTROAM_SERVICE === "1"
46
+ ? new ServiceRuntimeReporter(resolveServicePaths(undefined, options.dataDir), VERSION)
47
+ : null;
30
48
  const close = async () => {
31
49
  if (closing)
32
50
  return;
@@ -39,6 +57,7 @@ export async function main(argv) {
39
57
  process.once("SIGINT", requestClose);
40
58
  process.once("SIGTERM", requestClose);
41
59
  try {
60
+ await serviceReporter?.starting();
42
61
  runtime = await new RuntimeManager().start(options, pairing, target);
43
62
  console.log(`✓ Local server: ${runtime.localUrl}/web`);
44
63
  const lanUrl = findLanUrl(runtime.port) ?? runtime.localUrl;
@@ -52,6 +71,7 @@ export async function main(argv) {
52
71
  log: (line) => process.stderr.write(`${line}\n`),
53
72
  onAttempt: (provider) => console.log(`▲ Trying ${providerDisplayName(provider)} relay...`),
54
73
  onFailure: (provider, message) => console.error(`⚠ ${providerDisplayName(provider)} unavailable: ${message}`),
74
+ allowLanFallback: serviceReporter === null,
55
75
  });
56
76
  if (controller.signal.aborted)
57
77
  return;
@@ -69,18 +89,39 @@ export async function main(argv) {
69
89
  console.log(" The phone browser may show a one-time Pinggy security confirmation before pairing.");
70
90
  }
71
91
  const accessUrl = `${relay.publicUrl}/web${runtime.needsSetup ? `?pair=${encodeURIComponent(pairing.token)}` : ""}`;
72
- console.log(`\nOpen: ${accessUrl}`);
73
- if (options.qr)
74
- console.log(`\n${await renderQr(accessUrl)}`);
75
- if (runtime.needsSetup)
76
- console.log("First pairing link expires in 5 minutes.");
77
- console.log("Ctrl+C stops the tunnel and local server.");
78
- await Promise.race([runtime.exited, relay.tunnel?.exited ?? new Promise(() => { })]);
92
+ await serviceReporter?.ready({
93
+ localUrl: runtime.localUrl,
94
+ publicUrl: relay.publicUrl,
95
+ accessUrl,
96
+ provider: relay.provider,
97
+ });
98
+ if (serviceReporter) {
99
+ console.log("✓ Background service URL written to the private state file.");
100
+ }
101
+ else {
102
+ console.log(`\nOpen: ${accessUrl}`);
103
+ if (options.qr)
104
+ console.log(`\n${await renderQr(accessUrl)}`);
105
+ if (runtime.needsSetup)
106
+ console.log("First pairing link expires in 5 minutes.");
107
+ console.log("Ctrl+C stops the tunnel and local server.");
108
+ }
109
+ await Promise.race([
110
+ runtime.exited,
111
+ relay.tunnel?.exited ?? new Promise(() => { }),
112
+ sleepInhibitor.lost.then(async (error) => {
113
+ await new Promise((resolveTurn) => setImmediate(resolveTurn));
114
+ if (!closing)
115
+ throw error;
116
+ }),
117
+ ]);
79
118
  }
80
119
  finally {
81
120
  process.removeListener("SIGINT", requestClose);
82
121
  process.removeListener("SIGTERM", requestClose);
83
122
  await close();
123
+ await serviceReporter?.stopped().catch(() => { });
124
+ await sleepInhibitor.release();
84
125
  }
85
126
  }
86
127
  async function doctor(target, dataDir) {
@@ -111,6 +152,17 @@ async function doctor(target, dataDir) {
111
152
  catch (error) {
112
153
  reportDoctorFailure(error, "agent-tui: ");
113
154
  }
155
+ try {
156
+ const codex = await resolveCodexRuntime({
157
+ dataDir,
158
+ target,
159
+ onProgress: (message) => console.log(`… ${message}`),
160
+ });
161
+ console.log(`✓ Codex ${codex.version} (${codex.source}) ${codex.executable}`);
162
+ }
163
+ catch (error) {
164
+ reportDoctorFailure(error, "Codex: ");
165
+ }
114
166
  try {
115
167
  const executable = await ensureCloudflared(target, dataDir);
116
168
  console.log(`✓ cloudflared ${executable}`);
@@ -10,7 +10,7 @@ describe("resolveBundledCloudflared", () => {
10
10
  const archivePath = resolve(root, "cloudflared.tgz");
11
11
  await writeFile(archivePath, "archive");
12
12
  await writeFile(manifestPath, JSON.stringify({
13
- packageVersion: "0.2.0-preview.7",
13
+ packageVersion: "0.2.0-preview.9",
14
14
  upstreamVersion: "2026.8.2",
15
15
  target: "darwin-arm64",
16
16
  assetFormat: "tgz",
@@ -51,7 +51,7 @@ describe("resolveBundledCloudflared", () => {
51
51
  const assetPath = resolve(root, "cloudflared.exe");
52
52
  await writeFile(assetPath, "binary");
53
53
  await writeFile(manifestPath, JSON.stringify({
54
- packageVersion: "0.2.0-preview.7",
54
+ packageVersion: "0.2.0-preview.9",
55
55
  upstreamVersion: "2026.8.2",
56
56
  target: "windows-amd64",
57
57
  assetFormat: "executable",