agentroam 0.2.0-preview.36 → 0.2.0-preview.38

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.
@@ -1,49 +1,26 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { createReadStream, openSync } from "node:fs";
4
- import { readdir, readFile } from "node:fs/promises";
4
+ import { readFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
7
  import { renderQr } from "./qr.js";
8
+ import { localServiceRequest, LocalServiceHttpError } from "./local-service-request.js";
9
+ import { withCliProgress, withoutCliProgress } from "../bin/cli-progress.mjs";
8
10
  /** Resolve a private local descriptor; never send the administrative token to a tunnel URL. */
9
11
  export async function pairingAdmin(dataDir, action, body, options = {}) {
10
- const request = options.request ?? fetch;
11
- const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
12
- const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
13
- return []; throw error; });
14
- const candidates = [];
15
- for (const name of names.filter((name) => name.endsWith(".json"))) {
16
- try {
17
- const d = JSON.parse(await readFile(join(directory, name), "utf8"));
18
- const url = new URL(d.url);
19
- if (d.protocol !== 1 || typeof d.instanceId !== "string" || !Number.isSafeInteger(d.pid) || d.pid <= 0 || ![resolve(dataDir), resolve(dataDir, "data")].includes(d.dataDir) || !/^[a-f0-9]{64}$/.test(d.token))
20
- continue;
21
- if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
22
- continue;
23
- const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
24
- if (!response.ok)
25
- continue;
26
- const identity = await response.json();
27
- if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
28
- candidates.push(d);
29
- }
30
- catch { /* Stale descriptors are expected after a process crash. */ }
31
- }
32
- if (candidates.length !== 1)
33
- throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
34
- const d = candidates[0];
35
12
  const readOnly = ["devices", "requests", "audit"].includes(action);
36
- const response = await request(new URL(`/api/pairing/admin/${action}`, d.url), {
37
- method: readOnly ? "GET" : "POST",
38
- headers: { "x-agentroam-desktop-token": d.token, "content-type": "application/json" },
39
- ...(readOnly ? {} : { body: JSON.stringify(body ?? {}) }),
40
- redirect: "error", signal: AbortSignal.timeout(5000),
41
- });
42
- if (!response.ok) {
43
- const message = response.status === 423 ? "远程访问已锁定,请先执行 agentroam unlock" : response.status === 410 ? "请求已过期或已处理" : response.status === 409 ? "核对短语不匹配" : `设备授权操作失败 (HTTP ${response.status})`;
13
+ try {
14
+ return await localServiceRequest(dataDir, `/api/pairing/admin/${action}`, {
15
+ ...options, method: readOnly ? "GET" : "POST", ...(readOnly ? {} : { body: body ?? {} }),
16
+ });
17
+ }
18
+ catch (error) {
19
+ if (!(error instanceof LocalServiceHttpError))
20
+ throw error;
21
+ const message = error.status === 423 ? "远程访问已锁定,请先执行 agentroam unlock" : error.status === 410 ? "请求已过期或已处理" : error.status === 409 ? "核对短语不匹配" : `设备授权操作失败 (HTTP ${error.status})`;
44
22
  throw new Error(message);
45
23
  }
46
- return response.json();
47
24
  }
48
25
  export function terminalText(value) { return value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ""); }
