agent-hub-runner 0.1.4 → 0.2.1
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 +14 -0
- package/autostart.mjs +23 -5
- package/browser.mjs +430 -0
- package/package.json +3 -2
- package/runner.mjs +60 -3
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
<!-- @意图#R729(2026-11-04): 加 browser install / status 和「bot 的浏览器」一节(0.2.0) -->
|
|
1
2
|
<!-- @意图#R690(2026-11-01): 加「改动记录」一节:存在哪、打码、64 KB、30 天、怎么删 -->
|
|
3
|
+
<!-- @意图#R731(2026-11-04): 常见问题加开机自启「Bootstrap failed: 5」(0.2.1 起自动处理) -->
|
|
2
4
|
<!-- @意图#R666(2026-10-29): 改成不用全局安装的 npx 一条命令(码@地址)、Mac 开机自启、stop / uninstall、常见报错 -->
|
|
3
5
|
<!-- @意图#R664(2026-10-29): npm 包 agent-hub-runner 的说明(随包发布):给客户看的安装、配对、再连、查看、断开、卸载和安全提示 -->
|
|
4
6
|
# agent-hub-runner
|
|
@@ -48,6 +50,8 @@ npx -y agent-hub-runner@latest autostart off # 关掉开机自启
|
|
|
48
50
|
npx -y agent-hub-runner@latest autostart status
|
|
49
51
|
npx -y agent-hub-runner@latest stop # 停掉在跑的运行器(开机自启的下次开机还会起)
|
|
50
52
|
npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复制的程序;会问要不要删配置
|
|
53
|
+
npx -y agent-hub-runner@latest browser install # 给 bot 装它自己的浏览器(0.2.0 起,约 200MB,只要一次)
|
|
54
|
+
npx -y agent-hub-runner@latest browser status # 看浏览器装没装
|
|
51
55
|
```
|
|
52
56
|
|
|
53
57
|
开机自启时,程序复制到 `~/.agent-runner/bin/<版本>/`,日志在 `~/.agent-runner/logs/runner.log`(超过 5MB 自动清空)。网页上提示「有新版本」时,按提示运行那一条升级命令。
|
|
@@ -68,6 +72,7 @@ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复
|
|
|
68
72
|
- 「找不到服务器」「连不上服务器」「超时」:检查网络;命令要整条原样复制。
|
|
69
73
|
- 「配对码过期了 / 已经用过了 / 已经作废了」:网页上点「换一个码」再运行新命令。
|
|
70
74
|
- 「这个账号已经连着一台电脑了」:一个账号同时只能连一台,先在网页抽屉「连着的电脑」里点「断开」。
|
|
75
|
+
- 开机自启报「Bootstrap failed: 5: Input/output error」:这个后台任务以前被 macOS 记成了「已禁用」。0.2.1 起 `autostart on` 会自己解除并重试,直接再运行一次 `npx -y agent-hub-runner@latest autostart on` 就行;还不行就按它给的那条 `launchctl enable …` 命令运行后再试,或重启电脑再试。
|
|
71
76
|
|
|
72
77
|
## 断开、卸载
|
|
73
78
|
|
|
@@ -80,7 +85,16 @@ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复
|
|
|
80
85
|
- 存之前会把像密钥的字符串换成 `***`(`sk-…`、`ghp_…`、连接串里的密码、私钥、`XXX_KEY=…` 这类赋值);每条最多 64 KB;只留 30 天,过期自动删。电脑不在线时网页上看不到。
|
|
81
86
|
- 不想留:直接删掉 `edits/` 文件夹,不影响别的。
|
|
82
87
|
|
|
88
|
+
## bot 的浏览器(0.2.0 起)
|
|
89
|
+
|
|
90
|
+
- bot 能在你电脑上用一只**它自己的浏览器**(独立的 Chromium,不碰你平时用的浏览器)。先运行一次 `npx -y agent-hub-runner@latest browser install`:装到 `~/.agent-runner/browser/`(playwright-core)和 `~/.agent-runner/browsers/`(Chromium)。先走官方源,下不动会自动换国内镜像(npmmirror)。装好不用重启运行器。
|
|
91
|
+
- 每个 bot 一个资料目录 `~/.agent-runner/browser-profiles/<bot>/`,登录状态会保存。平时在后台(没有窗口)跑,闲 10 分钟自动关。
|
|
92
|
+
- 要登录、扫码、过验证码:在网页「我的电脑」抽屉里点「打开 bot 的浏览器」,会弹出这个 bot 的浏览器窗口,你弄好后直接关掉窗口,bot 接着在后台用。
|
|
93
|
+
- 不想让 bot 用浏览器:网页右栏「bot设置」里取消勾「允许用浏览器」。想清掉登录状态:删掉那个 bot 的资料目录。
|
|
94
|
+
- 卸载:删掉 `~/.agent-runner/browser`、`~/.agent-runner/browsers`、`~/.agent-runner/browser-profiles` 三个文件夹。
|
|
95
|
+
|
|
83
96
|
## 安全提示
|
|
84
97
|
|
|
85
98
|
- 别把放着密钥、令牌的文件夹授权出去:bot 在授权文件夹里读、写、改文件不用你批准。
|
|
86
99
|
- 跑命令前网页上会弹批准卡片,看清命令再点「允许一次」;命令本身能碰到文件夹以外的东西(没有沙箱)。
|
|
100
|
+
- bot 用浏览器不弹批准卡片,能打开任何网址;在 bot 的浏览器里登录过的网站,bot 都能以你的身份操作。别在里面登录你不想让 bot 碰的账号(网银、支付等)。
|
package/autostart.mjs
CHANGED
|
@@ -18,8 +18,9 @@ export const LABEL = "com.agenthub.runner";
|
|
|
18
18
|
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
19
19
|
export const THROTTLE_S = 10;
|
|
20
20
|
/** 复制到 ~/.agent-runner/bin/<版本>/ 的文件(package.json 用来读版本号) */
|
|
21
|
+
// @意图#R729(2026-11-04): 多复制 browser.mjs(runner.mjs 要 import 它)
|
|
21
22
|
// @意图#R690(2026-11-01): 多复制 changes.mjs(runner.mjs 要 import 它,少了开机自启的副本起不来)
|
|
22
|
-
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"];
|
|
23
24
|
/** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
|
|
24
25
|
export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
|
|
25
26
|
export const ONLY_MAC = "开机自启目前只支持 Mac 和 Windows";
|
|
@@ -123,11 +124,22 @@ export function runLaunchctl(args, env = process.env) {
|
|
|
123
124
|
}
|
|
124
125
|
const uid = () => (typeof process.getuid === "function" ? process.getuid() : 0);
|
|
125
126
|
|
|
127
|
+
// @意图#R731(2026-11-04): plist 的 PATH 用终端里的,但去掉 npx 临时缓存、各级 node_modules/.bin、npm 自带的 node-gyp-bin 这些会变或会被清的目录;去重保序
|
|
128
|
+
export function cleanPath(p) {
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
return String(p || "").split(":").filter((d) => d && !d.includes("/_npx/") && !/\/node_modules\/\.bin\/?$/.test(d) && !d.includes("/node-gyp-bin") && !seen.has(d) && seen.add(d)).join(":") || "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 加载失败时给小白看的中文说明(带一条能直接复制的修复命令) */
|
|
134
|
+
export const bootstrapHelp = (label, raw) => `开机自启没设成:macOS 拒绝加载后台任务(${raw})。\n可以在终端里复制运行下面这条命令后,再运行一次 autostart on:\n launchctl enable gui/$(id -u)/${label}\n还不行就重启一下电脑再试;仍然失败请把这段话截图发给我们。`;
|
|
135
|
+
|
|
126
136
|
/**
|
|
127
137
|
* 开启开机自启:复制自己、写 plist、(先卸掉旧的)再加载。加载后 launchd 马上起一个后台运行器(RunAtLoad)。
|
|
128
138
|
* 返回 { label, plist, script, node, log }。
|
|
129
139
|
*/
|
|
130
|
-
|
|
140
|
+
// @意图#R731(2026-11-04): 小白遇到「任务被 macOS 记成已禁用」(以前 launchctl disable 过,删 plist 也清不掉)时 bootstrap 报 5,自己处理掉:
|
|
141
|
+
// 加载前先 launchctl enable(没禁用也无害);还失败就等 1 秒再 enable + bootstrap 一次(刚 bootout 马上 bootstrap 偶尔也报 5);仍失败给中文说明和修复命令
|
|
142
|
+
export async function autostartOn({ configFile, version, srcDir, execPath = process.execPath, env = process.env, launchctl, platform = process.platform, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
131
143
|
launchctl ??= (a) => runLaunchctl(a, env);
|
|
132
144
|
if (platform !== "darwin") throw new Error(ONLY_MAC);
|
|
133
145
|
const cfg = path.resolve(configFile);
|
|
@@ -137,14 +149,20 @@ export async function autostartOn({ configFile, version, srcDir, execPath = proc
|
|
|
137
149
|
const label = labelFor(cfg, env);
|
|
138
150
|
const log = logFileFor(cfg, env);
|
|
139
151
|
mkdirSync(path.dirname(log), { recursive: true, mode: 0o700 });
|
|
140
|
-
const vars = { AGENT_RUNNER_CONFIG: cfg, AGENT_RUNNER_LAUNCHD: "1", PATH: env.PATH
|
|
152
|
+
const vars = { AGENT_RUNNER_CONFIG: cfg, AGENT_RUNNER_LAUNCHD: "1", PATH: cleanPath(env.PATH), LANG: env.LANG || "en_US.UTF-8" };
|
|
141
153
|
if (env.AGENT_RUNNER_HOME) vars.AGENT_RUNNER_HOME = env.AGENT_RUNNER_HOME;
|
|
142
154
|
const plist = plistPathFor(cfg, env);
|
|
143
155
|
mkdirSync(path.dirname(plist), { recursive: true });
|
|
144
156
|
writeFileSync(plist, plistXml({ label, args: [node, script, "start"], env: vars, log }), { mode: 0o644 });
|
|
145
157
|
await launchctl(["bootout", `gui/${uid()}/${label}`]); // 以前加载过(升级):先卸掉,没有也不要紧
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
let r;
|
|
159
|
+
for (let i = 0; i < 2; i++) {
|
|
160
|
+
if (i) await sleep(1000);
|
|
161
|
+
await launchctl(["enable", `gui/${uid()}/${label}`]);
|
|
162
|
+
r = await launchctl(["bootstrap", `gui/${uid()}`, plist]);
|
|
163
|
+
if (r.code === 0) break;
|
|
164
|
+
}
|
|
165
|
+
if (r.code !== 0) throw new Error(bootstrapHelp(label, `launchctl bootstrap 失败:${(r.stderr || r.stdout).trim() || r.code}`));
|
|
148
166
|
return { label, plist, script, node, log };
|
|
149
167
|
}
|
|
150
168
|
|
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.1
|
|
4
|
-
"description": "agent-hub 的电脑运行器:配对后让你的 bot
|
|
3
|
+
"version": "0.2.1",
|
|
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,6 +27,7 @@ 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 { browserStatus, createBrowserManager, installBrowser } from "./browser.mjs";
|
|
30
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;
|
|
@@ -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");
|
|
@@ -575,9 +616,12 @@ const usage = (prog) => `用法:
|
|
|
575
616
|
${prog} set-dir <bot_id> <路径> 直接指定这个 bot 的授权目录(和网页上点「选择目录」走同一套检查;弹窗弹不出来时用)
|
|
576
617
|
${prog} autostart on|off|status 开机自启(Mac 用 launchd,Windows 用计划任务;on 也用来升级到新版本)
|
|
577
618
|
${prog} uninstall [--purge] 关掉自启、删掉本地副本和日志(--purge 连配置一起删)
|
|
619
|
+
${prog} browser install 装 bot 专用的浏览器(playwright-core + 一只独立的 Chromium,约 200MB,先官方源、失败换国内镜像)
|
|
620
|
+
${prog} browser status 看浏览器装没装
|
|
578
621
|
${prog} --version
|
|
579
622
|
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac / Windows 上会弹窗让你选。`;
|
|
580
623
|
|
|
624
|
+
// @意图#R729(2026-11-04): 新命令 browser install | status(装 bot 专用浏览器、看装没装)
|
|
581
625
|
// @意图#R666(2026-10-29): pair 默认配完直接 start(--no-start 只配对);配完按 decideAutostart 问 / 提示 / 开启开机自启,开了就不在前台跑;
|
|
582
626
|
// 新命令 stop、autostart on|off|status、uninstall、--version;status 多显示在不在跑、版本、开机自启;用法里的命令按运行方式写(npx 时写 npx 写法)
|
|
583
627
|
// @意图#R664(2026-10-29): 导出给 bin/agent-hub-runner.mjs 调;用法和「现在运行:… start」里的命令名用 progName(装好的 agent-hub-runner,仓库里 node 写法)
|
|
@@ -689,6 +733,19 @@ export async function main(argv, prog = progName(), deps = {}) {
|
|
|
689
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 的私聊里点「选择目录」)"}`);
|
|
690
734
|
return;
|
|
691
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
|
+
}
|
|
692
749
|
if (a.cmd === "version") { console.log(RUNNER_VERSION); return; }
|
|
693
750
|
console.log(usage(prog));
|
|
694
751
|
process.exitCode = a.cmd && a.cmd !== "help" ? 1 : 0;
|