agentroam 0.2.0-preview.10 → 0.2.0-preview.12

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
@@ -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.10.tgz \
24
- ./agentroam-runtime-darwin-arm64-0.2.0-preview.10.tgz \
25
- ./agentroam-tui-darwin-arm64-0.2.0-preview.10.tgz \
26
- ./agentroam-0.2.0-preview.10.tgz
23
+ npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.12.tgz \
24
+ ./agentroam-runtime-darwin-arm64-0.2.0-preview.12.tgz \
25
+ ./agentroam-tui-darwin-arm64-0.2.0-preview.12.tgz \
26
+ ./agentroam-0.2.0-preview.12.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.10.tgz `
34
- ./agentroam-runtime-win32-x64-0.2.0-preview.10.tgz `
35
- ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.10.tgz `
36
- ./agentroam-0.2.0-preview.10.tgz
33
+ npm install ./agentroam-cloudflared-win32-x64-0.2.0-preview.12.tgz `
34
+ ./agentroam-runtime-win32-x64-0.2.0-preview.12.tgz `
35
+ ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.12.tgz `
36
+ ./agentroam-0.2.0-preview.12.tgz
37
37
  npx agentroam
38
38
  ```
39
39
 
@@ -71,6 +71,9 @@ downloaded only when no installed Node 22 is available.
71
71
  The service keeps running after the terminal closes, restarts after failure, and
72
72
  starts again when the user logs in. macOS uses a user LaunchAgent; Windows uses a
73
73
  current-user Task Scheduler task. Neither requires administrator privileges.
74
+ Running a newer versioned installer automatically stops and replaces an existing
75
+ AgentRoam user service before starting the new version. Session data, pairing
76
+ state, managed runtimes, and service logs are preserved during the upgrade.
74
77
 
75
78
  ```bash
76
79
  agentroam service status
@@ -7,7 +7,7 @@ import { promisify } from "node:util";
7
7
 
8
8
  const execFileAsync = promisify(execFile);
9
9
  const MANAGED_NODE_VERSION = "22.22.0";
10
- const RELEASE_VERSION = "0.2.0-preview.10";
10
+ const RELEASE_VERSION = "0.2.0-preview.12";
11
11
  const RELEASE_BASE_URL = `https://gitee.com/caoqu/team-agent/releases/download/v${RELEASE_VERSION}`;
12
12
  const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
13
13
 
