agentroam 0.2.0-preview.37 → 0.2.0-preview.39

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.
@@ -0,0 +1,9 @@
1
+ export interface CliProgressOptions {
2
+ enabled?: boolean;
3
+ environment?: NodeJS.ProcessEnv;
4
+ log?: (line: string) => void;
5
+ delayMs?: number;
6
+ intervalMs?: number;
7
+ }
8
+ export function withCliProgress<T>(label: string, operation: () => T | Promise<T>, options?: CliProgressOptions): Promise<T>;
9
+ export function withoutCliProgress<T>(operation: () => T | Promise<T>): Promise<T>;
@@ -0,0 +1,65 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ const context = new AsyncLocalStorage();
4
+
5
+ // Append complete lines to stderr so stdout (URLs, QR codes, JSON) stays usable.
6
+ class ProgressReporter {
7
+ constructor(options) {
8
+ const environment = options.environment ?? process.env;
9
+ this.enabled = options.enabled ?? (environment.AGENTROAM_SERVICE !== '1' && environment.AGENTROAM_NO_PROGRESS !== '1');
10
+ this.log = options.log ?? ((line) => process.stderr.write(`${line}\n`));
11
+ this.delayMs = options.delayMs ?? 800;
12
+ this.intervalMs = options.intervalMs ?? 5000;
13
+ this.steps = [];
14
+ this.pauses = 0;
15
+ }
16
+
17
+ begin(label) {
18
+ const step = { label, started: Date.now() };
19
+ this.steps.push(step);
20
+ this.schedule(this.delayMs);
21
+ return () => {
22
+ const wasActive = this.steps.at(-1) === step;
23
+ this.steps.splice(this.steps.indexOf(step), 1);
24
+ if (wasActive) this.schedule(this.delayMs);
25
+ };
26
+ }
27
+
28
+ schedule(milliseconds) {
29
+ clearTimeout(this.timer);
30
+ if (!this.enabled || this.pauses || !this.steps.length) return;
31
+ this.timer = setTimeout(() => {
32
+ const step = this.steps.at(-1);
33
+ const seconds = Math.floor((Date.now() - step.started) / 1000);
34
+ this.log(`… ${step.label}(已等待 ${seconds} 秒)`);
35
+ this.schedule(this.intervalMs);
36
+ }, milliseconds);
37
+ this.timer.unref?.();
38
+ }
39
+
40
+ pause() {
41
+ this.pauses++;
42
+ clearTimeout(this.timer);
43
+ return () => {
44
+ this.pauses--;
45
+ this.schedule(this.delayMs);
46
+ };
47
+ }
48
+ }
49
+
50
+ /** Nested stages share one heartbeat. Timers always stop, including on failure. */
51
+ export async function withCliProgress(label, operation, options = {}) {
52
+ const reporter = context.getStore() ?? new ProgressReporter(options);
53
+ return context.run(reporter, async () => {
54
+ const finish = reporter.begin(label);
55
+ try { return await operation(); }
56
+ finally { finish(); }
57
+ });
58
+ }
59
+
60
+ /** Suspend every nested heartbeat while a terminal prompt owns the cursor. */
61
+ export async function withoutCliProgress(operation) {
62
+ const resume = context.getStore()?.pause();
63
+ try { return await operation(); }
64
+ finally { resume?.(); }
65
+ }
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { Transform, Readable } from 'node:stream';
9
9
  import { pipeline } from 'node:stream/promises';
10
10
  import { execFile } from 'node:child_process';
11
+ import { withCliProgress } from './cli-progress.mjs';
11
12
 
12
13
  // 桌面端安装包是构建产物(dmg / exe),进不了只放源码的仓库,只能走 Releases 资产。
13
14
  // 资产由 `node scripts/publish-agentroam-release.mjs sync-github --commit <sha>` 上传;
