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.
@@ -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) {
@@ -24,7 +24,7 @@ export async function runCodexDesktopCommand(options, context = {}) {
24
24
  const codex = await (context.codexResolver ?? resolveCodexRuntime)({
25
25
  dataDir: options.dataDir,
26
26
  target: detectPlatform(platform, process.arch, process.versions.node),
27
- environment,
27
+ environment: { ...environment },
28
28
  platform,
29
29
  nodeExecutable: process.execPath,
30
30
  onProgress: (message) => log(`… ${message}`),
@@ -32,13 +32,17 @@ export async function runCodexDesktopCommand(options, context = {}) {
32
32
  environment.AGENT_CODEX_BIN = codex.executable;
33
33
  }
34
34
  const args = [scriptPath];
35
+ if (!options.desktopDryRun) {
36
+ environment.AGENTROAM_CODEX_DESKTOP_PREFLIGHT_NODE = process.execPath;
37
+ environment.AGENTROAM_CODEX_DESKTOP_PREFLIGHT = fileURLToPath(new URL("./codex-desktop-preflight.js", import.meta.url));
38
+ }
35
39
  if (options.desktopRestart)
36
40
  args.push("--restart");
37
41
  if (options.desktopDryRun)
38
42
  args.push("--dry-run");
39
43
  try {
40
44
  const { stdout, stderr } = await (context.execute ?? execFileAsync)("/bin/bash", args, {
41
- env: environment, encoding: "utf8", timeout: 30_000, maxBuffer: 1024 * 1024,
45
+ env: environment, encoding: "utf8", timeout: 60_000, maxBuffer: 1024 * 1024,
42
46
  });
43
47
  if (stdout.trim())
44
48
  log(stdout.trimEnd());
@@ -0,0 +1,57 @@
1
+ import { execFile } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { promisify } from "node:util";
4
+ import { pathToFileURL } from "node:url";
5
+ import { DesktopProbeRpcError, readDesktopActivity, withDesktopRpc } from "./codex-desktop-probe.js";
6
+ const execFileAsync = promisify(execFile);
7
+ async function inspectBackend(endpoint) {
8
+ return withDesktopRpc(endpoint, async (request, becameActive) => {
9
+ try {
10
+ await request("config/read", {});
11
+ try {
12
+ await request("account/gatewayOAuth/read", {});
13
+ }
14
+ catch (error) {
15
+ // Older servers may not have the Desktop gateway RPC.
16
+ if (!(error instanceof DesktopProbeRpcError && error.code === -32601))
17
+ throw error;
18
+ }
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ if (!(error instanceof DesktopProbeRpcError) || !/No such file or directory|os error 2/.test(error.message))
23
+ throw error;
24
+ const activity = await readDesktopActivity(request);
25
+ if (activity.state !== "idle" || becameActive())
26
+ throw new Error("共享后端配置失效,但任务忙碌或状态无法确认;已保留桌面和共享后端");
27
+ return false;
28
+ }
29
+ });
30
+ }
31
+ /** Repair only a missing-directory backend after two complete idle snapshots. */
32
+ export async function preflightCodexDesktop(endpoint, executable, context = {}) {
33
+ if (await inspectBackend(endpoint))
34
+ return;
35
+ if (await inspectBackend(endpoint))
36
+ return;
37
+ (context.log ?? console.log)("共享后端的工作目录已失效,已确认全部任务空闲,正在从稳定目录重建…");
38
+ await (context.restart ?? (async () => {
39
+ await execFileAsync(executable, ["app-server", "daemon", "restart"], {
40
+ cwd: homedir(), env: process.env, timeout: 15_000, maxBuffer: 1024 * 1024,
41
+ });
42
+ }))();
43
+ if (!await inspectBackend(endpoint))
44
+ throw new Error("共享后端重建后仍无法读取配置;已保留桌面");
45
+ }
46
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
47
+ const [endpoint, executable] = process.argv.slice(2);
48
+ if (!endpoint || !executable) {
49
+ console.error("Missing shared backend endpoint or Codex executable");
50
+ process.exitCode = 1;
51
+ }
52
+ else
53
+ preflightCodexDesktop(endpoint, executable).catch(error => {
54
+ console.error(`共享后端检查失败:${error instanceof Error ? error.message : error}`);
55
+ process.exitCode = 1;
56
+ });
57
+ }
@@ -1,6 +1,24 @@
1
1
  import { Agent, WebSocket } from "undici";
2
+ export class DesktopProbeRpcError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ }
8
+ }
2
9
  /** Read live state only. Never resume, subscribe, interrupt or unload a thread. */
3
10
  export async function probeDesktopActivity(endpoint, timeoutMs = 5_000) {
11
+ try {
12
+ return await withDesktopRpc(endpoint, async (request, becameActive) => {
13
+ const activity = await readDesktopActivity(request);
14
+ return becameActive() && activity.state === "idle" ? { state: "busy", activeCount: 1 } : activity;
15
+ }, timeoutMs);
16
+ }
17
+ catch {
18
+ return { state: "unknown", reason: "无法完整查询桌面端的实时任务状态" };
19
+ }
20
+ }
21
+ export async function withDesktopRpc(endpoint, operation, timeoutMs = 5_000) {
4
22
  let dispatcher;
5
23
  let socket;
6
24
  let timer;
@@ -49,7 +67,7 @@ export async function probeDesktopActivity(endpoint, timeoutMs = 5_000) {
49
67
  return;
50
68
  pending.delete(message.id);
51
69
  if (message.error)
52
- item.reject(new Error("Desktop probe RPC failed"));
70
+ item.reject(new DesktopProbeRpcError(message.error.code, String(message.error.message ?? "Desktop probe RPC failed")));
53
71
  else
54
72
  item.resolve(message.result);
55
73
  }
@@ -68,21 +86,17 @@ export async function probeDesktopActivity(endpoint, timeoutMs = 5_000) {
68
86
  reject(error);
69
87
  }
70
88
  });
71
- const operation = async () => {
89
+ const run = async () => {
72
90
  await opened;
73
- await request("initialize", { clientInfo: { name: "agentroam_desktop_setup", version: "1" } });
91
+ await request("initialize", { clientInfo: { name: "agentroam_desktop_setup", version: "1" }, capabilities: { experimentalApi: true } });
74
92
  connection.send(JSON.stringify({ method: "initialized", params: {} }));
75
- const activity = await readDesktopActivity(request);
76
- return changedToActive && activity.state === "idle" ? { state: "busy", activeCount: 1 } : activity;
93
+ return operation(request, () => changedToActive);
77
94
  };
78
95
  return await Promise.race([
79
- operation(),
96
+ run(),
80
97
  new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Desktop probe timed out")), timeoutMs); }),
81
98
  ]);
82
99
  }
83
- catch {
84
- return { state: "unknown", reason: "无法完整查询桌面端的实时任务状态" };
85
- }
86
100
  finally {
87
101
  if (timer)
88
102
  clearTimeout(timer);
@@ -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
+ }