49
26
  export function pairingQrPayload(accessUrl, grant) {
@@ -53,7 +30,7 @@ export function pairingQrPayload(accessUrl, grant) {
53
30
  return `${url.origin}/pair#pair=${grant}`;
54
31
  }
55
32
  export async function printPairingCode(dataDir, log = console.log, options = {}) {
56
- const result = await pairingAdmin(dataDir, "code");
33
+ const result = await withCliProgress("正在生成设备配对码", () => pairingAdmin(dataDir, "code"));
57
34
  log(`配对码:${result.code.slice(0, 4)} ${result.code.slice(4)}`);
58
35
  log("5 分钟内有效,仅可使用一次。手机输入后仍需在电脑上确认核对短语。");
59
36
  if ((options.qr ?? true) && (options.interactive ?? process.stdout.isTTY)) {
@@ -61,16 +38,19 @@ export async function printPairingCode(dataDir, log = console.log, options = {})
61
38
  if (accessUrl) {
62
39
  // Validate the public address before minting an invitation. Never persist the grant.
63
40
  pairingQrPayload(accessUrl, "a".repeat(43));
64
- const { grant } = await pairingAdmin(dataDir, "qr");
41
+ const qr = await withCliProgress("正在生成手机连接二维码", async () => {
42
+ const { grant } = await pairingAdmin(dataDir, "qr");
43
+ return renderQr(pairingQrPayload(accessUrl, grant));
44
+ });
65
45
  log("手机扫码直接连接(5 分钟、一次有效,请勿分享截图):");
66
- log(await renderQr(pairingQrPayload(accessUrl, grant)));
46
+ log(qr);
67
47
  }
68
48
  else
69
49
  log("生成 App 授权二维码:agentroam pair --url <手机可访问的服务器地址>");
70
50
  }
71
51
  const approvalInput = resolveApprovalInput(options);
72
52
  if (approvalInput) {
73
- await watchPairingApproval(dataDir, result.expiresAt, { log, signal: options.signal, input: approvalInput });
53
+ await withCliProgress("正在等待手机扫码或输入配对码", () => watchPairingApproval(dataDir, result.expiresAt, { log, signal: options.signal, input: approvalInput }));
74
54
  }
75
55
  else
76
56
  log("查看请求:agentroam approvals;批准:agentroam approve <请求ID> --phrase <手机核对短语>");
@@ -127,7 +107,7 @@ export async function watchPairingApproval(dataDir, codeExpiresAt, options = {})
127
107
  log(`请求 ID:${request.id}`);
128
108
  log(`核对短语:${request.phrase}`);
129
109
  const confirm = options.confirm ?? ((request, signal) => confirmOnTerminal(request, signal, options.input));
130
- const approved = await confirm(request, options.signal);
110
+ const approved = await withoutCliProgress(() => confirm(request, options.signal));
131
111
  if (options.signal?.aborted)
132
112
  return;
133
113
  if (Date.now() >= request.expires) {
@@ -1,4 +1,5 @@
1
- import { readdir, readFile, mkdir, writeFile, rm, realpath } from "node:fs/promises";
1
+ import { readdir, readFile, mkdir, writeFile, rm, realpath, rename } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join, resolve } from "node:path";
4
5
  import { setTimeout as delay } from "node:timers/promises";
@@ -68,8 +69,11 @@ export async function stopPreviousInstances(dataDir, log, dependencies = {}) {
68
69
  return []; throw error; });
69
70
  for (const name of names.filter(name => name.endsWith(".json"))) {
70
71
  let d;
72
+ let source;
73
+ const recordPath = join(registry, name);
71
74
  try {
72
- d = JSON.parse(await readFile(join(registry, name), "utf8"));
75
+ source = await readFile(recordPath, "utf8");
76
+ d = JSON.parse(source);
73
77
  if (d.protocol !== 1 || !Number.isSafeInteger(d.pid) || d.pid <= 1 || typeof d.instanceId !== "string" || !/^[a-f0-9]{64}$/.test(d.token))
74
78
  continue;
75
79
  const actual = await realpath(d.dataDir);
@@ -90,8 +94,25 @@ export async function stopPreviousInstances(dataDir, log, dependencies = {}) {
90
94
  identity = await response.json();
91
95
  }
92
96
  catch {
93
- if ((dependencies.alive ?? alive)(d.pid))
94
- throw new Error("同一数据目录的旧实例无法核验,请先停止旧实例再重试");
97
+ if ((dependencies.alive ?? alive)(d.pid)) {
98
+ const processes = dependencies.processes ?? processList;
99
+ const owner = (await processes()).find(p => p.pid === d.pid);
100
+ // An unavailable gateway may still own live sessions. Only quarantine
101
+ // when the PID demonstrably belongs to a different kind of process.
102
+ if (!owner?.command.trim() || /[\\/]ws-server\.mjs(?:["\s]|$)|[\\/]agentroam[\\/]bin[\\/]agentroam\.mjs(?:["\s]|$)/.test(owner.command)) {
103
+ throw new Error("同一数据目录的旧实例无法核验,请先停止旧实例再重试");
104
+ }
105
+ const current = (await processes()).find(p => p.pid === d.pid);
106
+ if (!current || current.ppid !== owner.ppid || current.command !== owner.command) {
107
+ throw new Error("旧实例身份已变化,请重试");
108
+ }
109
+ if (await readFile(recordPath, "utf8") !== source)
110
+ throw new Error("旧实例记录已变化,请重试");
111
+ const backupDir = join(registry, "stale");
112
+ await mkdir(backupDir, { recursive: true, mode: 0o700 });
113
+ await rename(recordPath, join(backupDir, `${randomUUID()}-${name}`));
114
+ log("检测到旧服务记录的 PID 已被其他进程复用,已备份失效记录,继续启动。");
115
+ }
95
116
  continue;
96
117
  }
97
118
  if (identity.protocol !== 1 || identity.instanceId !== d.instanceId || identity.dataDir !== d.dataDir)
@@ -1,6 +1,6 @@
1
1
  export function printLocalDesktopUrl(localUrl, log = console.log, platform = process.platform) {
2
2
  log(`远程桌面授权地址:${localUrl.replace(/\/$/, '')}/web`);
3
- log('请在运行 CLI 的这台电脑上用浏览器打开此地址,配对后点击“远程授权”(盾牌图标)。');
3
+ log('重新授权:agentroam desktop authorize;浏览器备用入口:在这台电脑打开上述地址,配对后点击“远程授权”(盾牌图标)。');
4
4
  if (platform === 'win32')
5
5
  log('点击“开启共享”。请保持 Windows 已登录且未锁屏;暂不支持 UAC 安全桌面和管理员窗口控制。');
6
6
  else
@@ -0,0 +1,52 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ export class LocalServiceHttpError extends Error {
5
+ status;
6
+ constructor(status) {
7
+ super(`本机服务请求失败 (HTTP ${status})`);
8
+ this.status = status;
9
+ }
10
+ }
11
+ /** Resolve an identity-checked loopback service; never send its token to a tunnel. */
12
+ export async function localServiceRequest(dataDir, path, options = {}) {
13
+ if (!path.startsWith("/api/"))
14
+ throw new Error("无效的本机服务路径");
15
+ const request = options.request ?? fetch;
16
+ const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
17
+ const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
18
+ return []; throw error; });
19
+ const candidates = [];
20
+ for (const name of names.filter((name) => name.endsWith(".json"))) {
21
+ try {
22
+ const d = JSON.parse(await readFile(join(directory, name), "utf8"));
23
+ const url = new URL(d.url);
24
+ if (d.protocol !== 1 || typeof d.instanceId !== "string" || !Number.isSafeInteger(d.pid) || d.pid <= 0 || ![resolve(dataDir), resolve(dataDir, "data")].includes(d.dataDir) || !/^[a-f0-9]{64}$/.test(d.token))
25
+ continue;
26
+ if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
27
+ continue;
28
+ const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
29
+ if (!response.ok)
30
+ continue;
31
+ const identity = await response.json();
32
+ if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
33
+ candidates.push(d);
34
+ }
35
+ catch { /* Stale descriptors are expected after a process crash. */ }
36
+ }
37
+ if (candidates.length !== 1)
38
+ throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
39
+ const d = candidates[0];
40
+ const url = new URL(path, d.url);
41
+ if (url.origin !== new URL(d.url).origin)
42
+ throw new Error("无效的本机服务路径");
43
+ const response = await request(url, {
44
+ method: options.method ?? "GET",
45
+ headers: { "x-agentroam-desktop-token": d.token, "content-type": "application/json", origin: new URL(d.url).origin },
46
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
47
+ redirect: "error", signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
48
+ });
49
+ if (!response.ok)
50
+ throw new LocalServiceHttpError(response.status);
51
+ return response.json();
52
+ }
@@ -7,6 +7,7 @@ import { resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
7
7
  import { resolvePlatformRuntime } from "./platform-packages.js";
8
8
  import { findAvailablePort } from "./port.js";
9
9
  import { ProcessSupervisor } from "./process-supervisor.js";
10
+ import { withCliProgress } from "../bin/cli-progress.mjs";
10
11
  export class RuntimeManager {
11
12
  supervisor;
12
13
  codexResolver;
@@ -22,8 +23,8 @@ export class RuntimeManager {
22
23
  const port = await findAvailablePort(options.port);
23
24
  const dataDir = resolve(options.dataDir);
24
25
  await Promise.all(["data", "bin", "cache", "logs"].map((name) => mkdir(resolve(dataDir, name), { recursive: true })));
25
- const codexEnvironment = await prepareCodexRuntimeEnvironment(process.env, { dataDir, target }, this.codexResolver, this.report);
26
- const childEnvironment = await prepareOpenCodeRuntimeEnvironment(codexEnvironment, { dataDir, target }, this.opencodeResolver, this.report);
26
+ const codexEnvironment = await withCliProgress("正在准备 Codex 组件", () => prepareCodexRuntimeEnvironment(process.env, { dataDir, target }, this.codexResolver, this.report));
27
+ const childEnvironment = await withCliProgress("正在准备 OpenCode 组件", () => prepareOpenCodeRuntimeEnvironment(codexEnvironment, { dataDir, target }, this.opencodeResolver, this.report));
27
28
  const { runtimeRoot } = resolvePlatformRuntime(target);
28
29
  await repairNativeRuntimePermissions(runtimeRoot);
29
30
  const gateway = resolve(runtimeRoot, "ws-server.mjs");
@@ -53,7 +54,7 @@ export class RuntimeManager {
53
54
  child.once("exit", (code, signal) => resolveExit({ code, signal }));
54
55
  });
55
56
  const localUrl = `http://127.0.0.1:${port}`;
56
- const status = await waitForHealth(`${localUrl}/api/web-auth/status`, child, 20_000);
57
+ const status = await withCliProgress("正在等待本机服务就绪", () => waitForHealth(`${localUrl}/api/web-auth/status`, child, 20_000));
57
58
  return {
58
59
  port,
59
60
  localUrl,
@@ -1,5 +1,18 @@
1
1
  import { lstat, readFile, readdir, realpath, rm } from "node:fs/promises";
2
2
  import { relative, resolve, sep } from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
6
+ async function processPathsInUse() {
7
+ // Windows has no reliable cwd inventory here: retain old installs instead.
8
+ if (process.platform === "win32")
9
+ throw new Error("Process paths unavailable");
10
+ const { stdout } = await execFileAsync(process.platform === "darwin" ? "/usr/sbin/lsof" : "lsof", ["-nP", "-d", "cwd,txt", "-Fn"], { timeout: 5_000, maxBuffer: 16 * 1024 * 1024 });
11
+ const paths = stdout.split("\n").filter(line => line.startsWith("n/")).map(line => line.slice(1));
12
+ if (!paths.length)
13
+ throw new Error("Incomplete process paths");
14
+ return [process.cwd(), ...paths];
15
+ }
3
16
  function versionParts(version) {
4
17
  const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.exec(version);
5
18
  return match ? [Number(match[1]), Number(match[2]), Number(match[3]), match[4] === undefined ? Infinity : Number(match[4])] : null;
@@ -18,7 +31,7 @@ function contains(parent, child) {
18
31
  return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !path.startsWith(sep));
19
32
  }
20
33
  /** Remove only recognized managed launcher versions after the replacement service is ready. */
21
- export async function cleanupOldLaunchers(config, log) {
34
+ export async function cleanupOldLaunchers(config, log, context = {}) {
22
35
  try {
23
36
  const dataDir = await realpath(config.dataDir);
24
37
  const launcherDir = resolve(dataDir, "launcher");
@@ -47,6 +60,11 @@ export async function cleanupOldLaunchers(config, log) {
47
60
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
48
61
  if (manifest.name !== "agentroam" || manifest.version !== entry.name)
49
62
  continue;
63
+ const usedPaths = await (context.pathsInUse ?? processPathsInUse)();
64
+ if (usedPaths.some(path => contains(candidate, path))) {
65
+ log(`旧版 CLI ${entry.name} 仍被进程使用,已保留。`);
66
+ continue;
67
+ }
50
68
  await rm(candidate, { recursive: true });
51
69
  log(`✓ 已清理旧版 CLI ${entry.name},配置和数据已保留`);
52
70
  }
@@ -60,13 +60,13 @@ export class MacLaunchAgent {
60
60
  const status = await this.status();
61
61
  if (!status.installed)
62
62
  throw new Error("AgentRoam service is not installed");
63
+ const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
63
64
  if (status.running)
64
- return status.state;
65
+ return this.waitForReady(paths);
65
66
  if (status.loaded)
66
67
  await this.runRequired("launchctl", ["kickstart", this.serviceTarget]);
67
68
  else
68
69
  await this.bootstrap(status.definition);
69
- const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
70
70
  return this.waitForReady(paths, status.state?.pid);
71
71
  }
72
72
  async stop() {
@@ -84,13 +84,14 @@ export class MacLaunchAgent {
84
84
  const config = await readServiceConfig(basePaths);
85
85
  const paths = resolveServicePaths(this.homeDir, config?.dataDir);
86
86
  const installed = await pathExists(paths.plistPath);
87
- const job = installed ? await this.inspectJob() : { loaded: false, running: false };
87
+ const job = installed ? await this.inspectJob() : { loaded: false, running: false, pid: undefined };
88
+ const state = await readServiceState(paths);
88
89
  return {
89
90
  installed,
90
91
  loaded: job.loaded,
91
92
  running: job.running,
92
93
  config,
93
- state: await readServiceState(paths),
94
+ state: job.running && state?.pid !== job.pid ? null : state,
94
95
  definition: paths.plistPath,
95
96
  };
96
97
  }
@@ -157,9 +158,11 @@ export class MacLaunchAgent {
157
158
  }
158
159
  async inspectJob() {
159
160
  const result = await this.runner("launchctl", ["print", this.serviceTarget]);
161
+ const pid = /\bpid\s*=\s*(\d+)\b/.exec(result.stdout);
160
162
  return {
161
163
  loaded: result.code === 0,
162
164
  running: result.code === 0 && /\bstate\s*=\s*running\b/.test(result.stdout),
165
+ pid: pid ? Number(pid[1]) : undefined,
163
166
  };
164
167
  }
165
168
  async bootstrap(definition) {
@@ -195,10 +198,13 @@ export class MacLaunchAgent {
195
198
  const deadline = Date.now() + this.readyTimeoutMs;
196
199
  do {
197
200
  const state = await readServiceState(paths);
198
- if (state?.status === "ready" && (!previousPid || state.pid !== previousPid))
199
- return state;
201
+ if (state?.status === "ready" && state.accessUrl && (!previousPid || state.pid !== previousPid)) {
202
+ const job = await this.inspectJob();
203
+ if (job.running && job.pid === state.pid)
204
+ return state;
205
+ }
200
206
  if (Date.now() >= deadline)
201
- return state;
207
+ return state?.status === "ready" ? null : state;
202
208
  await delay(this.pollIntervalMs);
203
209
  } while (true);
204
210
  }
@@ -9,6 +9,8 @@ import { WindowsTaskService } from "./windows-task-service.js";
9
9
  import { assertSupportedNodeVersion } from "../../bin/runtime-policy.mjs";
10
10
  import { cleanupOldLaunchers } from "./launcher-cleanup.js";
11
11
  import { setupCodexDesktop } from "../codex-desktop-setup.js";
12
+ import { authorizeRemoteDesktop } from "../desktop-authorization.js";
13
+ import { withCliProgress } from "../../bin/cli-progress.mjs";
12
14
  export async function runServiceCommand(options, context) {
13
15
  const platform = context.platform ?? process.platform;
14
16
  const log = context.log ?? console.log;
@@ -30,14 +32,14 @@ export async function runServiceCommand(options, context) {
30
32
  const environmentPath = buildServiceEnvironmentPath(context.nodePath, environment.PATH ?? environment.Path, platform);
31
33
  let codexPath;
32
34
  try {
33
- const codex = await (context.codexResolver ?? resolveCodexRuntime)({
35
+ const codex = await withCliProgress("正在检查和安装 Codex 组件", () => (context.codexResolver ?? resolveCodexRuntime)({
34
36
  dataDir: options.dataDir,
35
37
  target: detectPlatform(platform, context.arch ?? process.arch, nodeVersion),
36
38
  environment: { ...environment, PATH: environmentPath },
37
39
  platform,
38
40
  nodeExecutable: context.nodePath,
39
41
  onProgress: (message) => log(`… ${message}`),
40
- });
42
+ }));
41
43
  codexPath = codex.executable;
42
44
  }
43
45
  catch (error) {
@@ -58,36 +60,47 @@ export async function runServiceCommand(options, context) {
58
60
  dataDir: options.dataDir,
59
61
  installedAt: now.toISOString(),
60
62
  };
61
- const { definition, state } = await controller.install(config);
63
+ const { definition, state } = await withCliProgress("正在安装后台服务,等待服务和手机访问连接就绪", () => controller.install(config));
62
64
  log(`✓ AgentRoam service installed: ${definition}`);
65
+ if (state?.localUrl && state.status !== "stopped")
66
+ printLocalDesktopUrl(state.localUrl, log, platform);
67
+ requireReadyService(state);
68
+ if (state?.localUrl && state.status !== "stopped" && (platform === "darwin" || platform === "win32")) {
69
+ if (environment.AGENTROAM_INSTALL_REMOTE_DESKTOP === "skip") {
70
+ log("已跳过安装时远程桌面授权,可稍后执行 agentroam desktop authorize。");
71
+ }
72
+ else {
73
+ try {
74
+ await (context.desktopAuthorization ?? authorizeRemoteDesktop)(options.dataDir, { platform, isTTY: stdoutIsTTY, log });
75
+ }
76
+ catch (error) {
77
+ log(`远程桌面授权未完成:${error instanceof Error ? error.message : error}`);
78
+ log("AgentRoam 安装继续;可稍后执行 agentroam desktop authorize,或在本机浏览器的“远程授权”面板中重试。");
79
+ }
80
+ }
81
+ }
63
82
  if (platform === "darwin" && !options.desktopSetup)
64
83
  log("官方 Codex 桌面共享后端:agentroam codex-desktop --setup;预演:agentroam codex-desktop --dry-run。");
65
- if (state?.status === "ready" && state.version === config.version)
84
+ if (state.version === config.version)
66
85
  await cleanupOldLaunchers(config, log);
67
- if (state?.localUrl && state.status !== "stopped")
68
- printLocalDesktopUrl(state.localUrl, log, platform);
69
86
  if (options.desktopSetup && platform === "darwin") {
70
- await (context.desktopSetup ?? setupCodexDesktop)(options, { platform, environment, service: config, log });
71
- }
72
- if (state?.status === "ready" && state.accessUrl) {
73
- log(`Open: ${state.accessUrl}`);
74
- await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
87
+ await withCliProgress("正在配置官方 Codex 桌面共享连接", () => (context.desktopSetup ?? setupCodexDesktop)(options, { platform, environment, service: config, log }));
75
88
  }
76
- else
77
- log("Service is starting. Run `agentroam service url` and `agentroam pair` shortly.");
89
+ log(`Open: ${state.accessUrl}`);
90
+ await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
78
91
  return;
79
92
  }
80
93
  case "start": {
81
- const state = await controller.start();
82
- log("✓ AgentRoam service started");
83
- if (state?.status === "ready" && state.accessUrl)
84
- await logAccessUrl(state.accessUrl);
94
+ const state = await withCliProgress("正在启动后台服务,等待服务和手机访问连接就绪", () => controller.start());
85
95
  if (state?.localUrl && state.status !== "stopped")
86
96
  printLocalDesktopUrl(state.localUrl, log, platform);
97
+ requireReadyService(state);
98
+ log("✓ AgentRoam service started");
99
+ await logAccessUrl(state.accessUrl);
87
100
  return;
88
101
  }
89
102
  case "stop":
90
- await controller.stop();
103
+ await withCliProgress("正在停止后台服务", () => controller.stop());
91
104
  log("✓ AgentRoam service stopped");
92
105
  return;
93
106
  case "status": {
@@ -122,12 +135,12 @@ export async function runServiceCommand(options, context) {
122
135
  return;
123
136
  }
124
137
  case "restart": {
125
- const state = await controller.restart();
126
- log("✓ AgentRoam service restarted");
127
- if (state?.status === "ready" && state.accessUrl)
128
- await logAccessUrl(state.accessUrl);
138
+ const state = await withCliProgress("正在重启后台服务,等待服务和手机访问连接就绪", () => controller.restart());
129
139
  if (state?.localUrl && state.status !== "stopped")
130
140
  printLocalDesktopUrl(state.localUrl, log, platform);
141
+ requireReadyService(state);
142
+ log("✓ AgentRoam service restarted");
143
+ await logAccessUrl(state.accessUrl);
131
144
  return;
132
145
  }
133
146
  case "uninstall": {
@@ -140,6 +153,11 @@ export async function runServiceCommand(options, context) {
140
153
  throw cliError("service action is required");
141
154
  }
142
155
  }
156
+ function requireReadyService(state) {
157
+ if (state?.status !== "ready" || !state.accessUrl) {
158
+ throw Object.assign(new Error(`AgentRoam 后台服务未就绪(${state?.status ?? "未取得状态"})。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。`), { exitCode: 1 });
159
+ }
160
+ }
143
161
  export function buildServiceEnvironmentPath(nodePath, pathValue, platform) {
144
162
  const separator = platform === "win32" ? ";" : delimiter;
145
163
  const nodeDirectory = platform === "win32" ? win32.dirname(nodePath) : dirname(nodePath);
@@ -55,9 +55,9 @@ export class WindowsTaskService {
55
55
  const status = await this.status();
56
56
  if (!status.installed)
57
57
  throw new Error("AgentRoam service is not installed");
58
- if (status.running)
59
- return status.state;
60
58
  const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
59
+ if (status.running)
60
+ return this.waitForReady(paths);
61
61
  await Promise.all([removeIfExists(paths.statePath), removeIfExists(paths.urlPath)]);
62
62
  await this.runRequired(buildStartTaskScript());
63
63
  return this.waitForReady(paths, status.state?.pid);
@@ -88,12 +88,13 @@ export class WindowsTaskService {
88
88
  const config = await readServiceConfig(basePaths);
89
89
  const paths = resolveServicePaths(this.homeDir, config?.dataDir);
90
90
  const task = await this.inspectTask();
91
+ const state = await readServiceState(paths);
91
92
  return {
92
93
  installed: task.installed,
93
94
  loaded: task.installed,
94
95
  running: task.running,
95
96
  config,
96
- state: await readServiceState(paths),
97
+ state: task.running && state && !this.processExists(state.pid) ? null : state,
97
98
  definition: WINDOWS_SERVICE_NAME,
98
99
  };
99
100
  }
@@ -185,10 +186,13 @@ export class WindowsTaskService {
185
186
  const deadline = Date.now() + this.readyTimeoutMs;
186
187
  do {
187
188
  const state = await readServiceState(paths);
188
- if (state?.status === "ready" && (!previousPid || state.pid !== previousPid))
189
- return state;
189
+ if (state?.status === "ready" && state.accessUrl && (!previousPid || state.pid !== previousPid)) {
190
+ const task = await this.inspectTask();
191
+ if (task.running && this.processExists(state.pid))
192
+ return state;
193
+ }
190
194
  if (Date.now() >= deadline)
191
- return state;
195
+ return state?.status === "ready" ? null : state;
192
196
  await delay(this.pollIntervalMs);
193
197
  } while (true);
194
198
  }
@@ -1,7 +1,15 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
3
  const SERVICE_NAME = "AgentRoamUnlock";
4
4
  const DISPLAY_NAME = "AgentRoam Remote Unlock";
5
+ // Keep the event loop free while PowerShell waits for UAC or service control.
6
+ const runFile = (file, args, options) => new Promise((resolveResult) => {
7
+ execFile(file, [...args], options, (error, stdout, stderr) => {
8
+ resolveResult({ stdout, stderr, status: error ? 1 : 0,
9
+ ...(error && typeof error.code !== "number" ? { error } : {}),
10
+ });
11
+ });
12
+ });
5
13
  function psQuote(value) {
6
14
  return `'${value.replaceAll("'", "''")}'`;
7
15
  }
@@ -59,36 +67,36 @@ function runPowerShell(script, runner) {
59
67
  "-EncodedCommand", encodePowerShell(script),
60
68
  ], { encoding: "utf8", windowsHide: true });
61
69
  }
62
- export function queryUnlockServiceStatus(runner = spawnSync) {
70
+ export async function queryUnlockServiceStatus(runner = runFile) {
63
71
  const script = `$service = Get-Service -Name '${SERVICE_NAME}' -ErrorAction SilentlyContinue
64
72
  if (-not $service) { Write-Output 'missing' } elseif ($service.Status -eq 'Running') { Write-Output 'running' } else { Write-Output 'stopped' }
65
73
  `;
66
- const result = runPowerShell(script, runner);
74
+ const result = await runPowerShell(script, runner);
67
75
  if (result.error)
68
76
  throw result.error;
69
77
  if (result.status !== 0)
70
78
  throw new Error((result.stderr || "远程解锁服务状态查询失败").trim());
71
79
  return parseUnlockServiceStatus(result.stdout || "");
72
80
  }
73
- export function runUnlockServiceCommand(action, runtimeRoot, options = {}) {
81
+ export async function runUnlockServiceCommand(action, runtimeRoot, options = {}) {
74
82
  const platform = options.platform ?? process.platform;
75
- const runner = options.runner ?? spawnSync;
83
+ const runner = options.runner ?? runFile;
76
84
  const log = options.log ?? console.log;
77
85
  if (platform !== "win32")
78
86
  throw new Error("远程解锁服务仅支持 Windows 10/11 x64");
79
87
  if (action === "status") {
80
- const status = queryUnlockServiceStatus(runner);
88
+ const status = await queryUnlockServiceStatus(runner);
81
89
  log(status === "running" ? "远程解锁服务正在运行" : status === "stopped" ? "远程解锁服务已安装但未运行" : "远程解锁服务未安装");
82
90
  return;
83
91
  }
84
92
  const executable = unlockServiceExecutable(runtimeRoot);
85
- const result = runPowerShell(buildElevatedPowerShell(buildUnlockServiceAdminScript(action, executable)), runner);
93
+ const result = await runPowerShell(buildElevatedPowerShell(buildUnlockServiceAdminScript(action, executable)), runner);
86
94
  if (result.error)
87
95
  throw result.error;
88
96
  if (result.status !== 0) {
89
97
  throw new Error((result.stderr || (action === "install" ? "远程解锁服务安装失败或已取消管理员授权" : "远程解锁服务卸载失败或已取消管理员授权")).trim());
90
98
  }
91
- const status = queryUnlockServiceStatus(runner);
99
+ const status = await queryUnlockServiceStatus(runner);
92
100
  if (action === "install" && status !== "running")
93
101
  throw new Error("远程解锁服务安装后未能启动");
94
102
  if (action === "uninstall" && status !== "missing")
@@ -232,7 +232,7 @@ if ($env:AGENTROAM_INSTALL_SKIP_SERVICE -eq "1") {
232
232
  Write-Host "AgentRoam service registration skipped for isolated verification."
233
233
  } else {
234
234
  & $NodeBin $Entry service install --root $ServiceRoot --data-dir $DataDir
235
- if ($LASTEXITCODE -ne 0) { throw "AgentRoam service installation failed with exit code $LASTEXITCODE" }
235
+ if ($LASTEXITCODE -ne 0) { throw "CLI 已安装,但后台服务安装或启动失败(exit $LASTEXITCODE),尚不能扫码连接。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。" }
236
236
  }
237
237
  Write-Host "`nAgentRoam $AgentRoamVersion installed: $WrapperPath"
238
238
  Write-Host "Open a new terminal, then run: agentroam service status"