@@ -2,6 +2,7 @@ import { mkdtemp, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
+ import { AGENTROAM_VERSION } from "../platform-packages.js";
5
6
  import { resolveBundledCloudflared } from "./bundled-asset.js";
6
7
  describe("resolveBundledCloudflared", () => {
7
8
  it("resolves a valid platform package manifest and archive", async () => {
@@ -10,7 +11,7 @@ describe("resolveBundledCloudflared", () => {
10
11
  const archivePath = resolve(root, "cloudflared.tgz");
11
12
  await writeFile(archivePath, "archive");
12
13
  await writeFile(manifestPath, JSON.stringify({
13
- packageVersion: "0.2.0-preview.10",
14
+ packageVersion: AGENTROAM_VERSION,
14
15
  upstreamVersion: "2026.8.2",
15
16
  target: "darwin-arm64",
16
17
  assetFormat: "tgz",
@@ -51,7 +52,7 @@ describe("resolveBundledCloudflared", () => {
51
52
  const assetPath = resolve(root, "cloudflared.exe");
52
53
  await writeFile(assetPath, "binary");
53
54
  await writeFile(manifestPath, JSON.stringify({
54
- packageVersion: "0.2.0-preview.10",
55
+ packageVersion: AGENTROAM_VERSION,
55
56
  upstreamVersion: "2026.8.2",
56
57
  target: "windows-amd64",
57
58
  assetFormat: "executable",
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname } from "node:path";
4
- export const AGENTROAM_VERSION = "0.2.0-preview.10";
4
+ export const AGENTROAM_VERSION = "0.2.0-preview.12";
5
5
  export const PLATFORM_PACKAGES = {
6
6
  "darwin-arm64": {
7
7
  runtime: "agentroam-runtime-darwin-arm64",
@@ -26,6 +26,7 @@ export class MacLaunchAgent {
26
26
  async install(config) {
27
27
  await this.validateConfig(config);
28
28
  const paths = resolveServicePaths(this.homeDir, config.dataDir);
29
+ const previousState = await readServiceState(paths);
29
30
  await Promise.all([
30
31
  mkdir(paths.launchAgentsDir, { recursive: true }),
31
32
  ensurePrivateDirectory(paths.controlDir),
@@ -40,8 +41,10 @@ export class MacLaunchAgent {
40
41
  await this.runRequired("plutil", ["-convert", "xml1", "-o", plistTempPath, jsonPath]);
41
42
  await this.runRequired("plutil", ["-lint", plistTempPath]);
42
43
  await chmod(plistTempPath, 0o600);
43
- if ((await this.inspectJob()).loaded)
44
+ if ((await this.inspectJob()).loaded) {
44
45
  await this.runRequired("launchctl", ["bootout", this.serviceTarget]);
46
+ await this.waitForServiceExit(previousState?.pid);
47
+ }
45
48
  await rename(plistTempPath, paths.plistPath);
46
49
  await chmod(paths.plistPath, 0o600);
47
50
  await writePrivateJson(paths.configPath, config);
@@ -203,6 +206,21 @@ export class MacLaunchAgent {
203
206
  await delay(this.pollIntervalMs);
204
207
  }
205
208
  }
209
+ async waitForServiceExit(pid) {
210
+ const deadline = Date.now() + this.stopTimeoutMs;
211
+ do {
212
+ const processRunning = pid ? this.processExists(pid) : false;
213
+ const job = await this.inspectJob();
214
+ if (!processRunning && !job.loaded)
215
+ return;
216
+ if (Date.now() >= deadline) {
217
+ if (processRunning)
218
+ throw new Error(`service process ${pid} did not stop after launchctl bootout`);
219
+ throw new Error(`service ${SERVICE_LABEL} did not unload after launchctl bootout`);
220
+ }
221
+ await delay(this.pollIntervalMs);
222
+ } while (true);
223
+ }
206
224
  }
207
225
  export function buildLaunchAgentPlist(config, paths) {
208
226
  const environmentVariables = {
@@ -59,6 +59,125 @@ describe("MacLaunchAgent", () => {
59
59
  expect((await stat(paths.plistPath)).mode & 0o777).toBe(0o600);
60
60
  expect(JSON.parse(await readFile(paths.configPath, "utf8"))).toMatchObject({ roots: value.roots });
61
61
  });
62
+ it("waits for the previous launchd job and process before bootstrapping a replacement", async () => {
63
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-reinstall-"));
64
+ const paths = resolveServicePaths(home);
65
+ const value = config(home, paths.dataDir);
66
+ await createConfigFiles(value);
67
+ await mkdir(paths.launchAgentsDir, { recursive: true });
68
+ await writeFile(paths.plistPath, "old plist");
69
+ await writePrivateJson(paths.configPath, { ...value, version: "0.2.0-preview.8" });
70
+ await writePrivateJson(paths.statePath, {
71
+ status: "ready", pid: 700, version: "0.2.0-preview.8", startedAt: "x", updatedAt: "x",
72
+ });
73
+ await writePrivateText(resolve(paths.dataDir, "data/keep.txt"), "keep");
74
+ await writePrivateText(paths.stdoutPath, "keep logs");
75
+ let loaded = true;
76
+ let processChecks = 0;
77
+ const lifecycle = [];
78
+ const runner = async (command, args) => {
79
+ if (command === "plutil" && args[0] === "-convert") {
80
+ await writeFile(args[3], "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
81
+ }
82
+ if (command === "launchctl" && args[0] === "print") {
83
+ return { code: loaded ? 0 : 1, stdout: loaded ? "state = running" : "", stderr: "" };
84
+ }
85
+ if (command === "launchctl" && args[0] === "bootout")
86
+ lifecycle.push("bootout");
87
+ if (command === "launchctl" && args[0] === "bootstrap") {
88
+ lifecycle.push("bootstrap");
89
+ await writePrivateJson(paths.statePath, {
90
+ status: "ready", pid: 701, version: value.version, startedAt: "y", updatedAt: "y",
91
+ accessUrl: "https://ready.example/web",
92
+ });
93
+ }
94
+ return { code: 0, stdout: "", stderr: "" };
95
+ };
96
+ const processExists = vi.fn(() => {
97
+ processChecks++;
98
+ if (processChecks === 1)
99
+ return true;
100
+ loaded = false;
101
+ lifecycle.push("process-exit");
102
+ return false;
103
+ });
104
+ const result = await new MacLaunchAgent({
105
+ homeDir: home,
106
+ uid: 501,
107
+ runner,
108
+ processExists,
109
+ readyTimeoutMs: 20,
110
+ stopTimeoutMs: 50,
111
+ pollIntervalMs: 1,
112
+ }).install(value);
113
+ expect(result.state).toMatchObject({ status: "ready", pid: 701 });
114
+ expect(lifecycle).toEqual(["bootout", "process-exit", "bootstrap"]);
115
+ expect(processExists).toHaveBeenCalledWith(700);
116
+ expect(await readFile(resolve(paths.dataDir, "data/keep.txt"), "utf8")).toBe("keep");
117
+ expect(await readFile(paths.stdoutPath, "utf8")).toBe("keep logs");
118
+ });
119
+ it("waits for launchd to remove a previous job when no runtime pid is available", async () => {
120
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-reinstall-no-pid-"));
121
+ const paths = resolveServicePaths(home);
122
+ const value = config(home, paths.dataDir);
123
+ await createConfigFiles(value);
124
+ let printCount = 0;
125
+ let bootedOut = false;
126
+ let bootstrapPrintCount = 0;
127
+ const runner = async (command, args) => {
128
+ if (command === "plutil" && args[0] === "-convert") {
129
+ await writeFile(args[3], "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
130
+ }
131
+ if (command === "launchctl" && args[0] === "print") {
132
+ printCount++;
133
+ const loaded = !bootedOut || printCount < 3;
134
+ return { code: loaded ? 0 : 1, stdout: loaded ? "state = exited" : "", stderr: "" };
135
+ }
136
+ if (command === "launchctl" && args[0] === "bootout")
137
+ bootedOut = true;
138
+ if (command === "launchctl" && args[0] === "bootstrap") {
139
+ bootstrapPrintCount = printCount;
140
+ await writePrivateJson(paths.statePath, {
141
+ status: "ready", pid: 701, version: value.version, startedAt: "y", updatedAt: "y",
142
+ });
143
+ }
144
+ return { code: 0, stdout: "", stderr: "" };
145
+ };
146
+ await new MacLaunchAgent({
147
+ homeDir: home,
148
+ uid: 501,
149
+ runner,
150
+ readyTimeoutMs: 20,
151
+ stopTimeoutMs: 50,
152
+ pollIntervalMs: 1,
153
+ }).install(value);
154
+ expect(bootstrapPrintCount).toBeGreaterThanOrEqual(3);
155
+ });
156
+ it("does not bootstrap while the previous launchd job is still loaded", async () => {
157
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-reinstall-timeout-"));
158
+ const paths = resolveServicePaths(home);
159
+ const value = config(home, paths.dataDir);
160
+ await createConfigFiles(value);
161
+ const calls = [];
162
+ const runner = async (command, args) => {
163
+ calls.push(`${command} ${args[0] ?? ""}`);
164
+ if (command === "plutil" && args[0] === "-convert") {
165
+ await writeFile(args[3], "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
166
+ }
167
+ if (command === "launchctl" && args[0] === "print") {
168
+ return { code: 0, stdout: "state = exited", stderr: "" };
169
+ }
170
+ return { code: 0, stdout: "", stderr: "" };
171
+ };
172
+ await expect(new MacLaunchAgent({
173
+ homeDir: home,
174
+ uid: 501,
175
+ runner,
176
+ stopTimeoutMs: 0,
177
+ pollIntervalMs: 1,
178
+ }).install(value)).rejects.toThrow("did not unload after launchctl bootout");
179
+ expect(calls).not.toContain("launchctl bootstrap");
180
+ });
62
181
  it("reports ready URLs only while the installed job is running", async () => {
63
182
  const home = await mkdtemp(resolve(tmpdir(), "agentroam-launch-agent-url-"));
64
183
  const paths = resolveServicePaths(home);
@@ -1,11 +1,20 @@
1
1
  import { delimiter, dirname, isAbsolute, resolve, win32 } from "node:path";
2
2
  import { resolveCodexRuntime } from "../codex-runtime-manager.js";
3
3
  import { detectPlatform } from "../platform.js";
4
+ import { renderQr } from "../qr.js";
4
5
  import { MacLaunchAgent } from "./macos-launch-agent.js";
5
6
  import { WindowsTaskService } from "./windows-task-service.js";
6
7
  export async function runServiceCommand(options, context) {
7
8
  const platform = context.platform ?? process.platform;
8
9
  const log = context.log ?? console.log;
10
+ const stdoutIsTTY = context.isTTY ?? process.stdout.isTTY === true;
11
+ const logAccessUrl = async (accessUrl) => {
12
+ log(`Open: ${accessUrl}`);
13
+ if (!options.qr || !stdoutIsTTY)
14
+ return;
15
+ log("");
16
+ log(await renderQr(accessUrl));
17
+ };
9
18
  const controller = context.controller ?? context.launchAgent ?? createController(platform, context.homeDir);
10
19
  switch (options.serviceAction) {
11
20
  case "install": {
@@ -49,7 +58,7 @@ export async function runServiceCommand(options, context) {
49
58
  const { definition, state } = await controller.install(config);
50
59
  log(`✓ AgentRoam service installed: ${definition}`);
51
60
  if (state?.status === "ready" && state.accessUrl)
52
- log(`Open: ${state.accessUrl}`);
61
+ await logAccessUrl(state.accessUrl);
53
62
  else
54
63
  log("Service is starting. Run `agentroam service url` shortly.");
55
64
  return;
@@ -58,7 +67,7 @@ export async function runServiceCommand(options, context) {
58
67
  const state = await controller.start();
59
68
  log("✓ AgentRoam service started");
60
69
  if (state?.status === "ready" && state.accessUrl)
61
- log(`Open: ${state.accessUrl}`);
70
+ await logAccessUrl(state.accessUrl);
62
71
  return;
63
72
  }
64
73
  case "stop":
@@ -77,7 +86,7 @@ export async function runServiceCommand(options, context) {
77
86
  log(`Root: ${status.config.roots.join(", ")}`);
78
87
  }
79
88
  if (status.running && status.state?.status === "ready" && status.state.accessUrl) {
80
- log(`Open: ${status.state.accessUrl}`);
89
+ await logAccessUrl(status.state.accessUrl);
81
90
  }
82
91
  return;
83
92
  }
@@ -98,7 +107,7 @@ export async function runServiceCommand(options, context) {
98
107
  const state = await controller.restart();
99
108
  log("✓ AgentRoam service restarted");
100
109
  if (state?.status === "ready" && state.accessUrl)
101
- log(`Open: ${state.accessUrl}`);
110
+ await logAccessUrl(state.accessUrl);
102
111
  return;
103
112
  }
104
113
  case "uninstall": {
@@ -33,6 +33,48 @@ describe("runServiceCommand", () => {
33
33
  }));
34
34
  expect(log).toHaveBeenCalledWith("Open: https://ready.example/web");
35
35
  });
36
+ it("prints a scannable QR code after the URL on interactive terminals", async () => {
37
+ const log = vi.fn();
38
+ await runServiceCommand({ ...options("start"), qr: true }, {
39
+ platform: "darwin",
40
+ nodePath: "/node",
41
+ cliPath: "/agentroam.mjs",
42
+ version: "test",
43
+ isTTY: true,
44
+ log,
45
+ controller: controller({ start: vi.fn(async () => ({ status: "ready", accessUrl: "https://ready.example/web" })) }),
46
+ });
47
+ const openIndex = log.mock.calls.findIndex(([line]) => line === "Open: https://ready.example/web");
48
+ expect(openIndex).toBeGreaterThanOrEqual(0);
49
+ const qr = log.mock.calls[openIndex + 2]?.[0];
50
+ expect(qr).toMatch(/[▀▄█]/);
51
+ expect(qr).toContain("\n");
52
+ });
53
+ it("keeps service output QR-free when piped or when --no-qr is set", async () => {
54
+ const log = vi.fn();
55
+ const start = vi.fn(async () => ({ status: "ready", accessUrl: "https://ready.example/web" }));
56
+ await runServiceCommand({ ...options("start"), qr: true }, {
57
+ platform: "darwin",
58
+ nodePath: "/node",
59
+ cliPath: "/agentroam.mjs",
60
+ version: "test",
61
+ isTTY: false,
62
+ log,
63
+ controller: controller({ start }),
64
+ });
65
+ await runServiceCommand(options("start"), {
66
+ platform: "darwin",
67
+ nodePath: "/node",
68
+ cliPath: "/agentroam.mjs",
69
+ version: "test",
70
+ isTTY: true,
71
+ log,
72
+ controller: controller({ start }),
73
+ });
74
+ const lines = log.mock.calls.map(([line]) => line);
75
+ expect(lines).toContain("Open: https://ready.example/web");
76
+ expect(lines.some((line) => /[▀▄█]/.test(String(line)))).toBe(false);
77
+ });
36
78
  it("pins the selected Node directory ahead of the inherited service PATH", () => {
37
79
  expect(buildServiceEnvironmentPath("/Users/test/.nvm/versions/node/v22.22.2/bin/node", "/usr/bin:/Users/test/.nvm/versions/node/v22.22.2/bin", "darwin")).toBe("/Users/test/.nvm/versions/node/v22.22.2/bin:/usr/bin:/bin:/usr/sbin:/sbin");
38
80
  });
@@ -26,12 +26,21 @@ export class WindowsTaskService {
26
26
  const hostPath = resolveWindowsServiceHost(config.cliPath);
27
27
  await this.validate(config, hostPath);
28
28
  const paths = resolveServicePaths(this.homeDir, config.dataDir);
29
+ const previousTask = await this.inspectTask();
30
+ const previousState = await readServiceState(paths);
29
31
  await Promise.all([
30
32
  ensurePrivateDirectory(paths.controlDir),
31
33
  ensurePrivateDirectory(paths.logsDir),
32
34
  ensurePrivateFile(paths.stdoutPath),
33
35
  ensurePrivateFile(paths.stderrPath),
34
36
  ]);
37
+ if (previousTask.running) {
38
+ await this.runRequired(buildStopTaskScript());
39
+ if (previousState?.pid)
40
+ await this.waitForProcessExit(previousState.pid);
41
+ }
42
+ if (previousTask.installed)
43
+ await this.runRequired(buildUnregisterTaskScript());
35
44
  await writePrivateJson(paths.configPath, config);
36
45
  await Promise.all([removeIfExists(paths.statePath), removeIfExists(paths.urlPath)]);
37
46
  await this.runRequired(buildRegisterTaskScript(config, hostPath, paths.configPath));
@@ -1,7 +1,7 @@
1
1
  import { mkdtemp, mkdir, readFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { resolve } from "node:path";
4
- import { describe, expect, it } from "vitest";
4
+ import { describe, expect, it, vi } from "vitest";
5
5
  import { resolveServicePaths, writePrivateText } from "./service-files.js";
6
6
  import { WindowsTaskService, buildRegisterTaskScript, quoteWindowsCommandLineArgument, resolveWindowsServiceHost, } from "./windows-task-service.js";
7
7
  describe("WindowsTaskService", () => {
@@ -68,6 +68,141 @@ describe("WindowsTaskService", () => {
68
68
  expect(scripts.some((script) => script.includes("New-ScheduledTaskPrincipal") && script.includes("RunLevel Limited"))).toBe(true);
69
69
  expect(scripts.some((script) => script.includes("cmd.exe") || script.includes("ES_DISPLAY_REQUIRED"))).toBe(false);
70
70
  });
71
+ it("stops and unregisters a running task before installing its replacement", async () => {
72
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-windows-task-reinstall-"));
73
+ const paths = resolveServicePaths(home, resolve(home, "data"));
74
+ const value = config(home, paths.dataDir);
75
+ await mkdir(value.roots[0], { recursive: true });
76
+ await writePrivateText(paths.configPath, JSON.stringify({ ...value, version: "0.2.0-preview.8" }));
77
+ await writePrivateText(paths.statePath, JSON.stringify({
78
+ status: "ready", pid: 800, version: "0.2.0-preview.8", startedAt: "x", updatedAt: "x",
79
+ }));
80
+ await writePrivateText(resolve(paths.dataDir, "data/keep.txt"), "keep");
81
+ await writePrivateText(paths.stdoutPath, "keep logs");
82
+ let installed = true;
83
+ let running = true;
84
+ let processChecks = 0;
85
+ const lifecycle = [];
86
+ const runner = async (_command, args) => {
87
+ const script = Buffer.from(args.at(-1), "base64").toString("utf16le");
88
+ if (script.includes("Get-ScheduledTask")) {
89
+ return { code: 0, stdout: JSON.stringify({ installed, running }), stderr: "" };
90
+ }
91
+ if (script.includes("Stop-ScheduledTask")) {
92
+ lifecycle.push("stop");
93
+ running = false;
94
+ }
95
+ if (script.includes("Unregister-ScheduledTask")) {
96
+ lifecycle.push("unregister");
97
+ installed = false;
98
+ }
99
+ if (script.includes("Register-ScheduledTask")) {
100
+ lifecycle.push("register");
101
+ installed = true;
102
+ }
103
+ if (script.includes("Start-ScheduledTask")) {
104
+ lifecycle.push("start");
105
+ running = true;
106
+ await writePrivateText(paths.statePath, JSON.stringify({
107
+ status: "ready", pid: 801, version: value.version, startedAt: "y", updatedAt: "y",
108
+ }));
109
+ }
110
+ return { code: 0, stdout: "", stderr: "" };
111
+ };
112
+ const processExists = vi.fn(() => {
113
+ processChecks++;
114
+ if (processChecks === 1)
115
+ return true;
116
+ lifecycle.push("process-exit");
117
+ return false;
118
+ });
119
+ const result = await new WindowsTaskService({
120
+ homeDir: home,
121
+ runner,
122
+ processExists,
123
+ validateConfig: async () => undefined,
124
+ readyTimeoutMs: 20,
125
+ stopTimeoutMs: 50,
126
+ pollIntervalMs: 1,
127
+ }).install(value);
128
+ expect(result.state).toMatchObject({ status: "ready", pid: 801 });
129
+ expect(lifecycle).toEqual(["stop", "process-exit", "unregister", "register", "start"]);
130
+ expect(processExists).toHaveBeenCalledWith(800);
131
+ expect(await readFile(resolve(paths.dataDir, "data/keep.txt"), "utf8")).toBe("keep");
132
+ expect(await readFile(paths.stdoutPath, "utf8")).toBe("keep logs");
133
+ });
134
+ it("replaces an installed stopped task without stopping a process", async () => {
135
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-windows-task-reinstall-stopped-"));
136
+ const paths = resolveServicePaths(home, resolve(home, "data"));
137
+ const value = config(home, paths.dataDir);
138
+ await mkdir(value.roots[0], { recursive: true });
139
+ const lifecycle = [];
140
+ let installed = true;
141
+ const runner = async (_command, args) => {
142
+ const script = Buffer.from(args.at(-1), "base64").toString("utf16le");
143
+ if (script.includes("Get-ScheduledTask")) {
144
+ return { code: 0, stdout: JSON.stringify({ installed, running: false }), stderr: "" };
145
+ }
146
+ if (script.includes("Stop-ScheduledTask"))
147
+ lifecycle.push("stop");
148
+ if (script.includes("Unregister-ScheduledTask")) {
149
+ lifecycle.push("unregister");
150
+ installed = false;
151
+ }
152
+ if (script.includes("Register-ScheduledTask")) {
153
+ lifecycle.push("register");
154
+ installed = true;
155
+ }
156
+ if (script.includes("Start-ScheduledTask")) {
157
+ lifecycle.push("start");
158
+ await writePrivateText(paths.statePath, JSON.stringify({
159
+ status: "ready", pid: 801, version: value.version, startedAt: "y", updatedAt: "y",
160
+ }));
161
+ }
162
+ return { code: 0, stdout: "", stderr: "" };
163
+ };
164
+ await new WindowsTaskService({
165
+ homeDir: home,
166
+ runner,
167
+ processExists: vi.fn(() => false),
168
+ validateConfig: async () => undefined,
169
+ readyTimeoutMs: 20,
170
+ pollIntervalMs: 1,
171
+ }).install(value);
172
+ expect(lifecycle).toEqual(["unregister", "register", "start"]);
173
+ });
174
+ it("does not replace a running task while its previous process is still alive", async () => {
175
+ const home = await mkdtemp(resolve(tmpdir(), "agentroam-windows-task-reinstall-timeout-"));
176
+ const paths = resolveServicePaths(home, resolve(home, "data"));
177
+ const value = config(home, paths.dataDir);
178
+ await mkdir(value.roots[0], { recursive: true });
179
+ await writePrivateText(paths.statePath, JSON.stringify({
180
+ status: "ready", pid: 800, version: "0.2.0-preview.8", startedAt: "x", updatedAt: "x",
181
+ }));
182
+ const lifecycle = [];
183
+ const runner = async (_command, args) => {
184
+ const script = Buffer.from(args.at(-1), "base64").toString("utf16le");
185
+ if (script.includes("Get-ScheduledTask")) {
186
+ return { code: 0, stdout: JSON.stringify({ installed: true, running: true }), stderr: "" };
187
+ }
188
+ if (script.includes("Stop-ScheduledTask"))
189
+ lifecycle.push("stop");
190
+ if (script.includes("Unregister-ScheduledTask"))
191
+ lifecycle.push("unregister");
192
+ if (script.includes("Register-ScheduledTask"))
193
+ lifecycle.push("register");
194
+ return { code: 0, stdout: "", stderr: "" };
195
+ };
196
+ await expect(new WindowsTaskService({
197
+ homeDir: home,
198
+ runner,
199
+ processExists: () => true,
200
+ validateConfig: async () => undefined,
201
+ stopTimeoutMs: 0,
202
+ pollIntervalMs: 1,
203
+ }).install(value)).rejects.toThrow("did not stop after task termination");
204
+ expect(lifecycle).toEqual(["stop"]);
205
+ });
71
206
  it("builds a safely quoted task action for paths with spaces and apostrophes", () => {
72
207
  const value = config("C:\\Users\\O'Brien", "C:\\Users\\O'Brien\\.agentroam");
73
208
  const host = resolveWindowsServiceHost(value.cliPath);
@@ -22,7 +22,7 @@ export async function waitForPublicReadiness(publicUrl, options = {}) {
22
22
  try {
23
23
  const response = await fetchImpl(endpoint, {
24
24
  cache: "no-store",
25
- headers: { "user-agent": "agentroam-readiness/0.2.0-preview.10" },
25
+ headers: { "user-agent": "agentroam-readiness/0.2.0-preview.12" },
26
26
  signal: options.signal,
27
27
  });
28
28
  if (response.ok) {
package/install/README.md CHANGED
@@ -5,7 +5,7 @@ These versioned installers support macOS Apple Silicon and Windows x64. They reu
5
5
  ## macOS Apple Silicon
6
6
 
7
7
  ```sh
8
- curl -fsSL https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.10/install-agentroam.sh | sh
8
+ curl -fsSL https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.12/install-agentroam.sh | sh
9
9
  ```
10
10
 
11
11
  The command installs the wrapper at `~/.local/bin/agentroam`. If that directory is not already in `PATH`, the installer prints the required shell setting without editing shell profiles.
@@ -13,7 +13,7 @@ The command installs the wrapper at `~/.local/bin/agentroam`. If that directory
13
13
  ## Windows x64
14
14
 
15
15
  ```powershell
16
- irm https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.10/install-agentroam.ps1 | iex
16
+ irm https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.12/install-agentroam.ps1 | iex
17
17
  ```
18
18
 
19
19
  The command installs `%USERPROFILE%\.agentroam\bin\agentroam.cmd` and adds only that user directory to the user-level `PATH`. It does not require administrator access or change the machine-level `PATH`.
@@ -22,7 +22,9 @@ The command installs `%USERPROFILE%\.agentroam\bin\agentroam.cmd` and adds only
22
22
 
23
23
  Run the installer from the project directory AgentRoam should expose, or set `AGENTROAM_ROOT` explicitly. An installer launched from the home directory without an explicit root stops before service registration so it cannot expose the entire home directory accidentally.
24
24
 
25
- The installer registers and starts the service after Node, CLI, and `doctor` validation. While foreground AgentRoam or the service is running, it prevents idle system sleep but still allows the display to dim and turn off. Lid close, explicit sleep, hibernation, shutdown, and low-battery forced sleep remain controlled by the operating system.
25
+ The installer registers and starts the service after Node, CLI, and `doctor` validation. When a previous AgentRoam user service is installed, the new CLI stops it, waits for its process and platform registration to exit, removes only the old service registration, and then registers the replacement. Session data, pairing state, managed runtimes, and logs remain in the data directory. While foreground AgentRoam or the service is running, it prevents idle system sleep but still allows the display to dim and turn off. Lid close, explicit sleep, hibernation, shutdown, and low-battery forced sleep remain controlled by the operating system.
26
+
27
+ On interactive terminals, `service install`, `start`, `status`, and `restart` print a scannable QR code beside the `Open:` URL. Piped or redirected output stays plain text, and `agentroam service install --no-qr` suppresses the code. `service url` always prints only the raw URL for scripting.
26
28
 
27
29
  Use the installer for first installation, upgrades, and repair. Use these commands for routine control:
28
30
 
@@ -38,7 +40,7 @@ agentroam service uninstall
38
40
 
39
41
  ## Data and updates
40
42
 
41
- Set `AGENTROAM_DATA_DIR` before running either installer to override the default `~/.agentroam` data directory. Node is stored at `<data-dir>/runtimes/node/22.22.0`, and the launcher is stored at `<data-dir>/launcher/0.2.0-preview.10`.
43
+ Set `AGENTROAM_DATA_DIR` before running either installer to override the default `~/.agentroam` data directory. Node is stored at `<data-dir>/runtimes/node/22.22.0`, and the launcher is stored at `<data-dir>/launcher/0.2.0-preview.12`.
42
44
 
43
45
  Each AgentRoam release pins its own Node patch and installer assets. Updates happen only by running a newer versioned installer or installing a newer AgentRoam release; startup never follows a moving Node.js release channel.
44
46
 
@@ -2,7 +2,7 @@ $ErrorActionPreference = "Stop"
2
2
  $ProgressPreference = "SilentlyContinue"
3
3
 
4
4
  $NodeVersion = "22.22.0"
5
- $AgentRoamVersion = "0.2.0-preview.10"
5
+ $AgentRoamVersion = "0.2.0-preview.12"
6
6
  $NodeArchive = "node-v22.22.0-win-x64.zip"
7
7
  $NodeSha256 = "c97fa376d2becdc8863fcd3ca2dd9a83a9f3468ee7ccf7a6d076ec66a645c77a"
8
8
  $NodeUrl = "https://nodejs.org/dist/v22.22.0/$NodeArchive"
@@ -2,7 +2,7 @@
2
2
  set -eu
3
3
 
4
4
  NODE_VERSION="22.22.0"
5
- AGENTROAM_VERSION="0.2.0-preview.10"
5
+ AGENTROAM_VERSION="0.2.0-preview.12"
6
6
  NODE_ARCHIVE="node-v22.22.0-darwin-arm64.tar.xz"
7
7
  NODE_SHA256="2bd596bbfc4a275ceb8721a5954ee97daea5ebe673e96a185ebd732f6fb023ac"
8
8
  NODE_URL="https://nodejs.org/dist/v22.22.0/$NODE_ARCHIVE"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentroam",
3
- "version": "0.2.0-preview.10",
3
+ "version": "0.2.0-preview.12",
4
4
  "description": "Zero-install mobile remote terminal and file console",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,12 +26,12 @@
26
26
  "undici": "^6.25.0"
27
27
  },
28
28
  "optionalDependencies": {
29
- "agentroam-runtime-darwin-arm64": "0.2.0-preview.10",
30
- "agentroam-runtime-win32-x64": "0.2.0-preview.10",
31
- "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.10",
32
- "agentroam-cloudflared-win32-x64": "0.2.0-preview.10",
33
- "agentroam-tui-darwin-arm64": "0.2.0-preview.10",
34
- "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.10"
29
+ "agentroam-runtime-darwin-arm64": "0.2.0-preview.12",
30
+ "agentroam-runtime-win32-x64": "0.2.0-preview.12",
31
+ "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.12",
32
+ "agentroam-cloudflared-win32-x64": "0.2.0-preview.12",
33
+ "agentroam-tui-darwin-arm64": "0.2.0-preview.12",
34
+ "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.12"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",