@@ -57,7 +58,7 @@ export async function downloadDesktop(version, { platform = process.platform, ar
57
58
 
58
59
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
59
60
  try {
60
- const destination = await downloadDesktop(process.argv[2]);
61
+ const destination = await withCliProgress('正在下载并校验桌面端安装包', () => downloadDesktop(process.argv[2]));
61
62
  console.log(`桌面端安装包已下载:${destination}\n请打开安装包完成安装。首次打开 AgentRoam 后,按提示授权远程桌面。`);
62
63
  if (process.platform === 'darwin') execFile('/usr/bin/open', ['-R', destination], () => undefined);
63
64
  else if (process.platform === 'win32') execFile('explorer.exe', [`/select,${destination}`], () => undefined);
@@ -5,6 +5,7 @@ import { delimiter, dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
7
  import { MINIMUM_NODE_VERSION, compareNodeVersions, isSupportedNodeVersion, parseNodeVersion } from "./runtime-policy.mjs";
8
+ import { withCliProgress } from "./cli-progress.mjs";
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
  // 安装引导地址:指向仓库主分支上的安装脚本(raw 路径)。
@@ -35,10 +36,9 @@ export async function runNodePreflight(options = {}) {
35
36
  const target = detectManagedTarget(platform, arch);
36
37
  const dataDir = parsePreflightDataDir(argv, environment, options.homeDir ?? homedir());
37
38
  const findSystemNode = options.findSystemNode ?? defaultFindSystemNode;
38
- const systemNode = await findSystemNode(environment, platform, options.currentExecutable ?? process.execPath);
39
- let executable = systemNode;
40
-
41
- if (!executable) {
39
+ const executable = await withCliProgress("正在查找或安装兼容的 Node.js", async () => {
40
+ const systemNode = await findSystemNode(environment, platform, options.currentExecutable ?? process.execPath);
41
+ if (systemNode) return systemNode;
42
42
  const ensureNode = options.ensureNode ?? (async (installOptions) => {
43
43
  const { ensureManagedNode } = await import("../dist/node-runtime-manager.js");
44
44
  return ensureManagedNode(installOptions);
@@ -50,8 +50,8 @@ export async function runNodePreflight(options = {}) {
50
50
  arch,
51
51
  onProgress: options.onProgress ?? ((message) => processHost.stderr.write(`${message}\n`)),
52
52
  });
53
- executable = resolution.executable;
54
- }
53
+ return resolution.executable;
54
+ }, { environment, log: (line) => processHost.stderr.write(`${line}\n`) });
55
55
 
56
56
  const spawnChild = options.spawnChild ?? defaultSpawnChild;
57
57
  const child = spawnChild(executable, [launcherPath, ...argv], {
package/dist/args.js CHANGED
@@ -3,11 +3,18 @@ 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", "service", "unlock-service", "update", "update-worker", "pair", "devices", "revoke", "approvals", "approve", "deny", "lock", "unlock", "audit", "codex-desktop"].includes(values[0])) {
6
+ if (values[0] && ["start", "doctor", "version", "service", "unlock-service", "update", "update-worker", "pair", "devices", "revoke", "approvals", "approve", "deny", "lock", "unlock", "audit", "codex-desktop", "desktop"].includes(values[0])) {
7
7
  command = values.shift();
8
8
  }
9
9
  let serviceAction = null;
10
10
  let unlockServiceAction = null;
11
+ let desktopAction;
12
+ if (command === "desktop") {
13
+ const action = values.shift();
14
+ if (!action || !["authorize", "status", "disable"].includes(action))
15
+ throw cliError("desktop requires authorize, status, or disable");
16
+ desktopAction = action;
17
+ }
11
18
  if (command === "service") {
12
19
  const action = values.shift();
13
20
  if (!action || !["install", "start", "stop", "status", "url", "logs", "restart", "uninstall"].includes(action)) {
@@ -67,9 +74,13 @@ export function parseArgs(argv) {
67
74
  ...(approvalRequestId ? { approvalRequestId } : {}),
68
75
  updateVersion,
69
76
  updateStateFile,
77
+ ...(desktopAction ? { desktopAction } : {}),
70
78
  };
71
79
  for (let index = 0; index < values.length && command !== "update-worker"; index++) {
72
80
  const arg = values[index];
81
+ if (command === "desktop" && !["--data-dir", "--help", "-h"].includes(arg)) {
82
+ throw cliError("desktop accepts only --data-dir");
83
+ }
73
84
  if (command === "codex-desktop" && !["--restart", "--dry-run", "--setup", "--data-dir", "--help", "-h"].includes(arg)) {
74
85
  throw cliError("codex-desktop accepts only --setup, --restart, --dry-run and --data-dir");
75
86
  }
package/dist/cli.js CHANGED
@@ -23,6 +23,8 @@ import { runUnlockServiceCommand } from "./unlock-service.js";
23
23
  import { createCliErrorLog } from "./error-log.js";
24
24
  import { runCodexDesktopCommand } from "./codex-desktop-command.js";
25
25
  import { setupCodexDesktop } from "./codex-desktop-setup.js";
26
+ import { runDesktopCommand } from "./desktop-authorization.js";
27
+ import { withCliProgress } from "../bin/cli-progress.mjs";
26
28
  const VERSION = AGENTROAM_VERSION;
27
29
  export async function main(argv) {
28
30
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -40,6 +42,9 @@ export async function main(argv) {
40
42
  agentroam revoke <设备ID> 移除一台设备
41
43
  agentroam revoke --all 全部退出并取消待用配对码
42
44
  agentroam service install|start|stop|restart|status|url|logs|uninstall
45
+ agentroam desktop authorize 本机弹窗授权远程桌面,无需浏览器;可重复执行
46
+ agentroam desktop status 重新检查远程桌面权限和共享状态
47
+ agentroam desktop disable 关闭远程桌面共享,保留系统权限
43
48
  agentroam unlock-service install|uninstall|status 管理 Windows 远程解锁服务(安装时弹出 UAC)
44
49
  agentroam codex-desktop [--dry-run] 通过共享后端启动官方 Codex 桌面端(macOS)
45
50
  agentroam codex-desktop --restart 退出并重新连接官方桌面端,会中断它的全部运行会话
@@ -49,6 +54,19 @@ export async function main(argv) {
49
54
  return;
50
55
  }
51
56
  const options = parseArgs(argv);
57
+ // Foreground start remains alive after readiness; only its startup stages load.
58
+ if (options.command === "start" || options.command === "update-worker")
59
+ return executeCommand(options, argv);
60
+ const label = options.command === "service" ? `正在处理后台服务:${options.serviceAction}`
61
+ : options.command === "desktop" ? "正在检查远程桌面授权"
62
+ : options.command === "codex-desktop" ? "正在配置官方 Codex 桌面连接"
63
+ : options.command === "doctor" ? "正在检查运行组件"
64
+ : options.command === "update" ? "正在检查并准备更新"
65
+ : options.command === "unlock-service" ? "正在处理 Windows 解锁服务,请留意管理员授权弹窗"
66
+ : "正在处理设备授权命令";
67
+ await withCliProgress(label, () => executeCommand(options, argv));
68
+ }
69
+ async function executeCommand(options, argv) {
52
70
  // CLI 进程自身的按天错误日志:<dataDir>/logs/YYYY-MM-DD.log(NDJSON,与
53
71
  // server/desktop 的 core logger 同格式)。uncaughtException 记录后按默认
54
72
  // 语义退出(1),unhandledRejection 只记录。
@@ -98,6 +116,12 @@ export async function main(argv) {
98
116
  return;
99
117
  }
100
118
  const target = detectPlatform();
119
+ if (options.command === "desktop") {
120
+ if (!argv.includes("--data-dir"))
121
+ options.dataDir = await installedDataDir(options.dataDir);
122
+ await runDesktopCommand(options);
123
+ return;
124
+ }
101
125
  if (options.command === "codex-desktop") {
102
126
  if (!argv.includes("--data-dir"))
103
127
  options.dataDir = await installedDataDir(options.dataDir);
@@ -110,7 +134,7 @@ export async function main(argv) {
110
134
  if (options.command === "unlock-service") {
111
135
  if (target !== "windows-amd64")
112
136
  throw new Error("远程解锁服务仅支持 Windows 10/11 x64");
113
- runUnlockServiceCommand(options.unlockServiceAction, resolvePlatformRuntime(target).runtimeRoot);
137
+ await runUnlockServiceCommand(options.unlockServiceAction, resolvePlatformRuntime(target).runtimeRoot);
114
138
  return;
115
139
  }
116
140
  if (options.command === "update") {
@@ -129,7 +153,7 @@ export async function main(argv) {
129
153
  return;
130
154
  }
131
155
  console.log(`AgentRoam ${VERSION}\n✓ Node ${process.versions.node} · ${target}`);
132
- const sleepInhibitor = await acquireSleepInhibitor();
156
+ const sleepInhibitor = await withCliProgress("正在准备启动环境", () => acquireSleepInhibitor());
133
157
  const controller = new AbortController();
134
158
  let runtime = null;
135
159
  let relay = null;
@@ -145,23 +169,23 @@ export async function main(argv) {
145
169
  return closePromise;
146
170
  closing = true;
147
171
  controller.abort();
148
- closePromise = (async () => {
172
+ closePromise = withCliProgress("正在停止本机服务和访问连接", async () => {
149
173
  await relay?.tunnel?.close().catch(() => { });
150
174
  await runtime?.close().catch(() => { });
151
- })();
175
+ });
152
176
  return closePromise;
153
177
  };
154
178
  const requestClose = () => void close();
155
179
  process.once("SIGINT", requestClose);
156
180
  process.once("SIGTERM", requestClose);
157
181
  try {
158
- releaseStartup = await lockInstanceStartup(options.dataDir);
159
- await stopPreviousInstances(options.dataDir, console.log);
182
+ releaseStartup = await withCliProgress("正在等待启动锁", () => lockInstanceStartup(options.dataDir));
183
+ await withCliProgress("正在接管旧实例", () => stopPreviousInstances(options.dataDir, console.log));
160
184
  if (controller.signal.aborted)
161
185
  return;
162
186
  await serviceReporter?.starting();
163
187
  reportedStarting = Boolean(serviceReporter);
164
- runtime = await new RuntimeManager().start(options, target);
188
+ runtime = await withCliProgress("正在启动本机服务", () => new RuntimeManager().start(options, target));
165
189
  if (controller.signal.aborted) {
166
190
  await runtime.close();
167
191
  return;
@@ -172,18 +196,19 @@ export async function main(argv) {
172
196
  if (!serviceReporter)
173
197
  printLocalDesktopUrl(runtime.localUrl);
174
198
  const lanUrl = options.localOnly ? findLanUrl(runtime.port) ?? runtime.localUrl : runtime.localUrl;
175
- relay = await selectRelay({
199
+ const relayRuntime = runtime;
200
+ relay = await withCliProgress("正在建立手机访问连接", () => selectRelay({
176
201
  cli: options,
177
202
  target,
178
- localUrl: runtime.localUrl,
203
+ localUrl: relayRuntime.localUrl,
179
204
  lanUrl,
180
- port: runtime.port,
205
+ port: relayRuntime.port,
181
206
  signal: controller.signal,
182
207
  log: (line) => process.stderr.write(`${line}\n`),
183
208
  onAttempt: (provider) => console.log(`▲ Trying ${providerDisplayName(provider)} relay...`),
184
209
  onFailure: (provider, message) => console.error(`⚠ ${providerDisplayName(provider)} unavailable: ${message}`),
185
210
  allowLanFallback: options.localOnly,
186
- });
211
+ }));
187
212
  if (controller.signal.aborted) {
188
213
  await relay.tunnel?.close();
189
214
  return;
@@ -255,7 +280,7 @@ async function doctor(target, dataDir) {
255
280
  ["better-sqlite3", probeSQLiteRuntime],
256
281
  ]) {
257
282
  try {
258
- await probe(runtimeRequire);
283
+ await withCliProgress(`正在检查 ${module}`, () => probe(runtimeRequire));
259
284
  console.log(`✓ ${module}`);
260
285
  }
261
286
  catch (error) {
@@ -270,18 +295,18 @@ async function doctor(target, dataDir) {
270
295
  reportDoctorFailure(error, "agent-tui: ");
271
296
  }
272
297
  try {
273
- const codex = await resolveCodexRuntime({
298
+ const codex = await withCliProgress("正在检查和准备 Codex", () => resolveCodexRuntime({
274
299
  dataDir,
275
300
  target,
276
301
  onProgress: (message) => console.log(`… ${message}`),
277
- });
302
+ }));
278
303
  console.log(`✓ Codex ${codex.version} (${codex.source}) ${codex.executable}`);
279
304
  }
280
305
  catch (error) {
281
306
  reportDoctorFailure(error, "Codex: ");
282
307
  }
283
308
  try {
284
- const executable = await ensureCloudflared(target, dataDir);
309
+ const executable = await withCliProgress("正在检查和准备 Cloudflare 组件", () => ensureCloudflared(target, dataDir));
285
310
  console.log(`✓ cloudflared ${executable}`);
286
311
  }
287
312
  catch (error) {
@@ -4,6 +4,7 @@ import { ReadStream, WriteStream } from "node:tty";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { runCodexDesktopCommand } from "./codex-desktop-command.js";
6
6
  import { inspectCodexDesktop } from "./codex-desktop-status.js";
7
+ import { withoutCliProgress } from "../bin/cli-progress.mjs";
7
8
  export async function setupCodexDesktop(options, context = {}) {
8
9
  if ((context.platform ?? process.platform) !== "darwin")
9
10
  return;
@@ -39,7 +40,7 @@ export async function setupCodexDesktop(options, context = {}) {
39
40
  ? `官方 Codex 桌面端当前有 ${snapshot.activity.activeCount} 个运行中的会话。`
40
41
  : "无法可靠确认官方 Codex 桌面端的全部任务已结束。";
41
42
  log(reason);
42
- const answer = await (context.confirm ?? confirmDesktopRestart)("现在重启桌面端以共享会话?重启可能中断桌面端的所有运行任务;选择保留则暂不能共享原桌面会话 [y/N]:");
43
+ const answer = await withoutCliProgress(() => (context.confirm ?? confirmDesktopRestart)("现在重启桌面端以共享会话?重启可能中断桌面端的所有运行任务;选择保留则暂不能共享原桌面会话 [y/N]:"));
43
44
  if (!answer || !/^(?:y|yes)$/i.test(answer.trim())) {
44
45
  deferred();
45
46
  return;
@@ -0,0 +1,148 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { Readable } from "node:stream";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { promisify } from "node:util";
6
+ import { resolveApprovalInput, terminalText } from "./device-pairing.js";
7
+ import { localServiceRequest } from "./local-service-request.js";
8
+ import { withCliProgress, withoutCliProgress } from "../bin/cli-progress.mjs";
9
+ const runFile = promisify(execFile);
10
+ const endpoint = "/api/remote-authorization";
11
+ async function requestAuthorization(dataDir, body) {
12
+ const status = await localServiceRequest(dataDir, endpoint, {
13
+ method: body ? "POST" : "GET", body, timeoutMs: body?.action === "request-permission" ? 135_000 : 30_000,
14
+ });
15
+ if (status.local !== true || typeof status.supported !== "boolean" || typeof status.installed !== "boolean"
16
+ || typeof status.enabled !== "boolean" || typeof status.screen !== "boolean" || typeof status.accessibility !== "boolean"
17
+ || typeof status.online !== "boolean" || typeof status.platform !== "string"
18
+ || (status.error !== null && typeof status.error !== "string")) {
19
+ throw new Error("本机远程授权状态无效,请升级 CLI 后重试");
20
+ }
21
+ return status;
22
+ }
23
+ /** A native macOS dialog; no browser, shell interpolation, or piped stdin. */
24
+ export async function confirmDesktopAuthorization(platform) {
25
+ if (platform === "darwin") {
26
+ const script = 'display dialog "是否启用 AgentRoam 远程桌面?\n接下来将请求屏幕录制和辅助功能权限。授权后,已配对的设备可以查看和控制这台电脑。" with title "AgentRoam 远程桌面授权" buttons {"稍后", "开始授权"} default button "开始授权" cancel button "稍后" giving up after 120';
27
+ try {
28
+ const { stdout } = await runFile("/usr/bin/osascript", ["-e", script], { timeout: 125_000, maxBuffer: 4096 });
29
+ return stdout.includes("button returned:开始授权") && !stdout.includes("gave up:true");
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ const input = resolveApprovalInput({ interactive: true });
36
+ if (!input)
37
+ return false;
38
+ const reader = createInterface({ input, output: process.stdout });
39
+ try {
40
+ const answer = await withoutCliProgress(() => reader.question("启用远程桌面,允许已配对设备查看和控制这台电脑?[y/N]:"));
41
+ return /^(y|yes)$/i.test(answer.trim());
42
+ }
43
+ finally {
44
+ reader.close();
45
+ if (input !== process.stdin && input instanceof Readable)
46
+ input.destroy();
47
+ }
48
+ }
49
+ function recoveryHint(log) {
50
+ log("稍后重新授权:agentroam desktop authorize;也可在本机浏览器的“远程授权”面板中操作。");
51
+ }
52
+ function complete(status) {
53
+ return status.enabled && status.screen && status.accessibility && !status.error;
54
+ }
55
+ /** Request permissions without enabling sharing, then verify before activation. */
56
+ export async function authorizeRemoteDesktop(dataDir, context = {}) {
57
+ const log = context.log ?? console.log;
58
+ if (!(context.isTTY ?? process.stdout.isTTY === true)) {
59
+ log("未检测到交互终端,已跳过远程桌面授权。");
60
+ recoveryHint(log);
61
+ return false;
62
+ }
63
+ const request = context.request ?? requestAuthorization;
64
+ let status = await withCliProgress("正在检查本机远程桌面权限", () => request(dataDir));
65
+ if (!status.supported)
66
+ throw new Error("远程桌面支持 macOS 14 及以上的 Apple Silicon 电脑和 Windows 10/11 x64");
67
+ if (!status.installed)
68
+ throw new Error("当前 CLI 未包含远程授权组件,请升级 CLI");
69
+ if (status.enabled) {
70
+ status = await request(dataDir, { action: "recheck" });
71
+ if (complete(status)) {
72
+ log("✓ 远程桌面已授权并开启共享。");
73
+ return true;
74
+ }
75
+ }
76
+ const platform = context.platform ?? process.platform;
77
+ const confirm = context.confirm ?? (() => confirmDesktopAuthorization(platform));
78
+ const confirmed = await (platform === "darwin"
79
+ ? withCliProgress("正在等待本机授权弹窗确认", confirm)
80
+ : withoutCliProgress(confirm));
81
+ if (!confirmed) {
82
+ log("已跳过远程桌面授权。");
83
+ recoveryHint(log);
84
+ return false;
85
+ }
86
+ status = await request(dataDir, { action: "recheck" });
87
+ const wait = context.wait ?? delay;
88
+ if (platform === "darwin") {
89
+ for (const permission of ["screen", "accessibility"]) {
90
+ if (status[permission])
91
+ continue;
92
+ const name = permission === "screen" ? "屏幕录制" : "辅助功能";
93
+ log(`正在请求${name}权限,请在系统设置中允许 AgentRoam Remote Desktop…`);
94
+ status = await withCliProgress(`正在等待${name}权限,请在系统弹窗或设置中允许`, async () => {
95
+ let current = await request(dataDir, { action: "request-permission", permission });
96
+ for (let attempt = 0; !current[permission] && attempt < (context.pollAttempts ?? 60); attempt++) {
97
+ await wait(2000);
98
+ current = await request(dataDir, { action: "recheck" });
99
+ }
100
+ return current;
101
+ });
102
+ if (!status[permission]) {
103
+ log(`${name}权限尚未授予,本次未开启共享。`);
104
+ recoveryHint(log);
105
+ return false;
106
+ }
107
+ }
108
+ }
109
+ if (!status.screen || !status.accessibility) {
110
+ log("远程桌面尚不可用,请确认系统权限及当前桌面状态,本次未开启共享。");
111
+ recoveryHint(log);
112
+ return false;
113
+ }
114
+ status = await withCliProgress("正在开启并检查远程桌面共享", () => request(dataDir, { action: "enable" }));
115
+ if (!complete(status))
116
+ throw new Error(status.error ? terminalText(status.error) : "远程桌面共享未就绪,请重新检查系统权限");
117
+ log("✓ 远程桌面授权完成,已开启共享。手机首次连接仍需设备配对。");
118
+ return true;
119
+ }
120
+ export async function runDesktopCommand(options, context = {}) {
121
+ const log = context.log ?? console.log;
122
+ const request = context.request ?? requestAuthorization;
123
+ if (options.desktopAction === "authorize") {
124
+ try {
125
+ if (!await authorizeRemoteDesktop(options.dataDir, context))
126
+ process.exitCode = 1;
127
+ }
128
+ catch (error) {
129
+ recoveryHint(log);
130
+ throw error;
131
+ }
132
+ return;
133
+ }
134
+ let status = await request(options.dataDir);
135
+ if (options.desktopAction === "disable") {
136
+ status = await request(options.dataDir, { action: "disable" });
137
+ if (status.enabled)
138
+ throw new Error("远程桌面共享尚未关闭,请重试");
139
+ log("✓ 已关闭远程桌面共享,系统权限保留。重新开启:agentroam desktop authorize");
140
+ return;
141
+ }
142
+ if (status.supported && status.installed)
143
+ status = await request(options.dataDir, { action: "recheck" });
144
+ log(`远程桌面:${status.supported ? "支持" : "不支持"};共享:${status.enabled ? "已开启" : "未开启"};屏幕录制:${status.screen ? "已授权" : "未授权或桌面不可用"};辅助功能:${status.accessibility ? "已授权" : "未授权"}`);
145
+ if (status.error)
146
+ log(`状态提示:${terminalText(status.error)}`);
147
+ recoveryHint(log);
148
+ }
@@ -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,
@@ -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"
@@ -28,6 +28,47 @@ fi
28
28
  TEMP_ROOT=""
29
29
  OWN_NODE_LOCK=0
30
30
  OWN_LAUNCHER_LOCK=0
31
+ PROGRESS_PID=""
32
+
33
+ # Works before Node exists and never reads stdin (curl | sh owns it).
34
+ stop_progress() {
35
+ if [ -n "$PROGRESS_PID" ]; then
36
+ kill "$PROGRESS_PID" 2>/dev/null || true
37
+ wait "$PROGRESS_PID" 2>/dev/null || true
38
+ PROGRESS_PID=""
39
+ fi
40
+ }
41
+
42
+ start_progress() {
43
+ stop_progress
44
+ [ "${AGENTROAM_NO_PROGRESS:-}" != "1" ] || return 0
45
+ printf '… %s(已等待 0 秒)\n' "$1" >&2
46
+ (
47
+ progress_label=$1
48
+ progress_started=$(date +%s)
49
+ progress_sleep_pid=""
50
+ trap 'if [ -n "$progress_sleep_pid" ]; then kill "$progress_sleep_pid" 2>/dev/null || true; wait "$progress_sleep_pid" 2>/dev/null || true; fi' EXIT
51
+ trap 'exit 0' HUP INT TERM
52
+ while :; do
53
+ sleep 5 &
54
+ progress_sleep_pid=$!
55
+ wait "$progress_sleep_pid" || exit 0
56
+ progress_sleep_pid=""
57
+ progress_now=$(date +%s)
58
+ printf '… %s(已等待 %s 秒)\n' "$progress_label" "$((progress_now - progress_started))" >&2
59
+ done
60
+ ) </dev/null &
61
+ PROGRESS_PID=$!
62
+ }
63
+
64
+ run_with_progress() {
65
+ progress_command_label=$1
66
+ shift
67
+ start_progress "$progress_command_label"
68
+ if "$@"; then progress_command_status=0; else progress_command_status=$?; fi
69
+ stop_progress
70
+ return "$progress_command_status"
71
+ }
31
72
 
32
73
  fail() {
33
74
  printf 'agentroam installer: %s\n' "$*" >&2
@@ -35,6 +76,7 @@ fail() {
35
76
  }
36
77
 
37
78
  cleanup() {
79
+ stop_progress
38
80
  if [ -n "$TEMP_ROOT" ] && [ -d "$TEMP_ROOT" ]; then
39
81
  rm -rf "$TEMP_ROOT"
40
82
  fi
@@ -121,6 +163,7 @@ acquire_directory_lock() {
121
163
  done
122
164
  }
123
165
 
166
+ start_progress "正在检查可用的 Node.js"
124
167
  SYSTEM_NODE=$(command -v node 2>/dev/null || true)
125
168
  if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ "${AGENTROAM_FORCE_PRIVATE_NODE:-}" = "1" ]; then
126
169
  SYSTEM_NODE=""
@@ -138,7 +181,9 @@ else
138
181
  printf 'Using Node.js %s from NVM: %s\n' "$("$NODE_BIN" --version)" "$NODE_BIN"
139
182
  else
140
183
  if ! runtime_is_valid; then
184
+ start_progress "正在等待 Node.js 安装锁"
141
185
  acquire_directory_lock "$NODE_LOCK"
186
+ stop_progress
142
187
  OWN_NODE_LOCK=1
143
188
  if ! runtime_is_valid; then
144
189
  TEMP_ROOT=$(mktemp -d "$NODE_PARENT/.node-$NODE_VERSION.XXXXXX")
@@ -146,6 +191,7 @@ else
146
191
  extract_path="$TEMP_ROOT/extract"
147
192
  mkdir "$extract_path"
148
193
  printf 'Downloading Node.js %s...\n' "$NODE_VERSION"
194
+ start_progress "正在下载 Node.js $NODE_VERSION"
149
195
  if [ -n "${AGENTROAM_NODE_ARCHIVE_FILE:-}" ]; then
150
196
  cp "$AGENTROAM_NODE_ARCHIVE_FILE" "$archive_path"
151
197
  else
@@ -156,8 +202,10 @@ else
156
202
  sleep "$download_attempt"
157
203
  done
158
204
  fi
205
+ start_progress "正在校验 Node.js 安装包"
159
206
  actual_sha=$(shasum -a 256 "$archive_path" | awk '{print $1}')
160
207
  [ "$actual_sha" = "$NODE_SHA256" ] || fail "Node.js archive checksum mismatch"
208
+ start_progress "正在解压并验证 Node.js"
161
209
  /usr/bin/tar -xJf "$archive_path" -C "$extract_path"
162
210
  entry_count=$(find "$extract_path" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')
163
211
  [ "$entry_count" = "1" ] || fail "unexpected Node.js archive layout"
@@ -177,6 +225,7 @@ else
177
225
  NODE_BIN="$NODE_ROOT/bin/node"
178
226
  fi
179
227
  fi
228
+ stop_progress
180
229
 
181
230
  if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ "${AGENTROAM_BOOTSTRAP_NODE_DISCOVERY_ONLY:-}" = "1" ]; then
182
231
  printf '%s\n' "$NODE_BIN"
@@ -205,7 +254,9 @@ case "$AGENTROAM_VERSION_REQUEST" in
205
254
  *)
206
255
  # A dist-tag (default "preview") — resolve to the newest published version
207
256
  # so installs always pick up the latest release without touching this file.
257
+ start_progress "正在查询 AgentRoam $AGENTROAM_VERSION_REQUEST 最新版本"
208
258
  resolved_version="$("$NODE_BIN" "$NPM_CLI" view "agentroam@$AGENTROAM_VERSION_REQUEST" version --registry "$NPM_REGISTRY" 2>/dev/null | tail -1 | tr -d '[:space:]')"
259
+ stop_progress
209
260
  [ -n "$resolved_version" ] || fail "could not resolve agentroam@$AGENTROAM_VERSION_REQUEST from $NPM_REGISTRY"
210
261
  AGENTROAM_VERSION="$resolved_version"
211
262
  printf 'Installing AgentRoam %s (resolved from %s)\n' "$AGENTROAM_VERSION" "$AGENTROAM_VERSION_REQUEST"
@@ -225,14 +276,18 @@ launcher_is_valid() {
225
276
  && [ "$("$NODE_BIN" "$LAUNCHER_ROOT/node_modules/agentroam/bin/agentroam.mjs" version 2>/dev/null | awk '{print $2}' || true)" = "$AGENTROAM_VERSION" ]
226
277
  }
227
278
 
279
+ start_progress "正在检查已安装的 CLI"
228
280
  if ! launcher_is_valid; then
281
+ start_progress "正在等待 CLI 安装锁"
229
282
  acquire_directory_lock "$LAUNCHER_LOCK"
283
+ stop_progress
230
284
  OWN_LAUNCHER_LOCK=1
231
285
  if ! launcher_is_valid; then
232
286
  TEMP_ROOT=$(mktemp -d "$LAUNCHER_PARENT/.launcher-$AGENTROAM_VERSION.XXXXXX")
233
287
  # Test-only extra specs are whitespace-delimited artifact paths in isolated CI directories.
234
288
  # shellcheck disable=SC2086
235
- "$NODE_BIN" "$NPM_CLI" install --no-audit --no-fund --registry "$NPM_REGISTRY" --prefix "$TEMP_ROOT" $EXTRA_PACKAGE_SPECS "$PACKAGE_SPEC"
289
+ run_with_progress "正在下载和安装 CLI 及平台组件" "$NODE_BIN" "$NPM_CLI" install --no-audit --no-fund --registry "$NPM_REGISTRY" --prefix "$TEMP_ROOT" $EXTRA_PACKAGE_SPECS "$PACKAGE_SPEC"
290
+ start_progress "正在验证并启用 CLI"
236
291
  entry="$TEMP_ROOT/node_modules/agentroam/bin/agentroam.mjs"
237
292
  [ -f "$entry" ] || fail "AgentRoam launcher is missing after npm install"
238
293
  [ "$("$NODE_BIN" "$entry" version | awk '{print $2}')" = "$AGENTROAM_VERSION" ] || fail "AgentRoam version validation failed"
@@ -243,6 +298,7 @@ if ! launcher_is_valid; then
243
298
  rmdir "$LAUNCHER_LOCK" 2>/dev/null || true
244
299
  OWN_LAUNCHER_LOCK=0
245
300
  fi
301
+ stop_progress
246
302
 
247
303
  entry="$LAUNCHER_ROOT/node_modules/agentroam/bin/agentroam.mjs"
248
304
  wrapper_temp=$(mktemp "$WRAPPER_DIR/.agentroam.XXXXXX")
@@ -253,18 +309,26 @@ wrapper_temp=$(mktemp "$WRAPPER_DIR/.agentroam.XXXXXX")
253
309
  chmod 755 "$wrapper_temp"
254
310
  mv "$wrapper_temp" "$WRAPPER_PATH"
255
311
 
256
- if ! "$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"; then
312
+ if ! run_with_progress "正在检查运行组件" env AGENTROAM_NO_PROGRESS=1 "$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"; then
257
313
  printf 'Warning: some component checks failed; continuing background service installation. Affected features may be unavailable. Run agentroam doctor to retry.\n' >&2
258
314
  fi
259
315
  if [ "${AGENTROAM_INSTALL_SKIP_SERVICE:-}" = "1" ]; then
260
316
  printf 'AgentRoam service registration skipped for isolated verification.\n'
261
317
  else
318
+ # The CLI owns progress here so it can pause for confirmations and print QR intact.
319
+ printf '… 正在安装后台服务;随后检查远程桌面授权并生成手机连接二维码\n' >&2
262
320
  codex_setup_module="$(dirname "$entry")/../dist/codex-desktop-setup.js"
263
- if [ -f "$codex_setup_module" ]; then
264
- "$NODE_BIN" "$entry" service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR" --setup-codex-desktop
265
- else
266
- printf '当前发布的 CLI 尚未包含官方 Codex 桌面共享配置;扫码可连接 AgentRoam,原桌面会话暂未共享,请升级 CLI 后重试。\n'
267
- "$NODE_BIN" "$entry" service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR"
321
+ install_background_service() {
322
+ if [ -f "$codex_setup_module" ]; then
323
+ "$NODE_BIN" "$entry" service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR" --setup-codex-desktop
324
+ else
325
+ printf '当前发布的 CLI 尚未包含官方 Codex 桌面共享配置;扫码可连接 AgentRoam,原桌面会话暂未共享,请升级 CLI 后重试。\n'
326
+ "$NODE_BIN" "$entry" service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR"
327
+ fi
328
+ }
329
+ if ! install_background_service; then
330
+ printf 'CLI 已安装,但后台服务安装或启动失败,尚不能扫码连接。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。\n' >&2
331
+ exit 1
268
332
  fi
269
333
  fi
270
334
  printf '\nAgentRoam %s installed: %s\n' "$AGENTROAM_VERSION" "$WRAPPER_PATH"
@@ -288,7 +352,7 @@ case "$desktop_choice" in
288
352
  y|Y|yes|YES|是)
289
353
  desktop_helper="$LAUNCHER_ROOT/node_modules/agentroam/bin/desktop-download.mjs"
290
354
  if [ -f "$desktop_helper" ]; then
291
- "$NODE_BIN" "$desktop_helper" "$AGENTROAM_VERSION" || printf 'CLI 安装已完成,桌面端可稍后重新下载。\n'
355
+ run_with_progress "正在下载并校验桌面端安装包" env AGENTROAM_NO_PROGRESS=1 "$NODE_BIN" "$desktop_helper" "$AGENTROAM_VERSION" || printf 'CLI 安装已完成,桌面端可稍后重新下载。\n'
292
356
  else
293
357
  printf '当前发布的 CLI 尚未包含桌面下载功能,请在新版发布后重试。CLI 安装已完成。\n'
294
358
  fi
@@ -3,13 +3,13 @@
3
3
  "installers": {
4
4
  "darwin-arm64": {
5
5
  "fileName": "install-agentroam.sh",
6
- "sha256": "8ae51c123a05aa32f1483baf6d80ff7c5dbe0cc638578402e2a65c5e314f90de",
7
- "size": 12028
6
+ "sha256": "9fad4cb6528e0b71224c40c4eece94881a67191b165ccb673b980498b8646487",
7
+ "size": 14575
8
8
  },
9
9
  "windows-amd64": {
10
10
  "fileName": "install-agentroam.ps1",
11
- "sha256": "4a3683a3e6027e15356dab7a5c7d02a81ad1be3f4bad61a6119328ed2ab89298",
12
- "size": 13155
11
+ "sha256": "5df75c9a139997cfee382ef824216b92bf4a68eba679fbc5be01102019ce1652",
12
+ "size": 13309
13
13
  }
14
14
  }
15
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentroam",
3
- "version": "0.2.0-preview.37",
3
+ "version": "0.2.0-preview.39",
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.37",
30
- "agentroam-runtime-win32-x64": "0.2.0-preview.37",
31
- "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.36",
32
- "agentroam-cloudflared-win32-x64": "0.2.0-preview.36",
33
- "agentroam-tui-darwin-arm64": "0.2.0-preview.36",
34
- "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.36"
29
+ "agentroam-runtime-darwin-arm64": "0.2.0-preview.39",
30
+ "agentroam-runtime-win32-x64": "0.2.0-preview.39",
31
+ "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.39",
32
+ "agentroam-cloudflared-win32-x64": "0.2.0-preview.39",
33
+ "agentroam-tui-darwin-arm64": "0.2.0-preview.39",
34
+ "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.39"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",