agent-hub-runner 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/autostart.mjs +114 -4
- package/browser.mjs +430 -0
- package/package.json +3 -2
- package/runner.mjs +94 -17
package/README.md
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<!-- @意图#R729(2026-11-04): 加 browser install / status 和「bot 的浏览器」一节(0.2.0) -->
|
|
1
2
|
<!-- @意图#R690(2026-11-01): 加「改动记录」一节:存在哪、打码、64 KB、30 天、怎么删 -->
|
|
2
3
|
<!-- @意图#R666(2026-10-29): 改成不用全局安装的 npx 一条命令(码@地址)、Mac 开机自启、stop / uninstall、常见报错 -->
|
|
3
4
|
<!-- @意图#R664(2026-10-29): npm 包 agent-hub-runner 的说明(随包发布):给客户看的安装、配对、再连、查看、断开、卸载和安全提示 -->
|
|
@@ -48,6 +49,8 @@ npx -y agent-hub-runner@latest autostart off # 关掉开机自启
|
|
|
48
49
|
npx -y agent-hub-runner@latest autostart status
|
|
49
50
|
npx -y agent-hub-runner@latest stop # 停掉在跑的运行器(开机自启的下次开机还会起)
|
|
50
51
|
npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复制的程序;会问要不要删配置
|
|
52
|
+
npx -y agent-hub-runner@latest browser install # 给 bot 装它自己的浏览器(0.2.0 起,约 200MB,只要一次)
|
|
53
|
+
npx -y agent-hub-runner@latest browser status # 看浏览器装没装
|
|
51
54
|
```
|
|
52
55
|
|
|
53
56
|
开机自启时,程序复制到 `~/.agent-runner/bin/<版本>/`,日志在 `~/.agent-runner/logs/runner.log`(超过 5MB 自动清空)。网页上提示「有新版本」时,按提示运行那一条升级命令。
|
|
@@ -80,7 +83,16 @@ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复
|
|
|
80
83
|
- 存之前会把像密钥的字符串换成 `***`(`sk-…`、`ghp_…`、连接串里的密码、私钥、`XXX_KEY=…` 这类赋值);每条最多 64 KB;只留 30 天,过期自动删。电脑不在线时网页上看不到。
|
|
81
84
|
- 不想留:直接删掉 `edits/` 文件夹,不影响别的。
|
|
82
85
|
|
|
86
|
+
## bot 的浏览器(0.2.0 起)
|
|
87
|
+
|
|
88
|
+
- bot 能在你电脑上用一只**它自己的浏览器**(独立的 Chromium,不碰你平时用的浏览器)。先运行一次 `npx -y agent-hub-runner@latest browser install`:装到 `~/.agent-runner/browser/`(playwright-core)和 `~/.agent-runner/browsers/`(Chromium)。先走官方源,下不动会自动换国内镜像(npmmirror)。装好不用重启运行器。
|
|
89
|
+
- 每个 bot 一个资料目录 `~/.agent-runner/browser-profiles/<bot>/`,登录状态会保存。平时在后台(没有窗口)跑,闲 10 分钟自动关。
|
|
90
|
+
- 要登录、扫码、过验证码:在网页「我的电脑」抽屉里点「打开 bot 的浏览器」,会弹出这个 bot 的浏览器窗口,你弄好后直接关掉窗口,bot 接着在后台用。
|
|
91
|
+
- 不想让 bot 用浏览器:网页右栏「bot设置」里取消勾「允许用浏览器」。想清掉登录状态:删掉那个 bot 的资料目录。
|
|
92
|
+
- 卸载:删掉 `~/.agent-runner/browser`、`~/.agent-runner/browsers`、`~/.agent-runner/browser-profiles` 三个文件夹。
|
|
93
|
+
|
|
83
94
|
## 安全提示
|
|
84
95
|
|
|
85
96
|
- 别把放着密钥、令牌的文件夹授权出去:bot 在授权文件夹里读、写、改文件不用你批准。
|
|
86
97
|
- 跑命令前网页上会弹批准卡片,看清命令再点「允许一次」;命令本身能碰到文件夹以外的东西(没有沙箱)。
|
|
98
|
+
- bot 用浏览器不弹批准卡片,能打开任何网址;在 bot 的浏览器里登录过的网站,bot 都能以你的身份操作。别在里面登录你不想让 bot 碰的账号(网银、支付等)。
|
package/autostart.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
12
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync } from "node:fs";
|
|
13
|
+
// @意图#R725(2026-11-03): Windows 开机自启用计划任务(schtasks)。和 Mac 一样:登录时拉起、崩溃了重新跑、正常退出不拉起。
|
|
13
14
|
import os from "node:os";
|
|
14
15
|
import path from "node:path";
|
|
15
16
|
|
|
@@ -17,11 +18,14 @@ export const LABEL = "com.agenthub.runner";
|
|
|
17
18
|
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
18
19
|
export const THROTTLE_S = 10;
|
|
19
20
|
/** 复制到 ~/.agent-runner/bin/<版本>/ 的文件(package.json 用来读版本号) */
|
|
21
|
+
// @意图#R729(2026-11-04): 多复制 browser.mjs(runner.mjs 要 import 它)
|
|
20
22
|
// @意图#R690(2026-11-01): 多复制 changes.mjs(runner.mjs 要 import 它,少了开机自启的副本起不来)
|
|
21
|
-
export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "autostart.mjs", "package.json"];
|
|
23
|
+
export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "browser.mjs", "autostart.mjs", "package.json"];
|
|
22
24
|
/** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
|
|
23
25
|
export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
|
|
24
|
-
export const ONLY_MAC = "开机自启目前只支持 Mac";
|
|
26
|
+
export const ONLY_MAC = "开机自启目前只支持 Mac 和 Windows";
|
|
27
|
+
// @意图#R725(2026-11-03): Windows 计划任务名前缀。默认配置是 AgentHubRunner,别的配置加路径 sha256 前 8 位,一份配置一个任务。
|
|
28
|
+
export const TASK_PREFIX = "AgentHubRunner";
|
|
25
29
|
|
|
26
30
|
const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
|
|
27
31
|
|
|
@@ -87,8 +91,10 @@ ${envXml}
|
|
|
87
91
|
`;
|
|
88
92
|
}
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
// @意图#R725(2026-11-03): Windows 建软链要管理员权限(或开开发者模式),小白没有。所以 Windows 用一个 current.txt 记当前版本号,
|
|
95
|
+
// 包装脚本读它来找要跑的 runner.mjs。Mac 仍用 current 软链(先建临时链再改名,换的那一下不会断)。
|
|
96
|
+
/** 把运行器复制到 <home>/bin/<版本>/。返回要跑的 runner.mjs 路径。 */
|
|
97
|
+
export function installCopy({ srcDir, version, env = process.env, platform = process.platform }) {
|
|
92
98
|
const binRoot = path.join(runnerHome(env), "bin");
|
|
93
99
|
const dest = path.join(binRoot, version);
|
|
94
100
|
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
@@ -98,6 +104,10 @@ export function installCopy({ srcDir, version, env = process.env }) {
|
|
|
98
104
|
if (existsSync(from)) copyFileSync(from, path.join(dest, f));
|
|
99
105
|
}
|
|
100
106
|
}
|
|
107
|
+
if (platform === "win32") {
|
|
108
|
+
writeFileSync(path.join(binRoot, "current.txt"), version);
|
|
109
|
+
return path.join(dest, "runner.mjs");
|
|
110
|
+
}
|
|
101
111
|
const cur = path.join(binRoot, "current");
|
|
102
112
|
const tmp = path.join(binRoot, `.current-${process.pid}`);
|
|
103
113
|
rmSync(tmp, { force: true });
|
|
@@ -179,6 +189,106 @@ export function otherAutostarts(configFile, env = process.env) {
|
|
|
179
189
|
try { return readdirSync(launchAgentsDir(env)).filter((f) => f.startsWith(LABEL) && f.endsWith(".plist") && f !== mine); } catch { return []; }
|
|
180
190
|
}
|
|
181
191
|
|
|
192
|
+
// @意图#R725(2026-11-03): Windows 开机自启用计划任务,语义对齐 Mac 的 launchd:
|
|
193
|
+
// - 触发器是「登录时」(/sc onlogon),跑在用户自己的桌面会话里。不能用 SYSTEM 或开机即跑(那样在 Session 0,选目录的窗口弹不出来)。
|
|
194
|
+
// - 退出码不是 0 时重新跑(RestartCount 999、间隔 1 分钟),对齐 KeepAlive 的 SuccessfulExit=false:被网页断开、令牌失效、被顶掉都是 0 退出,不会被反复拉起;崩溃了才会重跑。
|
|
195
|
+
// - 不设 /rl highest:普通权限就够,而且提权会弹 UAC,小白配不上。
|
|
196
|
+
// - 任务跑一个 .cmd:计划任务的 /tr 不支持带空格的参数和重定向,node 装在 Program Files 里,所以包一层。日志追加到和 Mac 同一个文件。
|
|
197
|
+
const psQuote = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
198
|
+
|
|
199
|
+
/** 计划任务名:默认配置 AgentHubRunner,别的配置加 sha8 后缀(对齐 labelFor) */
|
|
200
|
+
export const taskNameFor = (configFile, env = process.env) => { const t = configTag(configFile, env); return t ? `${TASK_PREFIX}-${t}` : TASK_PREFIX; };
|
|
201
|
+
|
|
202
|
+
/** 计划任务跑的包装脚本:<home>/bin/start-<任务名>.cmd,日志追加到 logFileFor 那个文件 */
|
|
203
|
+
export function taskCmdFor(configFile, env = process.env) {
|
|
204
|
+
return path.join(runnerHome(env), "bin", `start-${taskNameFor(configFile, env)}.cmd`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// @意图#R725(2026-11-03): 不靠计划任务的「失败重拉」。实测 schtasks 的 RestartOnFailure 在被 taskkill 强杀时不重拉
|
|
208
|
+
// (任务记的结果是 1,但不再起)。所以在包装脚本里自己循环:退出码不是 0 就等 10 秒再跑,对齐 Mac 的
|
|
209
|
+
// KeepAlive SuccessfulExit=false。正常退出(被网页断开、令牌失效、Ctrl-C,都是 0)不重跑。
|
|
210
|
+
// 脚本路径不写死:从 current.txt 读当前版本号再拼(升级后不用改这个包装脚本)。binDir 是 <home>/bin。
|
|
211
|
+
/** 生成包装脚本的内容。node 是绝对路径,配置路径经环境变量传(和 Mac 的 plist 一样)。 */
|
|
212
|
+
export function taskCmdText({ node, binDir, configFile, log }) {
|
|
213
|
+
return [
|
|
214
|
+
"@echo off",
|
|
215
|
+
"setlocal EnableDelayedExpansion",
|
|
216
|
+
`set AGENT_RUNNER_CONFIG=${configFile}`,
|
|
217
|
+
"set AGENT_RUNNER_LAUNCHD=1",
|
|
218
|
+
":loop",
|
|
219
|
+
"set /p VER=<\"" + binDir + "\\current.txt\"",
|
|
220
|
+
"\"" + node + "\" \"" + binDir + "\\!VER!\\runner.mjs\" start >> \"" + log + "\" 2>&1",
|
|
221
|
+
"if %ERRORLEVEL%==0 goto end",
|
|
222
|
+
"timeout /t 10 /nobreak > nul",
|
|
223
|
+
"goto loop",
|
|
224
|
+
":end",
|
|
225
|
+
"",
|
|
226
|
+
].join("\r\n");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 调 schtasks,返回 { code, stdout, stderr },不抛错。AGENT_RUNNER_SCHTASKS 可换成假程序(测试用)。输出是 GBK,转成 UTF-8。 */
|
|
230
|
+
export function runSchtasks(args, env = process.env) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
execFile(env.AGENT_RUNNER_SCHTASKS || "schtasks", args, { encoding: "buffer" }, (err, stdout, stderr) => {
|
|
233
|
+
const dec = (b) => { try { return new TextDecoder("gbk").decode(b); } catch { return String(b ?? ""); } };
|
|
234
|
+
resolve({ code: err ? (typeof err.code === "number" ? err.code : 1) : 0, stdout: dec(stdout), stderr: dec(stderr) });
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 开启开机自启(Windows):复制自己、写包装脚本、建计划任务(登录时跑,失败重跑),马上起一次。返回 { task, cmd, node, log }。 */
|
|
240
|
+
export async function autostartOnWindows({ configFile, version, srcDir, execPath = process.execPath, env = process.env, schtasks }) {
|
|
241
|
+
schtasks ??= (a) => runSchtasks(a, env);
|
|
242
|
+
const cfg = path.resolve(configFile);
|
|
243
|
+
if (!existsSync(cfg)) throw new Error(`还没配对:没有 ${cfg},配对成功后才能设开机自启`);
|
|
244
|
+
const script = installCopy({ srcDir, version, env, platform: "win32" });
|
|
245
|
+
const node = pickNode(execPath);
|
|
246
|
+
const log = logFileFor(cfg, env);
|
|
247
|
+
mkdirSync(path.dirname(log), { recursive: true });
|
|
248
|
+
const cmd = taskCmdFor(cfg, env);
|
|
249
|
+
writeFileSync(cmd, taskCmdText({ node, binDir: path.join(runnerHome(env), "bin"), configFile: cfg, log }));
|
|
250
|
+
const name = taskNameFor(cfg, env);
|
|
251
|
+
const user = env.USERNAME;
|
|
252
|
+
if (!user) throw new Error("开机自启没设成:读不到当前用户名");
|
|
253
|
+
const args = ["/create", "/tn", name, "/tr", cmd, "/sc", "onlogon", "/ru", user, "/rl", "limited", "/f"];
|
|
254
|
+
const r = await schtasks(args);
|
|
255
|
+
if (r.code !== 0) throw new Error(`开机自启没设成:计划任务创建失败(${(r.stderr || r.stdout).trim() || r.code})`);
|
|
256
|
+
// 崩溃重跑由包装脚本自己的循环做(见 taskCmdText)。这里只补「用电池也跑」,笔记本合盖不中断。
|
|
257
|
+
const ps = `$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0); Set-ScheduledTask -TaskName ${psQuote(name)} -Settings $s | Out-Null`;
|
|
258
|
+
await new Promise((resolve) => execFile("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], () => resolve()));
|
|
259
|
+
await schtasks(["/run", "/tn", name]); // 马上起一次,不用等下次登录
|
|
260
|
+
return { task: name, cmd, node, log };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 关掉开机自启(Windows):停掉并删计划任务、删包装脚本。返回 { wasOn }。 */
|
|
264
|
+
export async function autostartOffWindows({ configFile, env = process.env, schtasks }) {
|
|
265
|
+
schtasks ??= (a) => runSchtasks(a, env);
|
|
266
|
+
const cfg = path.resolve(configFile);
|
|
267
|
+
const name = taskNameFor(cfg, env);
|
|
268
|
+
const cmd = taskCmdFor(cfg, env);
|
|
269
|
+
const wasOn = existsSync(cmd);
|
|
270
|
+
await schtasks(["/end", "/tn", name]);
|
|
271
|
+
await schtasks(["/delete", "/tn", name, "/f"]);
|
|
272
|
+
rmSync(cmd, { force: true });
|
|
273
|
+
return { wasOn };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 开机自启现在的样子(Windows):有没有包装脚本、任务在不在、状态。 */
|
|
277
|
+
export async function autostartStatusWindows({ configFile, env = process.env, schtasks }) {
|
|
278
|
+
schtasks ??= (a) => runSchtasks(a, env);
|
|
279
|
+
const cfg = path.resolve(configFile);
|
|
280
|
+
const name = taskNameFor(cfg, env);
|
|
281
|
+
const cmd = taskCmdFor(cfg, env);
|
|
282
|
+
const out = { supported: true, installed: existsSync(cmd), loaded: false, pid: null, task: name, cmd, log: logFileFor(cfg, env), problems: [] };
|
|
283
|
+
const r = await schtasks(["/query", "/tn", name, "/fo", "csv"]);
|
|
284
|
+
if (r.code === 0) {
|
|
285
|
+
out.loaded = true;
|
|
286
|
+
const row = r.stdout.split(/\r?\n/).find((l) => l.includes(name));
|
|
287
|
+
if (row && /running/i.test(row)) out.pid = 0; // 计划任务查不到进程号,0 表示在跑但不知道是哪个
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
182
292
|
/** 日志超过 max 字节就清空(launchd 用追加方式打开,清空后接着往后写不会乱)。清了返回 true。 */
|
|
183
293
|
export function truncateLogIfBig(file, max = LOG_MAX_BYTES) {
|
|
184
294
|
try {
|
package/browser.mjs
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// @意图#R729(2026-11-04): bot 专属浏览器(king 同意 R729,方案里最初记作 R726)。每个 bot 在用户电脑上有一只自己的 Chromium,
|
|
2
|
+
// 和用户平时用的 Chrome 完全分开;登录状态按 bot 存在 ~/.agent-runner/browser-profiles/<botId>/,多个 bot 能同时各用各的。
|
|
3
|
+
// 运行器本身仍然零依赖(king 定乙):playwright-core 和 Chromium 只在用户运行 `agent-hub-runner browser install` 时才装进
|
|
4
|
+
// ~/.agent-runner/browser/ 和 ~/.agent-runner/browsers/,用到时按绝对路径动态加载;没装、版本不对都回大白话。
|
|
5
|
+
// bot 操作时默认后台跑(不显示窗口);网页上点「打开 bot 的浏览器」时换成有窗口的,bot 和用户共用这个窗口;用户关掉窗口后 bot 下次操作回到后台。
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { runnerHome } from "./autostart.mjs";
|
|
11
|
+
|
|
12
|
+
/** 装哪个版本的 playwright-core(钉死;换版本要一起改这里、测试和文档) */
|
|
13
|
+
export const PW_VERSION = "1.64.1";
|
|
14
|
+
/** 官方源失败后换的国内镜像 */
|
|
15
|
+
export const MIRROR_REGISTRY = "https://registry.npmmirror.com";
|
|
16
|
+
export const MIRROR_DOWNLOAD_HOST = "https://cdn.npmmirror.com/binaries/playwright";
|
|
17
|
+
/** 官方源下载 Chromium 最多等多久,超过就当失败换镜像(官方源在国内常常很慢) */
|
|
18
|
+
export const OFFICIAL_TIMEOUT_MS = 10 * 60_000;
|
|
19
|
+
/** 后台(无窗口)浏览器多久没人用就关掉 */
|
|
20
|
+
export const IDLE_CLOSE_MS = 10 * 60_000;
|
|
21
|
+
/** 元素清单最多列多少个、页面文字最多带多少字 */
|
|
22
|
+
export const MAX_ELEMENTS = 150;
|
|
23
|
+
export const MAX_PAGE_TEXT = 2000;
|
|
24
|
+
/** 后台模式的页面大小(截图就是这么大) */
|
|
25
|
+
export const HEADLESS_VIEWPORT = { width: 1280, height: 800 };
|
|
26
|
+
|
|
27
|
+
export const browserPkgDir = (env = process.env) => path.join(runnerHome(env), "browser");
|
|
28
|
+
export const browsersDir = (env = process.env) => path.join(runnerHome(env), "browsers");
|
|
29
|
+
export const profilesDir = (env = process.env) => path.join(runnerHome(env), "browser-profiles");
|
|
30
|
+
|
|
31
|
+
/** 装浏览器的命令(提示里用) */
|
|
32
|
+
export const installCmd = (prog = "agent-hub-runner") => `${prog} browser install`;
|
|
33
|
+
// @意图#R729(2026-11-04): 没装 / 版本不对时 bot 和网页看到的话:直接给命令,用户复制到终端运行就行
|
|
34
|
+
export const NOT_INSTALLED = (prog = "npx -y agent-hub-runner@latest") =>
|
|
35
|
+
`这台电脑还没装 bot 专用的浏览器。请主人在电脑终端里运行:${installCmd(prog)}(会下载 playwright-core 和一只独立的 Chromium,约 200MB,只要装一次)`;
|
|
36
|
+
export const VERSION_MISMATCH = (have, prog = "npx -y agent-hub-runner@latest") =>
|
|
37
|
+
`电脑上装的浏览器组件版本(${have})和运行器要的(${PW_VERSION})不一样。请主人在电脑终端里重新运行:${installCmd(prog)}`;
|
|
38
|
+
export const RETRY_TEXT = "浏览器刚换成了有窗口的模式(主人点了「打开 bot 的浏览器」),这一步没做完,请重试这一步。";
|
|
39
|
+
export const CLOSED_BY_USER = "浏览器窗口被关掉了,这一步没做完,请重试这一步(会在后台重新打开)。";
|
|
40
|
+
|
|
41
|
+
/** 这个 bot 的资料目录。bot_id 只认字母、数字、点、下划线、横线(防止拼出别的路径)。 */
|
|
42
|
+
// @意图#R729(2026-11-04): 每个 bot 一份资料目录(登录信息、cookie 都在这里,关掉再开还在),互不相干
|
|
43
|
+
export function profileDirFor(botId, env = process.env) {
|
|
44
|
+
if (typeof botId !== "string" || !/^[A-Za-z0-9._-]{1,100}$/.test(botId) || botId === "." || botId === "..") throw new Error(`bot_id 不合法:${String(botId)}`);
|
|
45
|
+
return path.join(profilesDir(env), botId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 不加载 playwright,只看文件:装没装、版本对不对、Chromium 在不在。 */
|
|
49
|
+
// @意图#R729(2026-11-04): 心跳里报给服务器(网页抽屉显示「未安装」),也在每次用浏览器前先查一遍
|
|
50
|
+
export function browserStatus(env = process.env) {
|
|
51
|
+
const pkg = path.join(browserPkgDir(env), "node_modules", "playwright-core", "package.json");
|
|
52
|
+
let version = null;
|
|
53
|
+
try { version = String(JSON.parse(readFileSync(pkg, "utf8")).version); } catch { return { installed: false, reason: "no_pkg", version: null }; }
|
|
54
|
+
if (version !== PW_VERSION) return { installed: false, reason: "version_mismatch", version };
|
|
55
|
+
const dir = chromiumDir(env);
|
|
56
|
+
if (!dir || !existsSync(path.join(dir, "INSTALLATION_COMPLETE"))) return { installed: false, reason: "no_chromium", version };
|
|
57
|
+
return { installed: true, reason: null, version };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 装好的 playwright-core 自带的 browsers.json 里 chromium 那一项(版本号目录、浏览器版本) */
|
|
61
|
+
function chromiumEntry(env) {
|
|
62
|
+
try {
|
|
63
|
+
const j = JSON.parse(readFileSync(path.join(browserPkgDir(env), "node_modules", "playwright-core", "browsers.json"), "utf8"));
|
|
64
|
+
return (j.browsers ?? []).find((b) => b.name === "chromium") ?? null;
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
/** Chromium 应该装在哪(按 browsers.json 算出版本号目录) */
|
|
68
|
+
function chromiumDir(env) {
|
|
69
|
+
const c = chromiumEntry(env);
|
|
70
|
+
return c?.revision ? path.join(browsersDir(env), `chromium-${c.revision}`) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 后台(无头)模式用的 User-Agent:和有窗口的 Chrome 一样,不带 HeadlessChrome。
|
|
75
|
+
* 不这么做百度等网站一搜就跳验证码(2026-11-04 真机验收时遇到)。这不是过验证码,只是别自报「我是无头浏览器」。
|
|
76
|
+
*/
|
|
77
|
+
// @意图#R729(2026-11-04): 真机验收发现百度对 HeadlessChrome 的 UA 直接跳图形验证码;无头时按平台 + 钉死的 Chromium 主版本号给一个普通 Chrome 的 UA
|
|
78
|
+
export function headlessUserAgent(env = process.env, platform = process.platform) {
|
|
79
|
+
const major = String(chromiumEntry(env)?.browserVersion ?? "").split(".")[0] || "156";
|
|
80
|
+
const os = platform === "darwin" ? "Macintosh; Intel Mac OS X 10_15_7" : platform === "win32" ? "Windows NT 10.0; Win64; x64" : "X11; Linux x86_64";
|
|
81
|
+
return `Mozilla/5.0 (${os}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 没装好就抛大白话 */
|
|
85
|
+
export function ensureInstalled(env = process.env, prog) {
|
|
86
|
+
const st = browserStatus(env);
|
|
87
|
+
if (st.installed) return st;
|
|
88
|
+
if (st.reason === "version_mismatch") throw new Error(VERSION_MISMATCH(st.version, prog));
|
|
89
|
+
throw new Error(NOT_INSTALLED(prog));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 跑一个命令,输出原样转给终端;返回退出码。timeoutMs 到了杀掉、算失败。 */
|
|
93
|
+
function runCmd(cmd, args, { env, timeoutMs, platform = process.platform } = {}) {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const win = platform === "win32";
|
|
96
|
+
// Windows 上 npm 是 npm.cmd,新版 Node 要 shell 才能跑 .cmd;参数里的路径加引号
|
|
97
|
+
const child = spawn(cmd, win ? args.map((a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args, { env, stdio: "inherit", shell: win });
|
|
98
|
+
let timer;
|
|
99
|
+
if (timeoutMs) timer = setTimeout(() => { try { child.kill(); } catch { /* 已经退出 */ } resolve({ code: -1, timedOut: true }); }, timeoutMs);
|
|
100
|
+
child.on("error", (e) => { clearTimeout(timer); resolve({ code: -1, error: e }); });
|
|
101
|
+
child.on("exit", (code) => { clearTimeout(timer); resolve({ code: code ?? -1 }); });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* agent-hub-runner browser install:先把 playwright-core 装进 ~/.agent-runner/browser/,再把 Chromium 下到 ~/.agent-runner/browsers/。
|
|
107
|
+
* 两步都是先官方源,失败(或官方下 Chromium 超过 10 分钟)再换国内镜像。返回每一步用的是哪个源。
|
|
108
|
+
*/
|
|
109
|
+
// @意图#R729(2026-11-04): king 定:先官方、失败再镜像(npm 用 registry.npmmirror.com,Chromium 用 cdn.npmmirror.com 的 playwright 镜像);
|
|
110
|
+
// 只装 Chromium 本体(--no-shell,不另下 headless shell),后台模式也用它(channel: "chromium"),有窗口和后台共用同一只浏览器、同一份资料目录
|
|
111
|
+
export async function installBrowser({ env = process.env, log = console.log, run = runCmd, platform = process.platform, officialTimeoutMs = OFFICIAL_TIMEOUT_MS } = {}) {
|
|
112
|
+
const dir = browserPkgDir(env);
|
|
113
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
114
|
+
const pj = path.join(dir, "package.json");
|
|
115
|
+
if (!existsSync(pj)) writeFileSync(pj, `${JSON.stringify({ name: "agent-hub-runner-browser", private: true, description: "agent-hub-runner browser install 装的浏览器组件" }, null, 2)}\n`);
|
|
116
|
+
const npm = platform === "win32" ? "npm.cmd" : "npm";
|
|
117
|
+
const npmArgs = ["install", "--no-audit", "--no-fund", "--save-exact", "--prefix", dir, `playwright-core@${PW_VERSION}`];
|
|
118
|
+
const used = { npm: "official", chromium: "official" };
|
|
119
|
+
const st = browserStatus(env);
|
|
120
|
+
if (st.version === PW_VERSION) log(`playwright-core ${PW_VERSION} 已经装过,跳过。`);
|
|
121
|
+
else {
|
|
122
|
+
log(`第 1 步:装 playwright-core ${PW_VERSION}(官方源)……`);
|
|
123
|
+
let r = await run(npm, npmArgs, { env, platform });
|
|
124
|
+
if (r.code !== 0) {
|
|
125
|
+
log("官方源没装成,换国内镜像 registry.npmmirror.com 再试……");
|
|
126
|
+
used.npm = "mirror";
|
|
127
|
+
r = await run(npm, [...npmArgs, "--registry", MIRROR_REGISTRY], { env, platform });
|
|
128
|
+
if (r.code !== 0) throw new Error("playwright-core 没装成(官方源和镜像都失败了)。检查网络后再运行一次同一条命令。");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const cli = path.join(dir, "node_modules", "playwright-core", "cli.js");
|
|
132
|
+
if (!existsSync(cli)) throw new Error(`装完了却找不到 ${cli},请再运行一次同一条命令。`);
|
|
133
|
+
const pwEnv = { ...env, PLAYWRIGHT_BROWSERS_PATH: browsersDir(env) };
|
|
134
|
+
const args = [cli, "install", "--no-shell", "chromium"];
|
|
135
|
+
log(`第 2 步:下载 Chromium 到 ${browsersDir(env)}(官方源,约 200MB)……`);
|
|
136
|
+
let r = await run(process.execPath, args, { env: pwEnv, platform, timeoutMs: officialTimeoutMs });
|
|
137
|
+
if (r.code !== 0) {
|
|
138
|
+
log(r.timedOut ? `官方源 ${Math.round(officialTimeoutMs / 60_000)} 分钟还没下完,换国内镜像再试……` : "官方源没下成,换国内镜像 cdn.npmmirror.com 再试……");
|
|
139
|
+
used.chromium = "mirror";
|
|
140
|
+
r = await run(process.execPath, args, { env: { ...pwEnv, PLAYWRIGHT_DOWNLOAD_HOST: MIRROR_DOWNLOAD_HOST }, platform });
|
|
141
|
+
if (r.code !== 0) throw new Error("Chromium 没下成(官方源和镜像都失败了)。检查网络后再运行一次同一条命令。");
|
|
142
|
+
}
|
|
143
|
+
const after = browserStatus(env);
|
|
144
|
+
if (!after.installed) throw new Error("下载完了但检查没通过,请再运行一次同一条命令。");
|
|
145
|
+
log(`装好了:playwright-core ${PW_VERSION}(${used.npm === "mirror" ? "镜像" : "官方源"}),Chromium(${used.chromium === "mirror" ? "镜像" : "官方源"})。bot 现在能用浏览器了,运行器不用重启。`);
|
|
146
|
+
return used;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 按绝对路径加载 ~/.agent-runner/browser/ 里的 playwright-core(运行器自己不依赖它)。 */
|
|
150
|
+
// @意图#R729(2026-11-04): 乙:用到时才加载;PLAYWRIGHT_BROWSERS_PATH 要在第一次加载前设好(它在加载时读)
|
|
151
|
+
export function loadPlaywright(env = process.env, prog) {
|
|
152
|
+
ensureInstalled(env, prog);
|
|
153
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH = browsersDir(env);
|
|
154
|
+
const req = createRequire(path.join(browserPkgDir(env), "package.json"));
|
|
155
|
+
return req("playwright-core");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 页面正在跳转时读页面会碰到的错误(等一下再读就好) */
|
|
159
|
+
const NAV_RACE = /Execution context was destroyed|interrupted by another navigation|Cannot find context with specified id|frame was detached|navigat(ing|ion).*(changed|interrupted)/i;
|
|
160
|
+
|
|
161
|
+
// ---- 页面里跑的两段(序列化后在浏览器里执行,不能引用外面的变量) ----
|
|
162
|
+
/** 找出页面上能点、能填的元素(看得见的),最多 max 个 */
|
|
163
|
+
function collectElements(max) {
|
|
164
|
+
const sel = "a[href],button,input:not([type=hidden]),textarea,select,summary,[role=button],[role=link],[role=textbox],[role=searchbox],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=combobox],[contenteditable=''],[contenteditable=true],[onclick]";
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
167
|
+
const r = el.getBoundingClientRect();
|
|
168
|
+
if (r.width < 2 || r.height < 2) continue;
|
|
169
|
+
const s = getComputedStyle(el);
|
|
170
|
+
if (s.visibility === "hidden" || s.display === "none" || Number(s.opacity) === 0) continue;
|
|
171
|
+
if (r.bottom < -200 || r.top > innerHeight * 3) continue;
|
|
172
|
+
out.push(el);
|
|
173
|
+
if (out.length >= max) break;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
/** 一个元素的一行说明 */
|
|
178
|
+
function describeElements(els) {
|
|
179
|
+
const cut = (t, n) => { const x = String(t ?? "").replace(/\s+/g, " ").trim(); return x.length > n ? `${x.slice(0, n)}…` : x; };
|
|
180
|
+
return els.map((el) => {
|
|
181
|
+
const tag = el.tagName.toLowerCase();
|
|
182
|
+
const role = el.getAttribute("role") || "";
|
|
183
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
184
|
+
const r = el.getBoundingClientRect();
|
|
185
|
+
let kind = role || tag;
|
|
186
|
+
if (tag === "a") kind = "链接";
|
|
187
|
+
else if (tag === "button" || role === "button" || type === "submit" || type === "button") kind = "按钮";
|
|
188
|
+
else if (tag === "input" && (type === "checkbox" || type === "radio")) kind = type === "checkbox" ? "勾选框" : "单选";
|
|
189
|
+
else if (tag === "input" || tag === "textarea" || role === "textbox" || role === "searchbox" || el.isContentEditable) kind = "输入框";
|
|
190
|
+
else if (tag === "select" || role === "combobox") kind = "下拉框";
|
|
191
|
+
const text = cut(el.innerText || el.textContent, 60);
|
|
192
|
+
const label = cut(el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("alt"), 40);
|
|
193
|
+
const ph = cut(el.getAttribute("placeholder"), 40);
|
|
194
|
+
const val = tag === "input" && type !== "password" && type !== "checkbox" && type !== "radio" ? cut(el.value, 40) : "";
|
|
195
|
+
const href = tag === "a" ? cut(el.getAttribute("href"), 80) : "";
|
|
196
|
+
const parts = [kind];
|
|
197
|
+
if (text) parts.push(`"${text}"`);
|
|
198
|
+
if (label && label !== text) parts.push(`标签="${label}"`);
|
|
199
|
+
if (ph) parts.push(`提示="${ph}"`);
|
|
200
|
+
if (val) parts.push(`值="${val}"`);
|
|
201
|
+
if (type === "password") parts.push("(密码框)");
|
|
202
|
+
if ((type === "checkbox" || type === "radio") && el.checked) parts.push("(已勾)");
|
|
203
|
+
if (href && !href.startsWith("javascript")) parts.push(`→ ${href}`);
|
|
204
|
+
parts.push(`@(${Math.round(r.left + r.width / 2)},${Math.round(r.top + r.height / 2)})`);
|
|
205
|
+
return parts.join(" ");
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 每个 bot 一只浏览器(launchPersistentContext,资料目录按 bot 分)。bot 的操作按 bot 排队一个一个做;
|
|
211
|
+
* 网页点「打开」不排队,直接把后台的换成有窗口的(正在做的那一步会失败一次,叫模型重试)。
|
|
212
|
+
*/
|
|
213
|
+
// @意图#R729(2026-11-04): king 定的几条:默认后台跑、点按钮才显示;撞上时弹出同一个窗口一起看(后台实例关掉、同一份资料目录重开有窗口的、恢复打开的网址);
|
|
214
|
+
// 用户关掉窗口后下次操作回到后台;后台的 10 分钟没人用就关;浏览器操作全部自动允许(这里不管批准)
|
|
215
|
+
// showWindow=false 只给测试用:「有窗口」模式照样走切换逻辑,但实际不弹窗口
|
|
216
|
+
export function createBrowserManager({ env = process.env, log = () => {}, idleMs = IDLE_CLOSE_MS, load = () => loadPlaywright(env), prog, showWindow = true } = {}) {
|
|
217
|
+
/** botId → { ctx, headed, page, refs, gen, timer } */
|
|
218
|
+
const bots = new Map();
|
|
219
|
+
/** botId → 正在开 / 换模式的 Promise(同一个 bot 同时只开一次) */
|
|
220
|
+
const opening = new Map();
|
|
221
|
+
/** botId → 操作队列的尾巴 */
|
|
222
|
+
const queues = new Map();
|
|
223
|
+
let gen = 0;
|
|
224
|
+
|
|
225
|
+
const arm = (botId, e) => {
|
|
226
|
+
clearTimeout(e.timer);
|
|
227
|
+
if (e.headed) return; // 有窗口的由用户关,不自动关
|
|
228
|
+
e.timer = setTimeout(() => { if (bots.get(botId) === e) { log(`[${botId}] 浏览器 ${Math.round(idleMs / 60_000)} 分钟没用,关掉了`); void closeEntry(botId, e); } }, idleMs);
|
|
229
|
+
e.timer.unref?.();
|
|
230
|
+
};
|
|
231
|
+
async function closeEntry(botId, e) {
|
|
232
|
+
clearTimeout(e.timer);
|
|
233
|
+
if (bots.get(botId) === e) bots.delete(botId);
|
|
234
|
+
await e.ctx.close().catch(() => {});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function launch(botId, headed, urls = []) {
|
|
238
|
+
const pw = load();
|
|
239
|
+
const dir = profileDirFor(botId, env);
|
|
240
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
241
|
+
let ctx;
|
|
242
|
+
try {
|
|
243
|
+
ctx = await pw.chromium.launchPersistentContext(dir, {
|
|
244
|
+
channel: "chromium", headless: !(headed && showWindow), viewport: headed ? null : HEADLESS_VIEWPORT,
|
|
245
|
+
locale: "zh-CN", args: headed ? ["--start-maximized"] : [],
|
|
246
|
+
...(headed && showWindow ? {} : { userAgent: headlessUserAgent(env) }),
|
|
247
|
+
});
|
|
248
|
+
} catch (e) {
|
|
249
|
+
const m = String(e?.message ?? e);
|
|
250
|
+
if (/ProcessSingleton|SingletonLock|profile.*in use|already in use/i.test(m)) throw new Error("这个 bot 的浏览器资料目录正被另一只浏览器占着(可能是上次没关干净),先把那个 Chromium 窗口关掉再试。");
|
|
251
|
+
throw new Error(`浏览器没打开:${m.split("\n")[0]}`);
|
|
252
|
+
}
|
|
253
|
+
const e = { ctx, headed, page: null, refs: [], gen: ++gen, timer: undefined };
|
|
254
|
+
ctx.on("page", (p) => { if (bots.get(botId) === e) e.page = p; }); // 点了链接开新标签页:跟过去
|
|
255
|
+
ctx.on("close", () => { if (bots.get(botId) === e) { clearTimeout(e.timer); bots.delete(botId); if (e.headed) log(`[${botId}] 浏览器窗口关掉了,之后 bot 用浏览器会在后台打开`); } });
|
|
256
|
+
bots.set(botId, e);
|
|
257
|
+
const pages = ctx.pages();
|
|
258
|
+
for (let i = 0; i < urls.length; i++) {
|
|
259
|
+
const p = i < pages.length ? pages[i] : await ctx.newPage();
|
|
260
|
+
await p.goto(urls[i], { waitUntil: "domcontentloaded", timeout: 30_000 }).catch(() => {});
|
|
261
|
+
e.page = p;
|
|
262
|
+
}
|
|
263
|
+
if (!e.page) e.page = ctx.pages()[0] ?? await ctx.newPage();
|
|
264
|
+
arm(botId, e);
|
|
265
|
+
return e;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 拿到这个 bot 的浏览器(没有就后台开一只) */
|
|
269
|
+
async function get(botId) {
|
|
270
|
+
const cur = bots.get(botId);
|
|
271
|
+
if (cur) return cur;
|
|
272
|
+
if (opening.has(botId)) return opening.get(botId);
|
|
273
|
+
const p = launch(botId, false).finally(() => opening.delete(botId));
|
|
274
|
+
opening.set(botId, p);
|
|
275
|
+
return p;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 网页点「打开 bot 的浏览器」:换成 / 打开有窗口的 */
|
|
279
|
+
// @意图#R730(2026-11-04): 打开失败只把错误回给网页,runner 不能因此崩掉
|
|
280
|
+
async function openHeaded(botId) {
|
|
281
|
+
if (opening.has(botId)) await opening.get(botId).catch(() => {});
|
|
282
|
+
const cur = bots.get(botId);
|
|
283
|
+
if (cur?.headed) { await cur.page?.bringToFront().catch(() => {}); return { ok: true, already: true, urls: cur.ctx.pages().map((p) => p.url()) }; }
|
|
284
|
+
const run = (async () => {
|
|
285
|
+
let urls = [];
|
|
286
|
+
if (cur) {
|
|
287
|
+
urls = cur.ctx.pages().map((p) => p.url()).filter((u) => u && u !== "about:blank");
|
|
288
|
+
log(`[${botId}] 网页上点了打开浏览器:后台的先关掉,换成有窗口的(恢复 ${urls.length} 个网页)`);
|
|
289
|
+
await closeEntry(botId, cur);
|
|
290
|
+
} else log(`[${botId}] 网页上点了打开浏览器`);
|
|
291
|
+
const e = await launch(botId, true, urls);
|
|
292
|
+
await e.page?.bringToFront().catch(() => {});
|
|
293
|
+
return { ok: true, already: false, urls };
|
|
294
|
+
})().finally(() => opening.delete(botId));
|
|
295
|
+
// @意图#R730(2026-11-04): 存进 opening 的是 run 派生出来的 promise。run 失败(没装浏览器、启动出错)时,没人 await 它就成了
|
|
296
|
+
// 未处理的 rejection,Node 会把整个 runner 进程退掉,网页上显示离线。挂一个空 catch 标成已处理;别人 await 它照样拿到错误。
|
|
297
|
+
const waiting = run.then(() => bots.get(botId));
|
|
298
|
+
waiting.catch(() => {});
|
|
299
|
+
opening.set(botId, waiting);
|
|
300
|
+
return run;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function currentPage(e) {
|
|
304
|
+
if (e.page && !e.page.isClosed()) return e.page;
|
|
305
|
+
const ps = e.ctx.pages();
|
|
306
|
+
e.page = ps[ps.length - 1] ?? null;
|
|
307
|
+
return e.page;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function settle(page) {
|
|
311
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => {});
|
|
312
|
+
await page.waitForLoadState("load", { timeout: 3_000 }).catch(() => {});
|
|
313
|
+
await page.waitForTimeout(300).catch(() => {});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// @意图#R729(2026-11-04): 真机验收发现:按回车搜索、网页自己跳转时,刚好在跳的那一下读元素会报 Execution context was destroyed;
|
|
317
|
+
// 碰到跳转类错误就等页面稳住再读,最多 3 次
|
|
318
|
+
async function observe(e, page, view) {
|
|
319
|
+
for (let i = 0; ; i++) {
|
|
320
|
+
try {
|
|
321
|
+
const head = { url: page.url(), title: await page.title().catch(() => ""), headed: e.headed };
|
|
322
|
+
if (view === "image") {
|
|
323
|
+
const buf = await page.screenshot({ type: "jpeg", quality: 60, timeout: 15_000 });
|
|
324
|
+
return { kind: "image", mime: "image/jpeg", data: buf.toString("base64"), ...head };
|
|
325
|
+
}
|
|
326
|
+
return { kind: "elements", text: await elementsText(e, page), ...head };
|
|
327
|
+
} catch (err) {
|
|
328
|
+
if (i >= 2 || !NAV_RACE.test(String(err?.message ?? err))) throw err;
|
|
329
|
+
await settle(page);
|
|
330
|
+
page = currentPage(e) ?? page;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function elementsText(e, page) {
|
|
336
|
+
for (const h of e.refs) await h.dispose().catch(() => {});
|
|
337
|
+
e.refs = [];
|
|
338
|
+
const arr = await page.evaluateHandle(collectElements, MAX_ELEMENTS);
|
|
339
|
+
const lines = await arr.evaluate(describeElements);
|
|
340
|
+
for (let i = 0; i < lines.length; i++) {
|
|
341
|
+
const h = await arr.getProperty(String(i));
|
|
342
|
+
e.refs.push(h.asElement() ?? h);
|
|
343
|
+
}
|
|
344
|
+
await arr.dispose().catch(() => {});
|
|
345
|
+
const body = await page.evaluate(() => (document.body?.innerText ?? "").replace(/\n{3,}/g, "\n\n").trim()).catch(() => "");
|
|
346
|
+
const text = body.length > MAX_PAGE_TEXT ? `${body.slice(0, MAX_PAGE_TEXT)}…(后面还有 ${body.length - MAX_PAGE_TEXT} 字,滚动后再看)` : body;
|
|
347
|
+
return `可操作的元素([编号] 类型 文字 @(中心坐标);点击 / 输入用编号):\n${lines.map((l, i) => `[${i + 1}] ${l}`).join("\n") || "(没找到)"}\n\n页面文字:\n${text || "(空)"}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function refOf(e, ref) {
|
|
351
|
+
const n = Number(ref);
|
|
352
|
+
const h = Number.isInteger(n) && n >= 1 ? e.refs[n - 1] : undefined;
|
|
353
|
+
if (!h) throw new Error(`没有编号 [${String(ref)}]:编号只在最近一次返回的元素清单里有效,先用 snapshot 拿新清单`);
|
|
354
|
+
return h;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const num = (v, name) => { const n = Number(v); if (!Number.isFinite(n)) throw new Error(`${name} 要是数字`); return n; };
|
|
358
|
+
|
|
359
|
+
async function act(e, action, a) {
|
|
360
|
+
if (action === "close") { await closeEntry(a.__bot, e); return { kind: "text", text: "浏览器关掉了(登录状态还在,下次用会重新打开)" }; }
|
|
361
|
+
let page = currentPage(e) ?? await e.ctx.newPage();
|
|
362
|
+
e.page = page;
|
|
363
|
+
switch (action) {
|
|
364
|
+
case "open": {
|
|
365
|
+
const url = String(a.url ?? "").trim();
|
|
366
|
+
if (!url) throw new Error("open 要给 url");
|
|
367
|
+
const full = /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`;
|
|
368
|
+
if (!/^(https?|file|about|data):/i.test(full)) throw new Error(`不支持的网址:${url}`);
|
|
369
|
+
// 网页自己又跳了一次(重定向到登录页、验证页)不算失败,下面照常读跳到的那一页
|
|
370
|
+
await page.goto(full, { waitUntil: "domcontentloaded", timeout: 30_000 }).catch((err) => { if (!NAV_RACE.test(String(err?.message ?? err))) throw err; });
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "look": case "screenshot": case "snapshot": break;
|
|
374
|
+
case "click": await page.mouse.click(num(a.x, "x"), num(a.y, "y")); break;
|
|
375
|
+
case "click_ref": await refOf(e, a.ref).click({ timeout: 10_000 }); break;
|
|
376
|
+
case "type": {
|
|
377
|
+
const text = String(a.text ?? "");
|
|
378
|
+
if (a.ref !== undefined && a.ref !== null && a.ref !== "") {
|
|
379
|
+
await refOf(e, a.ref).click({ timeout: 10_000 });
|
|
380
|
+
await page.keyboard.press("ControlOrMeta+A");
|
|
381
|
+
}
|
|
382
|
+
await page.keyboard.type(text, { delay: 25 });
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case "press": await page.keyboard.press(String(a.key ?? "Enter")); break;
|
|
386
|
+
case "scroll": await page.mouse.wheel(Number(a.dx ?? 0) || 0, a.dy === undefined ? 600 : num(a.dy, "dy")); break;
|
|
387
|
+
case "back": await page.goBack({ waitUntil: "domcontentloaded", timeout: 15_000 }).catch(() => null); break;
|
|
388
|
+
default: throw new Error(`不认识的浏览器动作:${action}`);
|
|
389
|
+
}
|
|
390
|
+
if (action !== "look" && action !== "screenshot" && action !== "snapshot") await settle(currentPage(e) ?? page);
|
|
391
|
+
page = currentPage(e) ?? page;
|
|
392
|
+
const view = action === "snapshot" ? "elements" : a.view === "image" ? "image" : "elements";
|
|
393
|
+
return observe(e, page, view);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** bot 的一步操作。同一个 bot 的操作排队;中途被网页「打开」换了模式就回 RETRY_TEXT。 */
|
|
397
|
+
// @意图#R729(2026-11-04): 返回观察结果(截图或元素清单);资料目录、模式切换都在这里管
|
|
398
|
+
async function run(botId, action, args = {}) {
|
|
399
|
+
profileDirFor(botId, env); // 先校验 bot_id
|
|
400
|
+
const prev = queues.get(botId) ?? Promise.resolve();
|
|
401
|
+
const job = prev.catch(() => {}).then(async () => {
|
|
402
|
+
if (action === "close" && !bots.has(botId) && !opening.has(botId)) return { kind: "text", text: "浏览器本来就没开" };
|
|
403
|
+
ensureInstalled(env, prog);
|
|
404
|
+
const e = await get(botId);
|
|
405
|
+
const g = e.gen;
|
|
406
|
+
try {
|
|
407
|
+
const r = await act(e, action, { ...args, __bot: botId });
|
|
408
|
+
if (bots.get(botId) === e) arm(botId, e);
|
|
409
|
+
return r;
|
|
410
|
+
} catch (err) {
|
|
411
|
+
const now = bots.get(botId);
|
|
412
|
+
if (action !== "close" && (now?.gen ?? -1) !== g) throw new Error(now || opening.has(botId) ? RETRY_TEXT : CLOSED_BY_USER);
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
queues.set(botId, job);
|
|
417
|
+
try { return await job; } finally { if (queues.get(botId) === job) queues.delete(botId); }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
run,
|
|
422
|
+
openHeaded,
|
|
423
|
+
/** 测试 / 排查用:这个 bot 现在的模式(null = 没开) */
|
|
424
|
+
mode(botId) { const e = bots.get(botId); return e ? (e.headed ? "headed" : "headless") : null; },
|
|
425
|
+
/** 测试用:这个 bot 的 BrowserContext(模拟用户关窗口) */
|
|
426
|
+
_contextOf(botId) { return bots.get(botId)?.ctx ?? null; },
|
|
427
|
+
/** 运行器退出时关掉所有浏览器 */
|
|
428
|
+
async closeAll() { await Promise.all([...bots.entries()].map(([b, e]) => closeEntry(b, e))); },
|
|
429
|
+
};
|
|
430
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub-runner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "agent-hub 的电脑运行器:配对后让你的 bot
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准),还能用它自己的独立浏览器",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agent-hub-runner": "bin/agent-hub-runner.mjs"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"ops.mjs",
|
|
12
12
|
"edit.mjs",
|
|
13
13
|
"changes.mjs",
|
|
14
|
+
"browser.mjs",
|
|
14
15
|
"autostart.mjs",
|
|
15
16
|
"bin/agent-hub-runner.mjs",
|
|
16
17
|
"README.md"
|
package/runner.mjs
CHANGED
|
@@ -27,7 +27,8 @@ import { execFile, spawn, spawnSync } from "node:child_process";
|
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { runOp } from "./ops.mjs";
|
|
29
29
|
import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
|
|
30
|
-
import {
|
|
30
|
+
import { browserStatus, createBrowserManager, installBrowser } from "./browser.mjs";
|
|
31
|
+
import { autostartOff, autostartOffWindows, autostartOn, autostartOnWindows, autostartStatus, autostartStatusWindows, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
|
|
31
32
|
|
|
32
33
|
export const PROTOCOL_VERSION = 1;
|
|
33
34
|
/** npm 包名,也是装好后的命令名 */
|
|
@@ -303,8 +304,12 @@ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
|
|
|
303
304
|
// @意图#R639(2026-10-27已废弃): 目录不再是连接时固定的常量;没目录时 call 一律拒绝;pick_dir 选完检查、写回配置(600)、回 dir_result(R640 改成按 bot 分)
|
|
304
305
|
// @意图#R690(2026-11-01): write / edit 带 change_id(服务器记灰字时生成的 auto_id)就在写改成功后存一份改动记录(存失败只记日志);
|
|
305
306
|
// 新消息 change_get {id, change_id} → change_result {id, ok:true, found, record},不看这个 bot 有没有目录(换了目录也能看旧记录)
|
|
306
|
-
|
|
307
|
+
// @意图#R729(2026-11-04): 新消息 browser {id, bot_id, action, args} → result(截图或元素清单)、browser_open {id, bot_id} → result(网页点「打开 bot 的浏览器」);
|
|
308
|
+
// 浏览器不看这个 bot 有没有选目录(资料目录在 ~/.agent-runner/browser-profiles/<bot_id>/,和授权目录无关);断线不关浏览器(重连后接着用)
|
|
309
|
+
export function createSession(cfg, { log = say, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), changes, browser } = {}) {
|
|
307
310
|
let dirs = botDirsOf(cfg); // bot_id → 授权目录
|
|
311
|
+
let browsers = browser; // R729:每个 bot 一只浏览器,用到时才建
|
|
312
|
+
const browserMgr = () => (browsers ??= createBrowserManager({ log }));
|
|
308
313
|
const jobs = new Map(); // 调用 id → AbortController
|
|
309
314
|
const picks = new Map(); // 弹窗 id → AbortController
|
|
310
315
|
let store = changes; // 改动记录(R690),用到时才建
|
|
@@ -342,6 +347,34 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
|
|
|
342
347
|
}
|
|
343
348
|
}
|
|
344
349
|
|
|
350
|
+
// @意图#R729(2026-11-04): bot 用浏览器:全部自动允许(不弹卡);打字只记字数(可能是密码)
|
|
351
|
+
async function handleBrowser(send, msg) {
|
|
352
|
+
const { id } = msg;
|
|
353
|
+
const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
354
|
+
const action = typeof msg.action === "string" ? msg.action : "";
|
|
355
|
+
const args = msg.args && typeof msg.args === "object" ? msg.args : {};
|
|
356
|
+
if (!bot) { send({ type: "result", id, ok: false, error: "缺 bot_id" }); return; }
|
|
357
|
+
log(`[${bot}] 浏览器:${action}${action === "open" ? ` ${args.url ?? ""}` : action === "type" ? `(${String(args.text ?? "").length} 个字)` : ""}`);
|
|
358
|
+
try {
|
|
359
|
+
const res = await browserMgr().run(bot, action, args);
|
|
360
|
+
send({ type: "result", id, ok: true, ...res });
|
|
361
|
+
} catch (e) {
|
|
362
|
+
send({ type: "result", id, ok: false, error: e?.message ?? String(e) });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// @意图#R729(2026-11-04): 网页点「打开 bot 的浏览器」:在这台电脑上弹出有窗口的浏览器(后台的换成有窗口的,恢复打开的网址)
|
|
366
|
+
async function handleBrowserOpen(send, msg) {
|
|
367
|
+
const { id } = msg;
|
|
368
|
+
const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
369
|
+
if (!bot) { send({ type: "result", id, ok: false, error: "缺 bot_id" }); return; }
|
|
370
|
+
try {
|
|
371
|
+
const r = await browserMgr().openHeaded(bot);
|
|
372
|
+
send({ type: "result", id, ok: true, bot_id: bot, already: !!r.already, urls: r.urls ?? [] });
|
|
373
|
+
} catch (e) {
|
|
374
|
+
send({ type: "result", id, ok: false, bot_id: bot, error: e?.message ?? String(e) });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
345
378
|
async function handlePick(send, msg) {
|
|
346
379
|
const { id } = msg;
|
|
347
380
|
const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
@@ -377,6 +410,8 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
|
|
|
377
410
|
if (msg.type === "call") return handleCall(send, msg);
|
|
378
411
|
if (msg.type === "pick_dir") return handlePick(send, msg);
|
|
379
412
|
if (msg.type === "change_get") return handleChangeGet(send, msg);
|
|
413
|
+
if (msg.type === "browser") return handleBrowser(send, msg); // R729
|
|
414
|
+
if (msg.type === "browser_open") return handleBrowserOpen(send, msg); // R729
|
|
380
415
|
if (msg.type === "abort") { jobs.get(msg.id)?.abort(); picks.get(msg.id)?.abort(); }
|
|
381
416
|
return undefined;
|
|
382
417
|
},
|
|
@@ -386,6 +421,8 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
|
|
|
386
421
|
jobs.clear();
|
|
387
422
|
picks.clear();
|
|
388
423
|
},
|
|
424
|
+
/** R729:运行器退出时关掉所有 bot 的浏览器 */
|
|
425
|
+
closeBrowsers() { return browsers ? browsers.closeAll() : Promise.resolve(); },
|
|
389
426
|
};
|
|
390
427
|
}
|
|
391
428
|
|
|
@@ -396,6 +433,8 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
|
|
|
396
433
|
// @意图#R666(2026-10-29): hello 多报 runner_version(网页提示有新版本)和 autostart(是不是开机自启起的,网页据此给升级命令);
|
|
397
434
|
// 服务器用 4409 顶掉这条(同一份配置又开了一个):不再重连、结束("replaced");连不上时只在第一次和之后每 30 次打一行,开机自启没网时日志不刷屏;
|
|
398
435
|
// 连上那句不再说「右栏」,开机自启时不说「按 Ctrl-C」
|
|
436
|
+
// @意图#R729(2026-11-04): hello 和心跳多报浏览器装没装(browser: {installed, version}),网页抽屉据此显示「未安装」和安装命令;装好后不用重启,下一次心跳就更新;
|
|
437
|
+
// 停掉时关掉所有 bot 的浏览器
|
|
399
438
|
// @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
|
|
400
439
|
// @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
|
|
401
440
|
// @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
|
|
@@ -411,6 +450,7 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
|
|
|
411
450
|
const done = new Promise((r) => { resolveDone = r; });
|
|
412
451
|
const finish = (why) => { if (!stopped) { stopped = true; } resolveDone(why); };
|
|
413
452
|
const send = (sock, msg) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(msg)); };
|
|
453
|
+
const browserInfo = () => { try { const b = browserStatus(); return { installed: b.installed, version: b.version }; } catch { return { installed: false, version: null }; } };
|
|
414
454
|
|
|
415
455
|
function connect() {
|
|
416
456
|
if (stopped) return;
|
|
@@ -418,8 +458,8 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
|
|
|
418
458
|
ws = sock;
|
|
419
459
|
sock.addEventListener("open", () => {
|
|
420
460
|
send(sock, { type: "hello", version: PROTOCOL_VERSION, device_id: cfg.device_id, token: cfg.token, bot_dirs: session.botDirs,
|
|
421
|
-
name: cfg.name ?? os.hostname(), os: `${process.platform} ${os.release()}`, runner_version: RUNNER_VERSION, autostart: background });
|
|
422
|
-
beat = setInterval(() => send(sock, { type: "ping" }), HEARTBEAT_MS);
|
|
461
|
+
name: cfg.name ?? os.hostname(), os: `${process.platform} ${os.release()}`, runner_version: RUNNER_VERSION, autostart: background, browser: browserInfo() });
|
|
462
|
+
beat = setInterval(() => send(sock, { type: "ping", browser: browserInfo() }), HEARTBEAT_MS);
|
|
423
463
|
});
|
|
424
464
|
sock.addEventListener("message", (ev) => {
|
|
425
465
|
let msg;
|
|
@@ -452,6 +492,7 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
|
|
|
452
492
|
if (stopped) return;
|
|
453
493
|
stopped = true;
|
|
454
494
|
session.killAll();
|
|
495
|
+
void session.closeBrowsers(); // R729
|
|
455
496
|
clearInterval(beat);
|
|
456
497
|
try { ws?.close(1000, "runner stopped"); } catch { /* ignore */ }
|
|
457
498
|
resolveDone("stopped");
|
|
@@ -478,10 +519,17 @@ export function takeLock(file = configPath(), { pid = process.pid, launchd = pro
|
|
|
478
519
|
return { ok: true, release: () => { if (readLock(file)?.pid === pid) rmSync(lockPath(file), { force: true }); } };
|
|
479
520
|
}
|
|
480
521
|
|
|
481
|
-
|
|
522
|
+
// @意图#R725(2026-11-03): Windows 被断开时也关掉计划任务,不然下次登录又白起一次
|
|
523
|
+
/** 被网页断开 / 令牌失效后把这份配置的开机自启关掉(不然每次登录都白起一次);不是 Mac / Windows、没开都不要紧 */
|
|
482
524
|
async function autostartOffQuietly(file, deps) {
|
|
483
|
-
|
|
484
|
-
|
|
525
|
+
const platform = deps.platform ?? process.platform;
|
|
526
|
+
if (platform !== "darwin" && platform !== "win32") return false;
|
|
527
|
+
try {
|
|
528
|
+
const r = platform === "win32"
|
|
529
|
+
? await autostartOffWindows({ configFile: file, schtasks: deps.schtasks })
|
|
530
|
+
: await autostartOff({ configFile: file, launchctl: deps.launchctl });
|
|
531
|
+
return r.wasOn;
|
|
532
|
+
} catch { return false; }
|
|
485
533
|
}
|
|
486
534
|
|
|
487
535
|
// @意图#R666(2026-10-29): 先拿锁(后台已在跑就提示退出 0);开机自启时启动前日志超 5MB 清空、配置坏了/没配对也退出 0(不让 launchd 反复拉起);
|
|
@@ -513,11 +561,13 @@ async function runStart(prog = progName(), deps = {}) {
|
|
|
513
561
|
process.exit(0);
|
|
514
562
|
}
|
|
515
563
|
|
|
516
|
-
|
|
564
|
+
// @意图#R725(2026-11-03): Windows 也支持开机自启(计划任务,见 autostart.mjs)。Mac 和 Windows 都问;别的系统不开。
|
|
565
|
+
/** R666:配完要不要开机自启:flag(--autostart / --no-autostart)优先;不是 Mac / Windows 不开;是终端就问;不是终端(脚本、管道)不问、只提示 */
|
|
517
566
|
export function decideAutostart({ flag, platform = process.platform, tty = !!(process.stdin.isTTY && process.stdout.isTTY) }) {
|
|
518
|
-
|
|
567
|
+
const supported = platform === "darwin" || platform === "win32";
|
|
568
|
+
if (flag === true) return supported ? "on" : "unsupported";
|
|
519
569
|
if (flag === false) return "off";
|
|
520
|
-
if (
|
|
570
|
+
if (!supported) return "off";
|
|
521
571
|
return tty ? "ask" : "hint";
|
|
522
572
|
}
|
|
523
573
|
|
|
@@ -543,11 +593,16 @@ async function stopHolder(file) {
|
|
|
543
593
|
|
|
544
594
|
const here = () => path.dirname(fileURLToPath(import.meta.url));
|
|
545
595
|
// @意图#R666(2026-10-29): 开启自启:前台有一个在跑就先停掉它(不然后台那个起来就因为锁退出了),再交给 autostart.mjs(复制自己、写 plist、加载)
|
|
596
|
+
// @意图#R725(2026-11-03): Windows 走计划任务(autostartOnWindows),Mac 走 launchd。提示里的「这台 Mac」按平台写。
|
|
546
597
|
async function turnAutostartOn(file, prog, deps = {}) {
|
|
547
598
|
const held = readLock(file);
|
|
548
599
|
if (held && !held.launchd) await stopHolder(file);
|
|
549
|
-
const
|
|
550
|
-
|
|
600
|
+
const platform = deps.platform ?? process.platform;
|
|
601
|
+
const r = platform === "win32"
|
|
602
|
+
? await autostartOnWindows({ configFile: file, version: RUNNER_VERSION, srcDir: here(), execPath: deps.execPath, schtasks: deps.schtasks })
|
|
603
|
+
: await autostartOn({ configFile: file, version: RUNNER_VERSION, srcDir: here(), launchctl: deps.launchctl, platform });
|
|
604
|
+
const where = platform === "win32" ? "这台电脑" : "这台 Mac";
|
|
605
|
+
console.log(`已设开机自启:以后登录${where}会自动连上,不用开终端。现在已经在后台连着,可以关掉这个终端窗口了。\n日志:${r.log}\n关掉开机自启:${prog} autostart off`);
|
|
551
606
|
return r;
|
|
552
607
|
}
|
|
553
608
|
|
|
@@ -559,11 +614,14 @@ const usage = (prog) => `用法:
|
|
|
559
614
|
${prog} stop 停掉在跑的运行器
|
|
560
615
|
${prog} status 看配置、各 bot 的目录、在不在跑、开机自启
|
|
561
616
|
${prog} set-dir <bot_id> <路径> 直接指定这个 bot 的授权目录(和网页上点「选择目录」走同一套检查;弹窗弹不出来时用)
|
|
562
|
-
${prog} autostart on|off|status Mac
|
|
617
|
+
${prog} autostart on|off|status 开机自启(Mac 用 launchd,Windows 用计划任务;on 也用来升级到新版本)
|
|
563
618
|
${prog} uninstall [--purge] 关掉自启、删掉本地副本和日志(--purge 连配置一起删)
|
|
619
|
+
${prog} browser install 装 bot 专用的浏览器(playwright-core + 一只独立的 Chromium,约 200MB,先官方源、失败换国内镜像)
|
|
620
|
+
${prog} browser status 看浏览器装没装
|
|
564
621
|
${prog} --version
|
|
565
622
|
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac / Windows 上会弹窗让你选。`;
|
|
566
623
|
|
|
624
|
+
// @意图#R729(2026-11-04): 新命令 browser install | status(装 bot 专用浏览器、看装没装)
|
|
567
625
|
// @意图#R666(2026-10-29): pair 默认配完直接 start(--no-start 只配对);配完按 decideAutostart 问 / 提示 / 开启开机自启,开了就不在前台跑;
|
|
568
626
|
// 新命令 stop、autostart on|off|status、uninstall、--version;status 多显示在不在跑、版本、开机自启;用法里的命令按运行方式写(npx 时写 npx 写法)
|
|
569
627
|
// @意图#R664(2026-10-29): 导出给 bin/agent-hub-runner.mjs 调;用法和「现在运行:… start」里的命令名用 progName(装好的 agent-hub-runner,仓库里 node 写法)
|
|
@@ -604,15 +662,21 @@ export async function main(argv, prog = progName(), deps = {}) {
|
|
|
604
662
|
const sub = a.positional[0] ?? "status";
|
|
605
663
|
const file = configPath();
|
|
606
664
|
if (sub === "on") { loadConfig(file); await turnAutostartOn(file, prog, deps); return; }
|
|
607
|
-
if (platform !== "darwin") { console.log(ONLY_MAC); return; }
|
|
665
|
+
if (platform !== "darwin" && platform !== "win32") { console.log(ONLY_MAC); return; }
|
|
666
|
+
// @意图#R725(2026-11-03): Windows 的 off / status 走计划任务,Mac 走 launchd
|
|
608
667
|
if (sub === "off") {
|
|
609
|
-
const r =
|
|
668
|
+
const r = platform === "win32"
|
|
669
|
+
? await autostartOffWindows({ configFile: file, schtasks: deps.schtasks })
|
|
670
|
+
: await autostartOff({ configFile: file, launchctl: deps.launchctl });
|
|
610
671
|
console.log(r.wasOn ? "已关掉开机自启(后台那个也停了)。" : "本来就没开开机自启。");
|
|
611
672
|
return;
|
|
612
673
|
}
|
|
613
674
|
if (sub === "status") {
|
|
614
|
-
const st =
|
|
615
|
-
|
|
675
|
+
const st = platform === "win32"
|
|
676
|
+
? await autostartStatusWindows({ configFile: file, schtasks: deps.schtasks })
|
|
677
|
+
: await autostartStatus({ configFile: file, launchctl: deps.launchctl, platform });
|
|
678
|
+
const where = st.task ? `计划任务:${st.task}` : `plist:${st.plist}`;
|
|
679
|
+
console.log(st.installed ? `开机自启:开着(${st.loaded ? (st.pid ? `后台在跑,进程 ${st.pid}` : "已加载,现在没在跑") : "还没加载"})\n${where}\n日志:${st.log}` : "开机自启:没开");
|
|
616
680
|
for (const p of st.problems) console.log(`注意:${p}`);
|
|
617
681
|
return;
|
|
618
682
|
}
|
|
@@ -669,6 +733,19 @@ export async function main(argv, prog = progName(), deps = {}) {
|
|
|
669
733
|
console.log(`服务器:${cfg.server}\n设备:${cfg.device_id}\n运行器版本:${RUNNER_VERSION}\n现在:${run}${auto}\n各 bot 的授权目录:${d.length ? d.map(([b, p]) => `\n ${b}:${p}`).join("") : "(还没有,连上后在每个 bot 的私聊里点「选择目录」)"}`);
|
|
670
734
|
return;
|
|
671
735
|
}
|
|
736
|
+
// @意图#R729(2026-11-04): 乙:浏览器组件不进运行器的依赖,用户敲这一条才装(网页抽屉里给的就是它);status 只看文件,不加载
|
|
737
|
+
if (a.cmd === "browser") {
|
|
738
|
+
const sub = a.positional[0] ?? "status";
|
|
739
|
+
if (sub === "install") { await (deps.installBrowser ?? installBrowser)({ log: (t) => console.log(t) }); return; }
|
|
740
|
+
if (sub === "status") {
|
|
741
|
+
const st = browserStatus();
|
|
742
|
+
console.log(st.installed ? `浏览器:装好了(playwright-core ${st.version})`
|
|
743
|
+
: st.reason === "version_mismatch" ? `浏览器:版本不对(装的是 ${st.version}),重新运行 ${prog} browser install`
|
|
744
|
+
: `浏览器:还没装。运行 ${prog} browser install`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
throw new Error(`browser 后面只能是 install 或 status(${sub} 不认识)`);
|
|
748
|
+
}
|
|
672
749
|
if (a.cmd === "version") { console.log(RUNNER_VERSION); return; }
|
|
673
750
|
console.log(usage(prog));
|
|
674
751
|
process.exitCode = a.cmd && a.cmd !== "help" ? 1 : 0;
|