agent-hub-runner 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ <!-- @意图#R666(2026-10-29): 改成不用全局安装的 npx 一条命令(码@地址)、Mac 开机自启、stop / uninstall、常见报错 -->
2
+ <!-- @意图#R664(2026-10-29): npm 包 agent-hub-runner 的说明(随包发布):给客户看的安装、配对、再连、查看、断开、卸载和安全提示 -->
3
+ # agent-hub-runner
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ agent-hub 的电脑运行器。装在你自己的电脑上,配对之后,你的 bot 就能在你给它选的文件夹里读、写、改文件,跑命令(跑命令每次都要你在网页上点批准)。
6
+
7
+ - 没有第三方依赖,只用 Node 自带的功能。不用先装:用 `npx` 直接运行。
8
+ - Mac 上可以设成开机自动连上(在后台跑,不用开终端);不设的话在终端前台跑,按 Ctrl-C 断开。
9
+
10
+ ## 需要
11
+
12
+ - **Node 22 或更新**(终端里运行 `node -v` 看版本)。还没装:去 [nodejs.org](https://nodejs.org) 下载 LTS 版(Mac 选 .pkg 安装包),或者 `brew install node`。
13
+ - **macOS**:给 bot 选目录时会在 Mac 上弹出系统的「选文件夹」窗口;开机自启也只支持 Mac。别的系统能连上,但选不了目录。
14
+ - bot 跑命令时用 `bash`(macOS、Linux 自带)。
15
+
16
+ ## 第一次连接
17
+
18
+ 1. 在网页上打开某个 bot 的私聊,点输入框下面「我的电脑」那一行,右侧会打开抽屉,里面有一条命令。
19
+ 2. 复制那条命令到终端运行,形如:
20
+
21
+ ```bash
22
+ npx -y agent-hub-runner@latest pair <配对码>@你的服务器地址
23
+ ```
24
+
25
+ 配对码 5 分钟有效、只能用一次;过期了在抽屉里点「换一个码」。配对成功后直接连上。
26
+ 3. Mac 上会问「开机自动连上?(Y/n)」:回车就是「是」,以后开机自己连上,终端可以关;选「否」的话终端窗口别关。
27
+ 4. 抽屉里第 2 步会自动打勾。第 3 步点「选择目录…」,在 Mac 弹出的窗口里选这个 bot 能用的文件夹。每个 bot 各选各的。
28
+
29
+ `@` 后面的地址默认是 https;`localhost`、`127.x` 默认 http;也可以写全 `码@http://…`。老写法 `pair <配对码> --server <地址>` 照样能用。只配对不连:加 `--no-start`。
30
+
31
+ ## 以后再连
32
+
33
+ 开了开机自启的不用管。没开的,重启电脑或关了终端之后运行:
34
+
35
+ ```bash
36
+ npx -y agent-hub-runner@latest start
37
+ ```
38
+
39
+ 服务器地址和令牌配对时已经存下了,不用再给。后台已经连着时再 `start` 会说「已经在后台连着,不用再开」。
40
+
41
+ ## 其他命令
42
+
43
+ ```bash
44
+ npx -y agent-hub-runner@latest status # 版本、连的哪个服务器、在不在跑、开机自启、每个 bot 的目录
45
+ npx -y agent-hub-runner@latest autostart on # 开机自动连上(Mac);升级到新版也用这条
46
+ npx -y agent-hub-runner@latest autostart off # 关掉开机自启
47
+ npx -y agent-hub-runner@latest autostart status
48
+ npx -y agent-hub-runner@latest stop # 停掉在跑的运行器(开机自启的下次开机还会起)
49
+ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复制的程序;会问要不要删配置
50
+ ```
51
+
52
+ 开机自启时,程序复制到 `~/.agent-runner/bin/<版本>/`,日志在 `~/.agent-runner/logs/runner.log`(超过 5MB 自动清空)。网页上提示「有新版本」时,按提示运行那一条升级命令。
53
+
54
+ ## 配置文件
55
+
56
+ - 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的目录。别发给别人。
57
+ - 想放别处:设环境变量 `AGENT_RUNNER_CONFIG=/某个路径/config.json`。
58
+ - 文件坏了:删掉它,回网页拿新配对码重新配对。
59
+
60
+ ## 常见问题
61
+
62
+ - `command not found: npx`:还没装 Node。
63
+ - 「需要 Node 22 或更新」(上面还有几行英文 `npm warn EBADENGINE`):Node 太旧,装新的 LTS 版后关掉终端重开。
64
+ - 「权限不够」/ `EACCES`:别用 sudo;运行 `sudo chown -R "$(whoami)" ~/.npm ~/.agent-runner` 修好再试。
65
+ - 「找不到服务器」「连不上服务器」「超时」:检查网络;命令要整条原样复制。
66
+ - 「配对码过期了 / 已经用过了 / 已经作废了」:网页上点「换一个码」再运行新命令。
67
+ - 「这个账号已经连着一台电脑了」:一个账号同时只能连一台,先在网页抽屉「连着的电脑」里点「断开」。
68
+
69
+ ## 断开、卸载
70
+
71
+ - 在网页抽屉「连着的电脑」里点「断开」:令牌立刻作废,再连要重新配对;开机自启也会自己关掉。
72
+ - 卸载:`npx -y agent-hub-runner@latest uninstall`(加 `--purge` 连配置一起删,不再问)。
73
+
74
+ ## 安全提示
75
+
76
+ - 别把放着密钥、令牌的文件夹授权出去:bot 在授权文件夹里读、写、改文件不用你批准。
77
+ - 跑命令前网页上会弹批准卡片,看清命令再点「允许一次」;命令本身能碰到文件夹以外的东西(没有沙箱)。
package/autostart.mjs ADDED
@@ -0,0 +1,188 @@
1
+ // @意图#R666(2026-10-29): Mac 开机自启(king 同意 R666 二丙):配对后选「是」就写一个 launchd 的 LaunchAgent,之后登录 Mac 自动连上,不用开终端。
2
+ // - 不靠 npx 缓存(路径不稳定、会被清):把运行器自己(零依赖的几份源码 + package.json)复制到 ~/.agent-runner/bin/<版本>/,current 软链指向它;
3
+ // plist 里跑 [node 绝对路径(优先 /opt/homebrew/bin/node、/usr/local/bin/node 这种稳定软链), ~/.agent-runner/bin/current/runner.mjs, start],开机不联网也能起来,
4
+ // 也不会每次登录自动拉 npm 上的新代码;升级 = 重新跑一次 npx -y agent-hub-runner@latest autostart on。
5
+ // - Label com.agenthub.runner;用的不是默认配置(AGENT_RUNNER_CONFIG 指别处)时加配置路径 sha256 前 8 位后缀,一份配置一个后台任务;
6
+ // 环境变量写明 AGENT_RUNNER_CONFIG 绝对路径、AGENT_RUNNER_LAUNCHD=1(运行器据此知道自己在后台),PATH 用开启时终端里的(bot 跑的命令要找得到 git、npm 这些)。
7
+ // - RunAtLoad;KeepAlive 只在非 0 退出时拉起(被网页断开、令牌失效、被顶掉都是 0 退出,不会被反复拉起);ThrottleInterval 10 秒;
8
+ // 日志 ~/.agent-runner/logs/runner.log(非默认配置 runner-<后缀>.log),每次启动超过 5MB 就清空。
9
+ // - launchctl 调用可以换成假的(测试用:参数里传,或环境变量 AGENT_RUNNER_LAUNCHCTL 指一个假程序),目录可以用 AGENT_RUNNER_HOME、AGENT_RUNNER_LAUNCH_AGENTS 换到临时目录。
10
+ import { createHash } from "node:crypto";
11
+ import { execFile } from "node:child_process";
12
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync } from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ export const LABEL = "com.agenthub.runner";
17
+ export const LOG_MAX_BYTES = 5 * 1024 * 1024;
18
+ export const THROTTLE_S = 10;
19
+ /** 复制到 ~/.agent-runner/bin/<版本>/ 的文件(package.json 用来读版本号) */
20
+ export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "autostart.mjs", "package.json"];
21
+ /** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
22
+ export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
23
+ export const ONLY_MAC = "开机自启目前只支持 Mac";
24
+
25
+ const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
26
+
27
+ /** 运行器自己的目录(配置、副本、日志都在这下面);测试可用 AGENT_RUNNER_HOME 换掉 */
28
+ export const runnerHome = (env = process.env) => env.AGENT_RUNNER_HOME || path.join(os.homedir(), ".agent-runner");
29
+ export const defaultConfigFile = (env = process.env) => path.join(runnerHome(env), "config.json");
30
+ export const launchAgentsDir = (env = process.env) => env.AGENT_RUNNER_LAUNCH_AGENTS || path.join(os.homedir(), "Library", "LaunchAgents");
31
+
32
+ /** 默认配置没有后缀;别的配置用绝对路径 sha256 前 8 位 */
33
+ export function configTag(configFile, env = process.env) {
34
+ const abs = path.resolve(configFile);
35
+ if (abs === path.resolve(defaultConfigFile(env))) return "";
36
+ return createHash("sha256").update(abs).digest("hex").slice(0, 8);
37
+ }
38
+ export const labelFor = (configFile, env = process.env) => { const t = configTag(configFile, env); return t ? `${LABEL}.${t}` : LABEL; };
39
+ export const plistPathFor = (configFile, env = process.env) => path.join(launchAgentsDir(env), `${labelFor(configFile, env)}.plist`);
40
+ export const logFileFor = (configFile, env = process.env) => {
41
+ const t = configTag(configFile, env);
42
+ return path.join(runnerHome(env), "logs", t ? `runner-${t}.log` : "runner.log");
43
+ };
44
+
45
+ /** node 的绝对路径:稳定软链和当前 node 是同一个文件就用软链,否则用当前的 */
46
+ export function pickNode(execPath = process.execPath, candidates = STABLE_NODES) {
47
+ const me = real(execPath);
48
+ for (const c of candidates) if (existsSync(c) && real(c) === me) return c;
49
+ return execPath;
50
+ }
51
+
52
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
53
+
54
+ /** 生成 LaunchAgent 的 plist(XML) */
55
+ export function plistXml({ label, args, env, log }) {
56
+ const envXml = Object.entries(env).map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`).join("\n");
57
+ return `<?xml version="1.0" encoding="UTF-8"?>
58
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
59
+ <plist version="1.0">
60
+ <dict>
61
+ <key>Label</key>
62
+ <string>${esc(label)}</string>
63
+ <key>ProgramArguments</key>
64
+ <array>
65
+ ${args.map((a) => ` <string>${esc(a)}</string>`).join("\n")}
66
+ </array>
67
+ <key>EnvironmentVariables</key>
68
+ <dict>
69
+ ${envXml}
70
+ </dict>
71
+ <key>RunAtLoad</key>
72
+ <true/>
73
+ <key>KeepAlive</key>
74
+ <dict>
75
+ <key>SuccessfulExit</key>
76
+ <false/>
77
+ </dict>
78
+ <key>ThrottleInterval</key>
79
+ <integer>${THROTTLE_S}</integer>
80
+ <key>StandardOutPath</key>
81
+ <string>${esc(log)}</string>
82
+ <key>StandardErrorPath</key>
83
+ <string>${esc(log)}</string>
84
+ </dict>
85
+ </plist>
86
+ `;
87
+ }
88
+
89
+ /** 把运行器复制到 <home>/bin/<版本>/,current 软链指过去(先建临时链再改名,换的那一下不会断)。返回 current/runner.mjs 的路径。 */
90
+ export function installCopy({ srcDir, version, env = process.env }) {
91
+ const binRoot = path.join(runnerHome(env), "bin");
92
+ const dest = path.join(binRoot, version);
93
+ mkdirSync(dest, { recursive: true, mode: 0o700 });
94
+ if (real(srcDir) !== real(dest)) {
95
+ for (const f of COPY_FILES) {
96
+ const from = path.join(srcDir, f);
97
+ if (existsSync(from)) copyFileSync(from, path.join(dest, f));
98
+ }
99
+ }
100
+ const cur = path.join(binRoot, "current");
101
+ const tmp = path.join(binRoot, `.current-${process.pid}`);
102
+ rmSync(tmp, { force: true });
103
+ symlinkSync(version, tmp);
104
+ renameSync(tmp, cur);
105
+ return path.join(cur, "runner.mjs");
106
+ }
107
+
108
+ /** 默认的 launchctl:返回 { code, stdout, stderr },不抛错。AGENT_RUNNER_LAUNCHCTL 可换成别的程序(测试用假的,不真加载) */
109
+ export function runLaunchctl(args, env = process.env) {
110
+ return new Promise((resolve) => {
111
+ execFile(env.AGENT_RUNNER_LAUNCHCTL || "launchctl", args, { encoding: "utf8" }, (err, stdout, stderr) => resolve({ code: err ? (typeof err.code === "number" ? err.code : 1) : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }));
112
+ });
113
+ }
114
+ const uid = () => (typeof process.getuid === "function" ? process.getuid() : 0);
115
+
116
+ /**
117
+ * 开启开机自启:复制自己、写 plist、(先卸掉旧的)再加载。加载后 launchd 马上起一个后台运行器(RunAtLoad)。
118
+ * 返回 { label, plist, script, node, log }。
119
+ */
120
+ export async function autostartOn({ configFile, version, srcDir, execPath = process.execPath, env = process.env, launchctl, platform = process.platform }) {
121
+ launchctl ??= (a) => runLaunchctl(a, env);
122
+ if (platform !== "darwin") throw new Error(ONLY_MAC);
123
+ const cfg = path.resolve(configFile);
124
+ if (!existsSync(cfg)) throw new Error(`还没配对:没有 ${cfg},配对成功后才能设开机自启`);
125
+ const script = installCopy({ srcDir, version, env });
126
+ const node = pickNode(execPath);
127
+ const label = labelFor(cfg, env);
128
+ const log = logFileFor(cfg, env);
129
+ mkdirSync(path.dirname(log), { recursive: true, mode: 0o700 });
130
+ const vars = { AGENT_RUNNER_CONFIG: cfg, AGENT_RUNNER_LAUNCHD: "1", PATH: env.PATH || "/usr/bin:/bin:/usr/sbin:/sbin", LANG: env.LANG || "en_US.UTF-8" };
131
+ if (env.AGENT_RUNNER_HOME) vars.AGENT_RUNNER_HOME = env.AGENT_RUNNER_HOME;
132
+ const plist = plistPathFor(cfg, env);
133
+ mkdirSync(path.dirname(plist), { recursive: true });
134
+ writeFileSync(plist, plistXml({ label, args: [node, script, "start"], env: vars, log }), { mode: 0o644 });
135
+ await launchctl(["bootout", `gui/${uid()}/${label}`]); // 以前加载过(升级):先卸掉,没有也不要紧
136
+ const r = await launchctl(["bootstrap", `gui/${uid()}`, plist]);
137
+ if (r.code !== 0) throw new Error(`开机自启没设成:launchctl bootstrap 失败(${(r.stderr || r.stdout).trim() || r.code})`);
138
+ return { label, plist, script, node, log };
139
+ }
140
+
141
+ /** 关掉开机自启:卸载任务(后台那个会被停掉)、删 plist。返回 { wasOn }(原来有没有 plist)。 */
142
+ export async function autostartOff({ configFile, env = process.env, launchctl }) {
143
+ launchctl ??= (a) => runLaunchctl(a, env);
144
+ const cfg = path.resolve(configFile);
145
+ const plist = plistPathFor(cfg, env);
146
+ const wasOn = existsSync(plist);
147
+ rmSync(plist, { force: true });
148
+ await launchctl(["bootout", `gui/${uid()}/${labelFor(cfg, env)}`]);
149
+ return { wasOn };
150
+ }
151
+
152
+ /** 开机自启现在的样子:装没装(有没有 plist)、加载没有、后台进程号;plist 里的 node / 脚本找不到就写进 problems */
153
+ export async function autostartStatus({ configFile, env = process.env, launchctl, platform = process.platform }) {
154
+ launchctl ??= (a) => runLaunchctl(a, env);
155
+ const cfg = path.resolve(configFile);
156
+ const label = labelFor(cfg, env);
157
+ const plist = plistPathFor(cfg, env);
158
+ const out = { supported: platform === "darwin", installed: existsSync(plist), loaded: false, pid: null, label, plist, log: logFileFor(cfg, env), problems: [] };
159
+ if (!out.supported) return out;
160
+ if (out.installed) {
161
+ const xml = readFileSync(plist, "utf8");
162
+ const args = [...(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? "").matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1].replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&"));
163
+ if (args[0] && !existsSync(args[0])) out.problems.push(`找不到 node:${args[0]}(换过 Node 的装法?重新运行 autostart on)`);
164
+ if (args[1] && !existsSync(args[1])) out.problems.push(`找不到运行器副本:${args[1]}(重新运行 autostart on)`);
165
+ }
166
+ const r = await launchctl(["print", `gui/${uid()}/${label}`]);
167
+ if (r.code === 0) {
168
+ out.loaded = true;
169
+ const m = /\bpid = (\d+)/.exec(r.stdout);
170
+ if (m) out.pid = Number(m[1]);
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /** 还有没有别的配置开着自启(uninstall 时决定删不删共用的 bin 副本) */
176
+ export function otherAutostarts(configFile, env = process.env) {
177
+ const mine = `${labelFor(configFile, env)}.plist`;
178
+ try { return readdirSync(launchAgentsDir(env)).filter((f) => f.startsWith(LABEL) && f.endsWith(".plist") && f !== mine); } catch { return []; }
179
+ }
180
+
181
+ /** 日志超过 max 字节就清空(launchd 用追加方式打开,清空后接着往后写不会乱)。清了返回 true。 */
182
+ export function truncateLogIfBig(file, max = LOG_MAX_BYTES) {
183
+ try {
184
+ if (statSync(file).size <= max) return false;
185
+ truncateSync(file, 0);
186
+ return true;
187
+ } catch { return false; }
188
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // @意图#R664(2026-10-29): npm 包 agent-hub-runner 的命令入口(package.json 的 bin):客户电脑 npm i -g agent-hub-runner 后直接跑
3
+ // agent-hub-runner pair <配对码> --start --server <地址> / start / status;逻辑全在 ../runner.mjs,这里只把参数和命令名交过去。
4
+ // @意图#R666(2026-10-29): 主要用法改成 npx -y agent-hub-runner@latest pair 码@地址;最先查 Node 版本(在加载别的代码之前,老 Node 上也只打一句大白话,
5
+ // 说明上面 npm 的英文 EBADENGINE 警告不用管);出错用 explainError 打大白话(权限不够单独说)
6
+ const major = Number(process.versions.node.split(".")[0]);
7
+ if (major < 22 || typeof WebSocket === "undefined") {
8
+ console.error(`需要 Node 22 或更新,你这台电脑上是 ${process.versions.node}。\n(上面如果有几行英文 npm warn EBADENGINE,说的就是这个,不用管它。)\n去 https://nodejs.org 下载 LTS 版装上(Mac 选 .pkg 安装包),装好后关掉终端重新打开,再运行同一条命令。`);
9
+ process.exit(1);
10
+ }
11
+ const { main, progName, explainError } = await import("../runner.mjs");
12
+ main(process.argv.slice(2), progName()).catch((e) => { console.error(explainError(e)); process.exit(1); });
package/edit.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // @意图#R636(2026-10-27): 运行器零依赖,edit 的匹配规则照搬 pi 1.1.0 的 edit-diff(先精确、再按引号/破折号/行尾空白放宽),
2
+ // 这样 bot 在你电脑上改文件和 pi 原版的 edit 行为一样;路径判断在 ops.mjs 里做,这里只管文字替换。
3
+ export function detectLineEnding(content) {
4
+ const crlfIdx = content.indexOf("\r\n");
5
+ const lfIdx = content.indexOf("\n");
6
+ if (lfIdx === -1 || crlfIdx === -1) return "\n";
7
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
8
+ }
9
+ export const normalizeToLF = (text) => text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
10
+ export const restoreLineEndings = (text, ending) => (ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text);
11
+
12
+ export function normalizeForFuzzyMatch(text) {
13
+ return text.normalize("NFKC")
14
+ .split("\n").map((line) => line.trimEnd()).join("\n")
15
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
16
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
17
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
18
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
19
+ }
20
+ const splitLinesWithEndings = (content) => content.match(/[^\n]*\n|[^\n]+/g) ?? [];
21
+ function getLineSpans(content) {
22
+ let offset = 0;
23
+ return splitLinesWithEndings(content).map((line) => {
24
+ const span = { start: offset, end: offset + line.length };
25
+ offset = span.end;
26
+ return span;
27
+ });
28
+ }
29
+ function getReplacementLineRange(lines, r) {
30
+ const s = r.matchIndex;
31
+ const e = r.matchIndex + r.matchLength;
32
+ let startLine = -1;
33
+ for (let i = 0; i < lines.length; i++) {
34
+ if (s >= lines[i].start && s < lines[i].end) { startLine = i; break; }
35
+ }
36
+ if (startLine === -1) throw new Error("Replacement range is outside the base content.");
37
+ let endLine = startLine;
38
+ while (endLine < lines.length && lines[endLine].end < e) endLine++;
39
+ if (endLine >= lines.length) throw new Error("Replacement range is outside the base content.");
40
+ return { startLine, endLine: endLine + 1 };
41
+ }
42
+ function applyReplacements(content, replacements, offset = 0) {
43
+ let result = content;
44
+ for (let i = replacements.length - 1; i >= 0; i--) {
45
+ const r = replacements[i];
46
+ const at = r.matchIndex - offset;
47
+ result = result.substring(0, at) + r.newText + result.substring(at + r.matchLength);
48
+ }
49
+ return result;
50
+ }
51
+ function applyReplacementsPreservingUnchangedLines(originalContent, baseContent, replacements) {
52
+ const originalLines = splitLinesWithEndings(originalContent);
53
+ const baseLines = getLineSpans(baseContent);
54
+ if (originalLines.length !== baseLines.length) {
55
+ throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
56
+ }
57
+ const groups = [];
58
+ for (const r of [...replacements].sort((a, b) => a.matchIndex - b.matchIndex)) {
59
+ const range = getReplacementLineRange(baseLines, r);
60
+ const cur = groups[groups.length - 1];
61
+ if (cur && range.startLine < cur.endLine) {
62
+ cur.endLine = Math.max(cur.endLine, range.endLine);
63
+ cur.replacements.push(r);
64
+ continue;
65
+ }
66
+ groups.push({ ...range, replacements: [r] });
67
+ }
68
+ let idx = 0;
69
+ let result = "";
70
+ for (const g of groups) {
71
+ result += originalLines.slice(idx, g.startLine).join("");
72
+ const gs = baseLines[g.startLine].start;
73
+ const ge = baseLines[g.endLine - 1].end;
74
+ result += applyReplacements(baseContent.slice(gs, ge), g.replacements, gs);
75
+ idx = g.endLine;
76
+ }
77
+ return result + originalLines.slice(idx).join("");
78
+ }
79
+ function fuzzyFindText(content, oldText) {
80
+ const exact = content.indexOf(oldText);
81
+ if (exact !== -1) return { found: true, index: exact, matchLength: oldText.length, usedFuzzyMatch: false };
82
+ const fc = normalizeForFuzzyMatch(content);
83
+ const fo = normalizeForFuzzyMatch(oldText);
84
+ const fi = fc.indexOf(fo);
85
+ if (fi === -1) return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
86
+ return { found: true, index: fi, matchLength: fo.length, usedFuzzyMatch: true };
87
+ }
88
+ const countOccurrences = (content, oldText) => normalizeForFuzzyMatch(content).split(normalizeForFuzzyMatch(oldText)).length - 1;
89
+
90
+ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
91
+ const ne = edits.map((e) => ({ oldText: normalizeToLF(String(e.oldText)), newText: normalizeToLF(String(e.newText)) }));
92
+ const n = ne.length;
93
+ for (let i = 0; i < n; i++) {
94
+ if (ne[i].oldText.length === 0) {
95
+ throw new Error(n === 1 ? `oldText must not be empty in ${path}.` : `edits[${i}].oldText must not be empty in ${path}.`);
96
+ }
97
+ }
98
+ const usedFuzzy = ne.map((e) => fuzzyFindText(normalizedContent, e.oldText)).some((m) => m.usedFuzzyMatch);
99
+ const base = usedFuzzy ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
100
+ const matched = [];
101
+ for (let i = 0; i < n; i++) {
102
+ const m = fuzzyFindText(base, ne[i].oldText);
103
+ if (!m.found) {
104
+ throw new Error(n === 1
105
+ ? `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`
106
+ : `Could not find edits[${i}] in ${path}. The oldText must match exactly including all whitespace and newlines.`);
107
+ }
108
+ const occ = countOccurrences(base, ne[i].oldText);
109
+ if (occ > 1) {
110
+ throw new Error(n === 1
111
+ ? `Found ${occ} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`
112
+ : `Found ${occ} occurrences of edits[${i}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
113
+ }
114
+ matched.push({ editIndex: i, matchIndex: m.index, matchLength: m.matchLength, newText: ne[i].newText });
115
+ }
116
+ matched.sort((a, b) => a.matchIndex - b.matchIndex);
117
+ for (let i = 1; i < matched.length; i++) {
118
+ const p = matched[i - 1];
119
+ const c = matched[i];
120
+ if (p.matchIndex + p.matchLength > c.matchIndex) {
121
+ throw new Error(`edits[${p.editIndex}] and edits[${c.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
122
+ }
123
+ }
124
+ const newContent = usedFuzzy
125
+ ? applyReplacementsPreservingUnchangedLines(normalizedContent, base, matched)
126
+ : applyReplacements(base, matched);
127
+ if (newContent === normalizedContent) {
128
+ throw new Error(n === 1
129
+ ? `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`
130
+ : `No changes made to ${path}. The replacements produced identical content.`);
131
+ }
132
+ return { baseContent: normalizedContent, newContent };
133
+ }
134
+
135
+ /** 整个文件的改法:去 BOM、统一换行再匹配,写回时恢复原来的换行和 BOM(和 pi 原版一样)。 */
136
+ // @意图#R636(2026-10-27): 和 pi edit 同样的 BOM、换行处理
137
+ export function editText(raw, edits, path) {
138
+ const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
139
+ const content = bom ? raw.slice(1) : raw;
140
+ const ending = detectLineEnding(content);
141
+ const { newContent } = applyEditsToNormalizedContent(normalizeToLF(content), edits, path);
142
+ return bom + restoreLineEndings(newContent, ending);
143
+ }
package/ops.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径):
2
+ // 先按授权目录换算成真实路径(解开符号链接,包括指向不存在目标的符号链接),不在授权目录里的一律拒绝;bash 的工作目录就是授权目录。
3
+ // 第一轮没有沙箱(king 定的范围):bash 里的命令本身能碰到目录外的东西,只能靠你看清批准卡片再点。
4
+ import { spawn } from "node:child_process";
5
+ import fs from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { editText } from "./edit.mjs";
9
+
10
+ export const MAX_READ_BYTES = 20 * 1024 * 1024;
11
+ /** bash 没给 timeout 时的默认上限(秒),到点杀掉整个进程组。 */
12
+ export const DEFAULT_BASH_TIMEOUT_S = 300;
13
+
14
+ /** 把 bot 给的路径按授权目录换成绝对路径(~ 指你的家目录,相对路径相对授权目录;去掉开头的 @,和 pi 一样)。 */
15
+ // @意图#R636(2026-10-27): 相对路径一律相对授权目录
16
+ export function expandPath(p, root) {
17
+ let s = String(p).replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
18
+ if (s.startsWith("@")) s = s.slice(1);
19
+ if (s === "~") s = os.homedir();
20
+ else if (s.startsWith("~/")) s = path.join(os.homedir(), s.slice(2));
21
+ return path.resolve(root, s);
22
+ }
23
+
24
+ /** 真实路径:存在的直接 realpath;不存在的找到最近的已存在上级再拼回去;悬空的符号链接按它指向的地方算。 */
25
+ // @意图#R636(2026-10-27): 挡住 ../、越界符号链接、悬空符号链接(写的时候会顺着链接写到目录外)
26
+ export async function realOf(abs, depth = 0) {
27
+ if (depth > 40) throw new Error("符号链接太多层");
28
+ try {
29
+ return await fs.realpath(abs);
30
+ } catch (e) {
31
+ if (e?.code !== "ENOENT") throw e;
32
+ let st;
33
+ try { st = await fs.lstat(abs); } catch { st = undefined; }
34
+ if (st?.isSymbolicLink()) {
35
+ const target = path.resolve(path.dirname(abs), await fs.readlink(abs));
36
+ return realOf(target, depth + 1);
37
+ }
38
+ const parent = path.dirname(abs);
39
+ if (parent === abs) throw e;
40
+ return path.join(await realOf(parent, depth + 1), path.basename(abs));
41
+ }
42
+ }
43
+
44
+ export const inside = (real, root) => real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
45
+
46
+ /** 换算并检查:在授权目录里返回真实路径,不在就抛「目录外路径已拒绝」。 */
47
+ // @意图#R636(2026-10-27): 授权目录外的路径一律拒绝
48
+ export async function resolveInside(p, root) {
49
+ if (typeof p !== "string" || !p.trim()) throw new Error("路径是空的");
50
+ let real;
51
+ try { real = await realOf(expandPath(p, root)); } catch (e) { throw new Error(`无法解析路径:${p}(${e?.code ?? e?.message ?? e})`); }
52
+ if (!inside(real, root)) throw new Error(`目录外路径已拒绝:${p}(授权目录是 ${root})`);
53
+ return real;
54
+ }
55
+
56
+ function imageMime(buf) {
57
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
58
+ if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
59
+ const head = buf.subarray(0, 6).toString("latin1");
60
+ if (head === "GIF87a" || head === "GIF89a") return "image/gif";
61
+ if (buf.length >= 12 && buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
62
+ if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) return "image/bmp";
63
+ return null;
64
+ }
65
+
66
+ /** read:返回整份文字(截断由服务器照 pi 原版做)或图片。 */
67
+ // @意图#R636(2026-10-27): 读文件不用批准,但同样只能读授权目录里的
68
+ export async function opRead(args, root) {
69
+ const real = await resolveInside(args.path, root);
70
+ const st = await fs.stat(real);
71
+ if (st.isDirectory()) throw new Error(`${args.path} 是目录,不是文件(看目录里有什么用 bash 的 ls)`);
72
+ if (st.size > MAX_READ_BYTES) throw new Error(`${args.path} 太大(${st.size} 字节),超过 ${MAX_READ_BYTES} 字节;用 bash 的 head/sed 看一部分`);
73
+ const buf = await fs.readFile(real);
74
+ const mime = imageMime(buf);
75
+ if (mime) return { kind: "image", mime, data: buf.toString("base64") };
76
+ return { kind: "text", text: buf.toString("utf8") };
77
+ }
78
+
79
+ /** write:建好上级目录再整份写入。 */
80
+ // @意图#R636(2026-10-27): 写文件(已经过批准,只执行这一次)
81
+ export async function opWrite(args, root) {
82
+ if (typeof args.content !== "string") throw new Error("content 必须是文字");
83
+ const real = await resolveInside(args.path, root);
84
+ await fs.mkdir(path.dirname(real), { recursive: true });
85
+ await fs.writeFile(real, args.content, "utf8");
86
+ return { bytes: Buffer.byteLength(args.content, "utf8") };
87
+ }
88
+
89
+ /** edit:和 pi 原版同样的精确替换。 */
90
+ // @意图#R636(2026-10-27): 改文件(已经过批准,只执行这一次)
91
+ export async function opEdit(args, root) {
92
+ if (!Array.isArray(args.edits) || args.edits.length === 0) throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
93
+ const real = await resolveInside(args.path, root);
94
+ let raw;
95
+ try { raw = await fs.readFile(real, "utf8"); } catch (e) { throw new Error(`Could not edit file: ${args.path}. Error code: ${e?.code ?? "unknown"}.`); }
96
+ const next = editText(raw, args.edits, args.path);
97
+ await fs.writeFile(real, next, "utf8");
98
+ return { replaced: args.edits.length };
99
+ }
100
+
101
+ function killGroup(child) {
102
+ if (!child.pid) return;
103
+ try { process.kill(-child.pid, "SIGKILL"); } catch { /* 进程组已经没了 */ }
104
+ try { child.kill("SIGKILL"); } catch { /* ignore */ }
105
+ }
106
+
107
+ /**
108
+ * bash:在授权目录里用你终端的环境变量跑(服务器的环境变量不下发)。输出边跑边交给 onData;
109
+ * 超时或 signal 中止时杀掉整个进程组。返回 { exitCode, timedOut, aborted }。
110
+ */
111
+ // @意图#R636(2026-10-27): 跑命令(已经过批准,只执行这一次),有超时,超时杀进程
112
+ export function opBash(args, root, { onData, signal } = {}) {
113
+ const command = args.command;
114
+ if (typeof command !== "string" || !command.trim()) return Promise.reject(new Error("command 是空的"));
115
+ let timeoutS = DEFAULT_BASH_TIMEOUT_S;
116
+ if (args.timeout !== undefined && args.timeout !== null) {
117
+ const t = Number(args.timeout);
118
+ if (!Number.isFinite(t) || t <= 0) return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
119
+ timeoutS = t;
120
+ }
121
+ return new Promise((resolve, reject) => {
122
+ const child = spawn("bash", ["-c", command], { cwd: root, detached: true, stdio: ["ignore", "pipe", "pipe"], env: process.env });
123
+ let timedOut = false;
124
+ let aborted = false;
125
+ const timer = setTimeout(() => { timedOut = true; killGroup(child); }, timeoutS * 1000);
126
+ const onAbort = () => { aborted = true; killGroup(child); };
127
+ if (signal) {
128
+ if (signal.aborted) onAbort();
129
+ else signal.addEventListener("abort", onAbort, { once: true });
130
+ }
131
+ child.stdout.on("data", (d) => onData?.(d));
132
+ child.stderr.on("data", (d) => onData?.(d));
133
+ const done = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); };
134
+ child.on("error", (e) => { done(); reject(e); });
135
+ child.on("close", (code, sig) => {
136
+ done();
137
+ const exitCode = code ?? (sig ? 128 + (os.constants.signals[sig] ?? 0) : 1);
138
+ resolve({ exitCode, timedOut, aborted, timeout: timeoutS });
139
+ });
140
+ });
141
+ }
142
+
143
+ /** 按 op 分派;未知 op 拒绝。 */
144
+ // @意图#R636(2026-10-27): 协议里只有这四种工具调用
145
+ export function runOp(op, args, root, extra) {
146
+ const a = args && typeof args === "object" ? args : {};
147
+ if (op === "read") return opRead(a, root);
148
+ if (op === "write") return opWrite(a, root);
149
+ if (op === "edit") return opEdit(a, root);
150
+ if (op === "bash") return opBash(a, root, extra);
151
+ return Promise.reject(new Error(`未知操作 ${op}`));
152
+ }
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "agent-hub-runner",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准)",
5
+ "type": "module",
6
+ "bin": { "agent-hub-runner": "bin/agent-hub-runner.mjs" },
7
+ "files": ["runner.mjs", "ops.mjs", "edit.mjs", "autostart.mjs", "bin/agent-hub-runner.mjs", "README.md"],
8
+ "engines": { "node": ">=22" },
9
+ "scripts": { "test": "node --test" }
10
+ }
package/runner.mjs ADDED
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env node
2
+ // @意图#R636(2026-10-27): 你电脑上的运行器(第一轮最小可用,king 定甲):零第三方依赖,只用 Node 22+ 自带的 WebSocket、fetch、进程和文件 API。
3
+ // 用法:
4
+ // npx -y agent-hub-runner@latest pair <配对码>@<服务器地址> (R666:抽屉里就这一条;配完直接连上)
5
+ // node agent-hub/runner/runner.mjs pair <配对码> [--no-start] [--server http://127.0.0.1:3220]
6
+ // node agent-hub/runner/runner.mjs start | stop | status | autostart on|off|status | uninstall
7
+ // @意图#R666(2026-10-29): 更傻瓜(king 同意 R666):抽屉只给一条 npx 命令、配对码后面带服务器(码@域名)、pair 默认配完直接 start(--no-start 只配对);
8
+ // Mac 上配对后问要不要开机自启(launchd,见 autostart.mjs);报错一律大白话(Node 版本、连不上的各种原因、配对码不对/用过/过期、已有电脑、权限不够、配置坏了);
9
+ // 同一份配置只能跑一个(配置旁边 <配置>.lock 记进程号);被服务器 4409 顶掉不再重连;4401/4403/顶掉都正常退出(0)并关掉自启;
10
+ // hello 报自己的版本和是不是开机自启,网页据此提示「有新版本」。
11
+ // @意图#R664(2026-10-29): 运行器做成客户电脑 npm 安装的小工具(包名、命令名都是 agent-hub-runner,入口 bin/agent-hub-runner.mjs):
12
+ // npm i -g agent-hub-runner → agent-hub-runner pair <配对码> --start --server <地址>;以后 agent-hub-runner start。仓库里 node 直接跑照旧。
13
+ // @意图#R663(2026-10-29): pair 加 --start(二甲):配对成功后直接接着 start 连上,网页上只给一条命令;不带 --start 的老用法不变
14
+ // @意图#R640(2026-10-27): 一台电脑一个运行器,但每个 bot 有自己的授权目录(bot→目录 对照表 bot_dirs),互相碰不到;king 定乙:没有默认目录,
15
+ // 每个 bot 都要在它的私聊里「我的电脑」(R663 起是输入框下面那一行,原来是右栏)点「选择目录」,运行器在本机弹窗选。服务器只报 bot_id,定不了路径。pair 不再收 --dir。
16
+ // @意图#R639(2026-10-27): 配对不再选目录(king 定乙,替换 R637 配对时弹窗);start 连上后在网页「我的电脑」点「选择目录」,
17
+ // 服务器经这条 WebSocket 叫运行器在 Mac 上弹系统选文件夹窗口,选完马上生效并写回配置文件。
18
+ // 配对后长期令牌和授权目录存在 ~/.agent-runner/config.json(权限 600);目录只能由你在本机窗口里选,服务器只能叫你弹窗、定不了目录。
19
+ // (R639 时目录是一台电脑一个、配对时还能 --dir;R640 起换成每个 bot 一个,老配置里的单一 dir 读到直接忽略。)
20
+ // start 在终端前台运行,主动用 WebSocket 连服务器;按 Ctrl-C 立刻断开。没有沙箱。(R666 起有开机自启、npx 用法。)
21
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
22
+ import fs from "node:fs/promises";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import readline from "node:readline/promises";
26
+ import { execFile } from "node:child_process";
27
+ import { fileURLToPath } from "node:url";
28
+ import { runOp } from "./ops.mjs";
29
+ import { autostartOff, autostartOn, autostartStatus, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
30
+
31
+ export const PROTOCOL_VERSION = 1;
32
+ /** npm 包名,也是装好后的命令名 */
33
+ export const PKG_BIN = "agent-hub-runner";
34
+ /** R666:抽屉里给的、npx 运行时提示里写的命令前缀(@latest:每次都用最新版,也不会被电脑上旧的全局安装盖掉) */
35
+ export const NPX_CMD = `npx -y ${PKG_BIN}@latest`;
36
+ /** R666:自己的版本号(读同目录的 package.json;开机自启的副本也带着它),hello 时报给服务器 */
37
+ export const RUNNER_VERSION = (() => { try { return String(JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version); } catch { return "0.0.0"; } })();
38
+ /** R666:配对请求、连接超时(秒) */
39
+ export const PAIR_TIMEOUT_MS = 15_000;
40
+ export const HEARTBEAT_MS = 10_000;
41
+ export const DEFAULT_SERVER = "http://127.0.0.1:3220";
42
+ /** 服务器用这两个关闭码表示令牌不对 / 已在网页上断开:不再重连。 */
43
+ export const CLOSE_BAD_TOKEN = 4401;
44
+ export const CLOSE_REVOKED = 4403;
45
+ /** R666:同一台电脑又连上来一个(同一份配置开了两个运行器),服务器把旧的这条用 4409 断开:不再重连 */
46
+ export const CLOSE_REPLACED = 4409;
47
+
48
+ // @意图#R666(2026-10-29): 默认目录可用 AGENT_RUNNER_HOME 换(测试、开机自启的副本用);不设时和原来一样是 ~/.agent-runner/config.json
49
+ export const configPath = () => process.env.AGENT_RUNNER_CONFIG || path.join(runnerHome(), "config.json");
50
+
51
+ const stamp = () => new Date().toLocaleTimeString("zh-CN", { hour12: false });
52
+ const say = (s) => console.log(`[${stamp()}] ${s}`);
53
+
54
+ // @意图#R636(2026-10-27): 令牌文件权限 600、目录 700
55
+ export function saveConfig(cfg, file = configPath()) {
56
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
57
+ writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
58
+ chmodSync(file, 0o600);
59
+ }
60
+ // @意图#R666(2026-10-29): 提示改成现在网页上的位置(输入框下面「我的电脑」那一行),不再说「连接电脑」;配置文件坏了说清楚怎么办
61
+ export function loadConfig(file = configPath()) {
62
+ if (!existsSync(file)) throw new Error(`还没配对:没有 ${file}。先在网页上 bot 的私聊里点输入框下面「我的电脑」那一行,复制里面的命令运行。`);
63
+ const text = readFileSync(file, "utf8");
64
+ try { return JSON.parse(text); }
65
+ catch { throw new Error(`配置文件坏了:${file}。删掉它(rm "${file}"),再回网页拿新配对码重新配对。`); }
66
+ }
67
+
68
+ // @意图#R666(2026-10-29): pair 默认配完直接 start:--start 照样认(老命令),--no-start 只配对;--autostart / --no-autostart 跳过「开机自动连上?」的提问;
69
+ // uninstall --purge 连配置一起删;--version / -v 打版本
70
+ // @意图#R663(2026-10-29): 认 --start(只对 pair 有意义,不带值)
71
+ export function parseArgs(argv) {
72
+ const [cmd, ...rest] = argv;
73
+ const out = { cmd: cmd === "--version" || cmd === "-v" ? "version" : cmd === "--help" || cmd === "-h" ? "help" : cmd, positional: [] };
74
+ for (let i = 0; i < rest.length; i++) {
75
+ const a = rest[i];
76
+ if (a === "--dir") out.dir = rest[++i];
77
+ else if (a === "--server") out.server = rest[++i];
78
+ else if (a === "--name") out.name = rest[++i];
79
+ else if (a === "--start") out.start = true;
80
+ else if (a === "--no-start") out.start = false;
81
+ else if (a === "--autostart") out.autostart = true;
82
+ else if (a === "--no-autostart") out.autostart = false;
83
+ else if (a === "--purge") out.purge = true;
84
+ else if (a.startsWith("--")) throw new Error(`不认识的参数 ${a}`);
85
+ else out.positional.push(a);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** R666:Node 太低时的大白话(bin 入口在加载别的代码之前也用同样的话) */
91
+ export const nodeTooOld = (v) => `需要 Node 22 或更新,你这台电脑上是 ${v}。\n(上面如果有几行英文 npm warn EBADENGINE,说的就是这个,不用管它。)\n去 https://nodejs.org 下载 LTS 版装上(Mac 选 .pkg 安装包),装好后关掉终端重新打开,再运行同一条命令。`;
92
+ // @意图#R666(2026-10-29): 版本太低的提示改成大白话(怎么装、英文警告不用管)
93
+ function checkNode() {
94
+ const major = Number(process.versions.node.split(".")[0]);
95
+ if (major < 22 || typeof WebSocket === "undefined") throw new Error(nodeTooOld(process.versions.node));
96
+ }
97
+
98
+ export const PICK_PROMPT = "选择要授权给 bot 的文件夹";
99
+ export const PICK_SCRIPT = `POSIX path of (choose folder with prompt "${PICK_PROMPT}")`;
100
+ /** R640:这个 bot 在对照表里没有目录时,调用一律回这句。 */
101
+ // @意图#R666(2026-10-29): 「右栏」是 R663 以前的位置,改成输入框下面「我的电脑」那一行(和服务器 devices.ts 的 NO_DIR 一字不差)
102
+ export const NO_DIR = "还没给这个 bot 选目录,请在这个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」";
103
+ export const PICK_ONLY_MAC = "只支持 Mac:选目录的窗口只在 Mac 上弹";
104
+ /** R640:pair 带了 --dir 时的提示(不报错,照常配对,目录不用)。 */
105
+ export const PAIR_DIR_IGNORED = "目录请连上后在每个 bot 的私聊里选(--dir 已不用,这次没用它)";
106
+
107
+ /** 配置里的 bot→目录 对照表。只认 bot_dirs 里值是非空字符串的条目;老格式的单一 dir 直接忽略(所有 bot 都没有目录)。 */
108
+ // @意图#R640(2026-10-27): king 定乙:没有默认目录;老配置的 dir 不迁成任何 bot 的目录
109
+ export function botDirsOf(cfg) {
110
+ const out = {};
111
+ const m = cfg && typeof cfg.bot_dirs === "object" && cfg.bot_dirs !== null && !Array.isArray(cfg.bot_dirs) ? cfg.bot_dirs : {};
112
+ for (const [bot, dir] of Object.entries(m)) if (bot && typeof dir === "string" && dir) out[bot] = dir;
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Mac 上弹系统「选文件夹」窗口。选了返回 POSIX 路径;点取消返回 null;signal 中止时关掉窗口进程并抛 AbortError;
118
+ * 弹不出来(比如没有图形界面)抛错。
119
+ */
120
+ // @意图#R640(2026-10-27): 弹不出来时不再提 --dir(pair 不收 --dir 了)
121
+ // @意图#R639(2026-10-27): 改成网页点「选择目录」时调用;服务器超时发 abort 要能关掉弹窗进程
122
+ // @意图#R637(2026-10-27): 不写 --dir 时用 osascript 弹系统选文件夹窗口(king 定);取消和弹不出来分开报
123
+ export function pickDirMac({ signal } = {}) {
124
+ return new Promise((resolve, reject) => {
125
+ execFile("osascript", ["-e", PICK_SCRIPT], { encoding: "utf8", signal }, (err, stdout, stderr) => {
126
+ if (!err) {
127
+ const p = String(stdout).trim();
128
+ return p ? resolve(p) : resolve(null);
129
+ }
130
+ if (signal?.aborted || err.name === "AbortError") return reject(Object.assign(new Error("已中止"), { name: "AbortError" }));
131
+ if (/-128|User canceled|用户已取消/.test(String(stderr))) return resolve(null);
132
+ const why = String(stderr).trim() || err.message;
133
+ reject(new Error(`弹不出选文件夹的窗口(${why})`));
134
+ });
135
+ });
136
+ }
137
+
138
+ /** 检查授权目录:存在、换成真实路径、是目录、不是磁盘根目录。返回真实路径。 */
139
+ // @意图#R640(2026-10-27): 每个 bot 选的目录都走这套检查
140
+ // @意图#R639(2026-10-27): 配对时的 --dir 和网页上选的目录走同一套检查
141
+ export async function checkDir(dir) {
142
+ const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`授权目录不存在:${dir}`); });
143
+ if (!statSync(real).isDirectory()) throw new Error(`授权目录不是目录:${dir}`);
144
+ if (real === path.parse(real).root) throw new Error("不能把整个磁盘根目录授权出去");
145
+ return real;
146
+ }
147
+
148
+ /** 配对:用一次性配对码换长期令牌,存进本地配置。不选目录(连上后在每个 bot 的私聊里选);带了 dir 不报错、不用,返回 dirIgnored。 */
149
+ // @意图#R640(2026-10-27): 去掉 --dir:带了只提示「目录请连上后在每个 bot 的私聊里选」,照常配对;配置里 bot_dirs 是空表、不再有 dir
150
+ // @意图#R639(2026-10-27已废弃): 配对不弹窗、不要求目录(king 定乙);--dir 还能用,给了就照旧检查;没给 config.dir 为空(R640 去掉 --dir)
151
+ // @意图#R637(2026-10-27已废弃): Mac 上没写 --dir 就弹窗选目录、取消不请求服务器(R639 改成连上后在网页上选)
152
+ // @意图#R636(2026-10-27): 配对码一次性、5 分钟有效(服务器判断);授权目录先换成真实路径再存
153
+ // @意图#R666(2026-10-29): 配对码可以写成「码@服务器」(code 里带 @ 时拆开,见 parsePairTarget;显式给了 server 就用 server);15 秒超时;
154
+ // 连不上、服务器回错都翻成大白话(explainNetError / explainPairFailure);缺码的提示不再写 runner.mjs
155
+ export async function pair({ code, dir, server, name = os.hostname(), timeoutMs = PAIR_TIMEOUT_MS }, file = configPath()) {
156
+ checkNode();
157
+ const t = parsePairTarget(code, server);
158
+ if (!t.code) throw new Error(`缺配对码:${progName()} pair <配对码>@<服务器地址>(请从网页上原样复制整条命令)`);
159
+ let r;
160
+ try {
161
+ r = await fetch(`${t.server}/devices/pair`, {
162
+ method: "POST", headers: { "content-type": "application/json" },
163
+ body: JSON.stringify({ code: t.code, name, os: `${process.platform} ${os.release()}` }),
164
+ signal: AbortSignal.timeout(timeoutMs),
165
+ });
166
+ } catch (e) { throw new Error(explainNetError(e, t.server, timeoutMs)); }
167
+ const text = await r.text().catch(() => "");
168
+ let body = null;
169
+ try { body = JSON.parse(text); } catch { body = null; }
170
+ if (r.status !== 201 || !body || typeof body.token !== "string") throw new Error(explainPairFailure(r.status, body));
171
+ saveConfig({ server: t.server, device_id: body.device_id, token: body.token, name, bot_dirs: {} }, file);
172
+ return { deviceId: body.device_id, file, server: t.server, dirIgnored: dir !== undefined && dir !== null && dir !== "" };
173
+ }
174
+
175
+ const isLoopback = (h) => h === "localhost" || /^127\./.test(h) || h === "[::1]" || h === "::1";
176
+ /**
177
+ * 「码@服务器」拆成 { code, server }(R666):@ 后面没写 http:// / https:// 时默认 https,localhost、127.x、[::1] 用 http;可以带端口、路径;
178
+ * 去掉末尾的 /。显式给了 server(--server)就用它;没 @ 也没 --server 用 DEFAULT_SERVER(老行为)。地址看不懂、带 ? # 或账号密码就报错。
179
+ */
180
+ // @意图#R666(2026-10-29): 配对码里带服务器信息(king 定一甲:码@域名),命令里不用再写 --server;老写法 pair 码 --server 地址照样能用
181
+ export function parsePairTarget(raw, server) {
182
+ const s = String(raw ?? "").trim();
183
+ const at = s.indexOf("@");
184
+ const code = (at < 0 ? s : s.slice(0, at)).trim();
185
+ const where = at < 0 ? "" : s.slice(at + 1).trim();
186
+ if (server) return { code, server: String(server).trim().replace(/\/+$/, "") };
187
+ if (!where) return { code, server: DEFAULT_SERVER };
188
+ const explicit = /^https?:\/\//i.test(where);
189
+ let u;
190
+ try { u = new URL(explicit ? where : `https://${where}`); }
191
+ catch { throw new Error(`配对码后面的服务器地址看不懂:${where}。请从网页上原样复制整条命令。`); }
192
+ if (u.search || u.hash || u.username || u.password) throw new Error(`服务器地址不能带 ? # 或账号密码:${where}。请从网页上原样复制整条命令。`);
193
+ if (!explicit && isLoopback(u.hostname)) u.protocol = "http:";
194
+ return { code, server: `${u.origin}${u.pathname}`.replace(/\/+$/, "") };
195
+ }
196
+
197
+ const hostOf = (server) => { try { return new URL(server).host; } catch { return server; } };
198
+ /** fetch 失败(连不上)翻成大白话:找不到、没开、证书、超时、被断开、其他 */
199
+ // @意图#R666(2026-10-29): 原来只打一句英文 fetch failed;按 cause.code 分开说(king 定五)
200
+ export function explainNetError(e, server, timeoutMs = PAIR_TIMEOUT_MS) {
201
+ const code = String(e?.cause?.code ?? e?.cause?.cause?.code ?? e?.code ?? "");
202
+ const host = hostOf(server);
203
+ if (e?.name === "TimeoutError" || e?.name === "AbortError" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ETIMEDOUT") {
204
+ return `连服务器超时(${Math.round(timeoutMs / 1000)} 秒没回应):${host}。检查这台电脑能不能上网,或者稍后再试。`;
205
+ }
206
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") return `找不到服务器 ${host}:命令里 @ 后面的地址可能写错了,或者这台电脑没联网。`;
207
+ if (code === "ECONNREFUSED") return `连不上服务器 ${server}:服务器没开,或者地址、端口不对。`;
208
+ if (/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS|SSL/.test(code)) return `服务器 ${host} 的 https 证书有问题(${code}):可能证书过期了,或者网络在拦截。请告诉服务器的管理员。`;
209
+ if (code === "ECONNRESET" || code === "EPIPE" || code === "UND_ERR_SOCKET") return `和服务器 ${host} 的连接被断开了:稍后再试;一直这样请检查代理或网络。`;
210
+ return `连不上服务器 ${server}(${code || e?.message || "未知原因"}):检查网络和命令里的地址。`;
211
+ }
212
+
213
+ /** R666:服务器回的错误码(/devices/pair 返回的 code)对应的大白话 */
214
+ export const PAIR_ERR = {
215
+ bad_code: "配对码不对:请从网页上原样复制整条命令再运行(码只能用一次,5 分钟有效)。",
216
+ used: "这个配对码已经用过了:回网页点「换一个码」,再复制新命令运行。",
217
+ replaced: "这个配对码已经作废了(网页上换过新码):请复制网页上最新的那条命令运行。",
218
+ expired: "配对码过期了(5 分钟有效):回网页点「换一个码」,再复制新命令运行。",
219
+ device_exists: "这个账号已经连着一台电脑了:先在网页「我的电脑」抽屉最下面「连着的电脑」里点「断开」,再换一个码重新配对。",
220
+ bad_input: "命令不完整:请从网页上原样复制整条命令再运行。",
221
+ };
222
+ const NOT_HUB = (status) => `这个地址不是 agent-hub 服务器(回了 ${status}):检查命令里 @ 后面的地址,请从网页上原样复制整条命令。`;
223
+ /** 配对请求回的不是 201:先看服务器给的 code,老服务器没 code 就按状态码猜;回的不是 JSON 多半不是 agent-hub */
224
+ // @意图#R666(2026-10-29): 400/410/409/404/405/非 JSON/5xx 各给一句(king 定五)
225
+ export function explainPairFailure(status, body) {
226
+ if (body && typeof body === "object" && PAIR_ERR[body.code]) return `配对失败:${PAIR_ERR[body.code]}`;
227
+ if (status === 404 || status === 405) return `配对失败:${NOT_HUB(status)}`;
228
+ if (status >= 500) return `配对失败:服务器出错了(${status}),稍后再试。`;
229
+ if (!body || typeof body !== "object") return `配对失败:${NOT_HUB(status)}`;
230
+ if (status === 410) return `配对失败:${PAIR_ERR.expired}`;
231
+ if (status === 409) return `配对失败:${PAIR_ERR.device_exists}`;
232
+ if (status === 400) return `配对失败:${PAIR_ERR.bad_code}`;
233
+ return `配对失败(${status}):${body.error ?? "未知原因"}`;
234
+ }
235
+
236
+ /** 整个命令出错时打给人看的话:权限不够(多半用 sudo 跑过)单独说怎么修,别的原样 */
237
+ // @意图#R666(2026-10-29): EACCES / EPERM 提示别用 sudo + chown(king 定五)
238
+ export function explainError(e) {
239
+ const code = e?.code;
240
+ if (code === "EACCES" || code === "EPERM") {
241
+ const p = e.path ? String(e.path) : runnerHome();
242
+ const dir = p.startsWith(runnerHome()) ? runnerHome() : path.dirname(p);
243
+ return `权限不够,写不了 ${p}:别用 sudo 运行这个命令。如果以前用 sudo 跑过,先运行 sudo chown -R "$(whoami)" "${dir}",再重新运行。`;
244
+ }
245
+ return e?.message ?? String(e);
246
+ }
247
+
248
+ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
249
+
250
+ /**
251
+ * 一条连接上的工作:执行工具调用、网页叫弹窗选目录。每个 bot 一个授权目录(对照表),选完新目录,这个 bot 之后的调用只认新目录;已经在跑的不打断。
252
+ * 返回 { onMessage(msg, send), killAll(), botDirs },startRunner 用它,测试也直接用它(不用起 WebSocket)。
253
+ */
254
+ // @意图#R640(2026-10-27): 目录按 bot 分:call 按 bot_id 查对照表,没有就拒绝 NO_DIR;pick_dir 带 bot_id,选完检查后记进对照表、写回配置(600),
255
+ // 回 dir_result {id, ok, bot_id, dir};老配置的单一 dir 忽略
256
+ // @意图#R639(2026-10-27已废弃): 目录不再是连接时固定的常量;没目录时 call 一律拒绝;pick_dir 选完检查、写回配置(600)、回 dir_result(R640 改成按 bot 分)
257
+ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform = process.platform, file = configPath() } = {}) {
258
+ let dirs = botDirsOf(cfg); // bot_id → 授权目录
259
+ const jobs = new Map(); // 调用 id → AbortController
260
+ const picks = new Map(); // 弹窗 id → AbortController
261
+
262
+ async function handleCall(send, msg) {
263
+ const { id, op, args } = msg;
264
+ const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
265
+ const dir = bot ? dirs[bot] : undefined; // 这次调用用开始时这个 bot 的目录;中途换目录不影响它
266
+ if (!dir) { send({ type: "result", id, ok: false, error: NO_DIR }); return; }
267
+ const ac = new AbortController();
268
+ jobs.set(id, ac);
269
+ log(`[${bot}] ${op === "bash" ? `bash:${args?.command}` : `${op}:${args?.path}`}`);
270
+ try {
271
+ const res = await runOp(op, args, dir, { signal: ac.signal, onData: (d) => send({ type: "output", id, data: Buffer.from(d).toString("base64") }) });
272
+ if (ac.signal.aborted) send({ type: "result", id, ok: false, error: "已中止" });
273
+ else send({ type: "result", id, ok: true, ...res });
274
+ } catch (e) {
275
+ send({ type: "result", id, ok: false, error: e?.message ?? String(e) });
276
+ } finally {
277
+ jobs.delete(id);
278
+ }
279
+ }
280
+
281
+ async function handlePick(send, msg) {
282
+ const { id } = msg;
283
+ const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
284
+ if (!bot) { send({ type: "dir_result", id, ok: false, error: "缺 bot_id" }); return; }
285
+ if (platform !== "darwin") { send({ type: "dir_result", id, ok: false, bot_id: bot, error: PICK_ONLY_MAC }); return; }
286
+ const ac = new AbortController();
287
+ picks.set(id, ac);
288
+ log(`网页上给 bot ${bot} 点了选择目录:请在弹出的窗口里选文件夹`);
289
+ try {
290
+ const picked = await pickDir({ signal: ac.signal });
291
+ if (ac.signal.aborted) return; // 服务器那边已经按超时处理了
292
+ if (!picked) { log("没换目录(点了取消)"); send({ type: "dir_result", id, ok: false, bot_id: bot, cancelled: true }); return; }
293
+ const real = await checkDir(picked);
294
+ dirs = { ...dirs, [bot]: real };
295
+ cfg.bot_dirs = { ...dirs };
296
+ delete cfg.dir; // 老格式的单一 dir 不再写回
297
+ saveConfig(cfg, file);
298
+ log(`bot ${bot} 的授权目录换成 ${real}`);
299
+ send({ type: "dir_result", id, ok: true, bot_id: bot, dir: real });
300
+ } catch (e) {
301
+ if (ac.signal.aborted) { log("选目录超时,窗口已关掉"); return; }
302
+ send({ type: "dir_result", id, ok: false, bot_id: bot, error: e?.message ?? String(e) });
303
+ } finally {
304
+ picks.delete(id);
305
+ }
306
+ }
307
+
308
+ return {
309
+ get botDirs() { return { ...dirs }; },
310
+ onMessage(msg, send) {
311
+ if (msg.type === "call") return handleCall(send, msg);
312
+ if (msg.type === "pick_dir") return handlePick(send, msg);
313
+ if (msg.type === "abort") { jobs.get(msg.id)?.abort(); picks.get(msg.id)?.abort(); }
314
+ return undefined;
315
+ },
316
+ killAll() {
317
+ for (const ac of jobs.values()) ac.abort();
318
+ for (const ac of picks.values()) ac.abort();
319
+ jobs.clear();
320
+ picks.clear();
321
+ },
322
+ };
323
+ }
324
+
325
+ /**
326
+ * 连服务器并执行转发来的工具调用。断线会自动重连(不重放旧调用);令牌不对或网页上断开了就停。
327
+ * 返回 { stop, done }:stop() 立刻断开并杀掉正在跑的命令。
328
+ */
329
+ // @意图#R666(2026-10-29): hello 多报 runner_version(网页提示有新版本)和 autostart(是不是开机自启起的,网页据此给升级命令);
330
+ // 服务器用 4409 顶掉这条(同一份配置又开了一个):不再重连、结束("replaced");连不上时只在第一次和之后每 30 次打一行,开机自启没网时日志不刷屏;
331
+ // 连上那句不再说「右栏」,开机自启时不说「按 Ctrl-C」
332
+ // @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
333
+ // @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
334
+ // @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
335
+ export function startRunner(cfg, { log = say, retryMs = 2000, pickDir = pickDirMac, platform = process.platform, file = configPath(), background = process.env.AGENT_RUNNER_LAUNCHD === "1" } = {}) {
336
+ checkNode();
337
+ let fails = 0;
338
+ const session = createSession(cfg, { log, pickDir, platform, file });
339
+ let ws = null;
340
+ let stopped = false;
341
+ let beat = null;
342
+ let resolveDone;
343
+ const done = new Promise((r) => { resolveDone = r; });
344
+ const finish = (why) => { if (!stopped) { stopped = true; } resolveDone(why); };
345
+ const send = (sock, msg) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(msg)); };
346
+
347
+ function connect() {
348
+ if (stopped) return;
349
+ const sock = new WebSocket(wsUrlOf(cfg.server));
350
+ ws = sock;
351
+ sock.addEventListener("open", () => {
352
+ send(sock, { type: "hello", version: PROTOCOL_VERSION, device_id: cfg.device_id, token: cfg.token, bot_dirs: session.botDirs,
353
+ name: cfg.name ?? os.hostname(), os: `${process.platform} ${os.release()}`, runner_version: RUNNER_VERSION, autostart: background });
354
+ beat = setInterval(() => send(sock, { type: "ping" }), HEARTBEAT_MS);
355
+ });
356
+ sock.addEventListener("message", (ev) => {
357
+ let msg;
358
+ try { msg = JSON.parse(typeof ev.data === "string" ? ev.data : Buffer.from(ev.data).toString("utf8")); } catch { return; }
359
+ if (msg.type === "ready") {
360
+ fails = 0;
361
+ const n = Object.keys(session.botDirs).length;
362
+ const tail = background ? "(开机自启,在后台运行)" : "按 Ctrl-C 断开。";
363
+ log(n ? `已连上服务器 ${cfg.server},${n} 个 bot 选了目录。${tail}`
364
+ : `已连上服务器 ${cfg.server},还没有 bot 选目录:请在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」。${tail}`);
365
+ } else if (msg.type === "revoked") { session.killAll(); log("网页上已断开这台电脑,令牌已作废。要再连,请在网页上重新拿配对码配对。"); }
366
+ else void session.onMessage(msg, (m) => send(sock, m));
367
+ });
368
+ sock.addEventListener("close", (ev) => {
369
+ clearInterval(beat);
370
+ session.killAll(); // 断线:正在跑的命令杀掉、弹窗关掉,服务器那边按「设备离线」返回,重连后不重放
371
+ if (ws === sock) ws = null;
372
+ if (stopped) return;
373
+ if (ev.code === CLOSE_BAD_TOKEN) { log("服务器不认这个令牌(可能已经在网页上断开过)。请重新配对。"); finish("bad_token"); return; }
374
+ if (ev.code === CLOSE_REVOKED) { log("这台电脑已在网页上断开。请重新配对。"); finish("revoked"); return; }
375
+ if (ev.code === CLOSE_REPLACED) { log("同一份配置在别处又开了一个运行器,服务器把这个顶掉了:这个先退出(不再重连)。"); finish("replaced"); return; }
376
+ fails += 1;
377
+ if (fails === 1 || fails % 30 === 0) log(`和服务器的连接断了,${retryMs / 1000} 秒后重连${fails > 1 ? `(已经连不上 ${fails} 次,会一直重试)` : ""}`);
378
+ setTimeout(connect, retryMs);
379
+ });
380
+ sock.addEventListener("error", () => { /* close 里处理 */ });
381
+ }
382
+ connect();
383
+ const stop = () => {
384
+ if (stopped) return;
385
+ stopped = true;
386
+ session.killAll();
387
+ clearInterval(beat);
388
+ try { ws?.close(1000, "runner stopped"); } catch { /* ignore */ }
389
+ resolveDone("stopped");
390
+ };
391
+ return { stop, done };
392
+ }
393
+
394
+ /** R666:同一份配置只能跑一个:配置旁边的 <配置>.lock 记着进程号 */
395
+ export const lockPath = (file = configPath()) => `${file}.lock`;
396
+ export function readLock(file = configPath()) {
397
+ try { const j = JSON.parse(readFileSync(lockPath(file), "utf8")); return Number.isInteger(j?.pid) ? j : null; } catch { return null; }
398
+ }
399
+ export const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; } };
400
+ /**
401
+ * 拿锁:别的活着的进程拿着就返回 { ok: false, pid, launchd };否则写上自己(600)返回 { ok: true, release }。
402
+ * 进程死了留下的旧锁直接覆盖。
403
+ */
404
+ // @意图#R666(2026-10-29): 防互踢(king 定必须做):开机自启的在后台跑着,再在终端里 start 就提示「已经在后台连着」退出,不再两个互相顶
405
+ export function takeLock(file = configPath(), { pid = process.pid, launchd = process.env.AGENT_RUNNER_LAUNCHD === "1" } = {}) {
406
+ const cur = readLock(file);
407
+ if (cur && cur.pid !== pid && pidAlive(cur.pid)) return { ok: false, pid: cur.pid, launchd: !!cur.launchd };
408
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
409
+ writeFileSync(lockPath(file), `${JSON.stringify({ pid, launchd, started_at: new Date().toISOString() })}\n`, { mode: 0o600 });
410
+ return { ok: true, release: () => { if (readLock(file)?.pid === pid) rmSync(lockPath(file), { force: true }); } };
411
+ }
412
+
413
+ /** 被网页断开 / 令牌失效后把这份配置的开机自启关掉(不然每次登录都白起一次);不是 Mac、没开都不要紧 */
414
+ async function autostartOffQuietly(file, deps) {
415
+ if ((deps.platform ?? process.platform) !== "darwin") return false;
416
+ try { return (await autostartOff({ configFile: file, launchctl: deps.launchctl })).wasOn; } catch { return false; }
417
+ }
418
+
419
+ // @意图#R666(2026-10-29): 先拿锁(后台已在跑就提示退出 0);开机自启时启动前日志超 5MB 清空、配置坏了/没配对也退出 0(不让 launchd 反复拉起);
420
+ // 结束一律退出码 0(Ctrl-C、被网页断开、令牌失效、被顶掉),被断开 / 令牌失效时顺手关掉自启
421
+ // @意图#R663(2026-10-29): 前台跑 start,Ctrl-C 断开(pair --start 和 start 共用)
422
+ async function runStart(prog = progName(), deps = {}) {
423
+ const file = configPath();
424
+ const background = process.env.AGENT_RUNNER_LAUNCHD === "1";
425
+ if (background && truncateLogIfBig(logFileFor(file))) say("日志超过 5MB,已清空");
426
+ let cfg;
427
+ try { cfg = loadConfig(file); } catch (e) {
428
+ if (background) { console.error(explainError(e)); process.exit(0); }
429
+ throw e;
430
+ }
431
+ const lock = takeLock(file);
432
+ if (!lock.ok) {
433
+ console.log(`已经在后台连着,不用再开(进程 ${lock.pid}${lock.launchd ? ",开机自启起的" : ""})。要停掉它:${prog} stop`);
434
+ process.exit(0);
435
+ }
436
+ const r = startRunner(cfg, { file, background });
437
+ const quit = () => { r.stop(); lock.release(); setTimeout(() => process.exit(0), 100); };
438
+ process.on("SIGINT", quit);
439
+ process.on("SIGTERM", quit);
440
+ const why = await r.done;
441
+ lock.release();
442
+ if (why === "bad_token" || why === "revoked") {
443
+ if (await autostartOffQuietly(file, deps)) say("已关掉开机自启(这台电脑在网页上断开了)。");
444
+ }
445
+ process.exit(0);
446
+ }
447
+
448
+ /** R666:配完要不要开机自启:flag(--autostart / --no-autostart)优先;不是 Mac 不开;是终端就问;不是终端(脚本、管道)不问、只提示 */
449
+ export function decideAutostart({ flag, platform = process.platform, tty = !!(process.stdin.isTTY && process.stdout.isTTY) }) {
450
+ if (flag === true) return platform === "darwin" ? "on" : "unsupported";
451
+ if (flag === false) return "off";
452
+ if (platform !== "darwin") return "off";
453
+ return tty ? "ask" : "hint";
454
+ }
455
+
456
+ /** 终端里问一句 是/否;直接回车 = dflt */
457
+ export async function askYesNo(question, dflt = true, { input = process.stdin, output = process.stdout } = {}) {
458
+ const rl = readline.createInterface({ input, output });
459
+ try {
460
+ const a = (await rl.question(question)).trim().toLowerCase();
461
+ if (!a) return dflt;
462
+ return ["y", "yes", "是", "好", "要"].includes(a);
463
+ } finally { rl.close(); }
464
+ }
465
+
466
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
467
+ /** 停掉拿着锁的那个运行器(不是自己);等它真退出(最多 5 秒)。返回被停的锁信息,没有在跑的返回 null */
468
+ async function stopHolder(file) {
469
+ const l = readLock(file);
470
+ if (!l || l.pid === process.pid || !pidAlive(l.pid)) return null;
471
+ try { process.kill(l.pid, "SIGTERM"); } catch { /* 已经没了 */ }
472
+ for (let i = 0; i < 50 && pidAlive(l.pid); i++) await sleep(100);
473
+ return l;
474
+ }
475
+
476
+ const here = () => path.dirname(fileURLToPath(import.meta.url));
477
+ // @意图#R666(2026-10-29): 开启自启:前台有一个在跑就先停掉它(不然后台那个起来就因为锁退出了),再交给 autostart.mjs(复制自己、写 plist、加载)
478
+ async function turnAutostartOn(file, prog, deps = {}) {
479
+ const held = readLock(file);
480
+ if (held && !held.launchd) await stopHolder(file);
481
+ const r = await autostartOn({ configFile: file, version: RUNNER_VERSION, srcDir: here(), launchctl: deps.launchctl, platform: deps.platform });
482
+ console.log(`已设开机自启:以后登录这台 Mac 会自动连上,不用开终端。现在已经在后台连着,可以关掉这个终端窗口了。\n日志:${r.log}\n关掉开机自启:${prog} autostart off`);
483
+ return r;
484
+ }
485
+
486
+ const usage = (prog) => `用法:
487
+ ${prog} pair <配对码>@<服务器地址> [--no-start] [--autostart | --no-autostart]
488
+ (网页上「我的电脑」里复制的就是这一条;配完直接连上,Mac 上会问要不要开机自动连上;--no-start 只配对不连)
489
+ (老写法 ${prog} pair <配对码> --server <地址> 也能用)
490
+ ${prog} start 连上服务器(终端窗口别关;Ctrl-C 断开)
491
+ ${prog} stop 停掉在跑的运行器
492
+ ${prog} status 看配置、各 bot 的目录、在不在跑、开机自启
493
+ ${prog} autostart on|off|status Mac 开机自启(on 也用来升级到新版本)
494
+ ${prog} uninstall [--purge] 关掉自启、删掉本地副本和日志(--purge 连配置一起删)
495
+ ${prog} --version
496
+ 目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac 上会弹窗让你选。`;
497
+
498
+ // @意图#R666(2026-10-29): pair 默认配完直接 start(--no-start 只配对);配完按 decideAutostart 问 / 提示 / 开启开机自启,开了就不在前台跑;
499
+ // 新命令 stop、autostart on|off|status、uninstall、--version;status 多显示在不在跑、版本、开机自启;用法里的命令按运行方式写(npx 时写 npx 写法)
500
+ // @意图#R664(2026-10-29): 导出给 bin/agent-hub-runner.mjs 调;用法和「现在运行:… start」里的命令名用 progName(装好的 agent-hub-runner,仓库里 node 写法)
501
+ // @意图#R663(2026-10-29): pair --start 配对成功后直接进 start(二甲);提示里「我的电脑」改成在私聊输入框下面那一行点开(R663 挪了位置);不带 --start 照旧提示再运行 start(R666 起默认就 start,--no-start 才提示)
502
+ // @意图#R640(2026-10-27): pair 带 --dir 只提示不报错;status 列出每个 bot 的目录;用法文字去掉 --dir
503
+ export async function main(argv, prog = progName(), deps = {}) {
504
+ checkNode();
505
+ const a = parseArgs(argv);
506
+ const platform = deps.platform ?? process.platform;
507
+ if (a.cmd === "pair") {
508
+ const r = await pair({ code: a.positional[0], dir: a.dir, server: a.server, name: a.name ?? os.hostname() });
509
+ if (r.dirIgnored) console.log(PAIR_DIR_IGNORED);
510
+ const where = "目录:连上后在网页上每个 bot 的私聊里,点输入框下面「我的电脑」那一行选。";
511
+ console.log(`配对成功(服务器 ${r.server})。${where}\n配置存在 ${r.file}(权限 600)。`);
512
+ const how = decideAutostart({ flag: a.autostart, platform, tty: deps.tty });
513
+ let on = how === "on";
514
+ if (how === "ask") on = await (deps.ask ?? askYesNo)("开机自动连上?以后不用开终端 (Y/n) ", true);
515
+ if (how === "unsupported") console.log(ONLY_MAC + ",这次不设。");
516
+ if (on) {
517
+ try { await turnAutostartOn(r.file, prog, deps); return; }
518
+ catch (e) { console.log(`开机自启没设成:${explainError(e)}\n这次先在前台连着(终端窗口别关)。`); }
519
+ }
520
+ if (how === "ask") console.log(`好的,不设开机自启。以后想开:${prog} autostart on`);
521
+ if (how === "hint") console.log(`想开机自动连上(不用开终端):运行 ${prog} autostart on`);
522
+ if (a.start === false) { console.log(`现在运行:${prog} start`); return; }
523
+ console.log("接着连服务器……");
524
+ await runStart(prog, deps);
525
+ return;
526
+ }
527
+ if (a.cmd === "start") { await runStart(prog, deps); return; }
528
+ if (a.cmd === "stop") {
529
+ const l = await stopHolder(configPath());
530
+ if (!l) { console.log("没有在跑的运行器。"); return; }
531
+ console.log(`已停掉(进程 ${l.pid})。${l.launchd ? `开机自启还开着,下次登录会自己连上;不想要:${prog} autostart off` : ""}`);
532
+ return;
533
+ }
534
+ if (a.cmd === "autostart") {
535
+ const sub = a.positional[0] ?? "status";
536
+ const file = configPath();
537
+ if (sub === "on") { loadConfig(file); await turnAutostartOn(file, prog, deps); return; }
538
+ if (platform !== "darwin") { console.log(ONLY_MAC); return; }
539
+ if (sub === "off") {
540
+ const r = await autostartOff({ configFile: file, launchctl: deps.launchctl });
541
+ console.log(r.wasOn ? "已关掉开机自启(后台那个也停了)。" : "本来就没开开机自启。");
542
+ return;
543
+ }
544
+ if (sub === "status") {
545
+ const st = await autostartStatus({ configFile: file, launchctl: deps.launchctl, platform });
546
+ console.log(st.installed ? `开机自启:开着(${st.loaded ? (st.pid ? `后台在跑,进程 ${st.pid}` : "已加载,现在没在跑") : "还没加载"})\nplist:${st.plist}\n日志:${st.log}` : "开机自启:没开");
547
+ for (const p of st.problems) console.log(`注意:${p}`);
548
+ return;
549
+ }
550
+ throw new Error(`autostart 后面只能是 on、off 或 status(${sub} 不认识)`);
551
+ }
552
+ if (a.cmd === "uninstall") {
553
+ const file = configPath();
554
+ if (platform === "darwin") await autostartOff({ configFile: file, launchctl: deps.launchctl }).catch(() => {});
555
+ await stopHolder(file);
556
+ const home = runnerHome();
557
+ if (!otherAutostarts(file).length) {
558
+ rmSync(path.join(home, "bin"), { recursive: true, force: true });
559
+ rmSync(path.join(home, "logs"), { recursive: true, force: true });
560
+ }
561
+ let purge = !!a.purge;
562
+ if (!purge && existsSync(file) && (deps.tty ?? !!(process.stdin.isTTY && process.stdout.isTTY))) {
563
+ purge = await (deps.ask ?? askYesNo)("要连配对信息(令牌)一起删掉吗?删了以后要重新配对 (y/N) ", false);
564
+ }
565
+ if (purge) { rmSync(file, { force: true }); rmSync(lockPath(file), { force: true }); }
566
+ console.log(`已卸载:开机自启关了、本地副本和日志删了。${purge ? `配置也删了(${file});记得在网页「我的电脑」里点「断开」作废令牌。` : `配置留着(${file});连配置一起删:${prog} uninstall --purge`}`);
567
+ return;
568
+ }
569
+ if (a.cmd === "status") {
570
+ const file = configPath();
571
+ const cfg = loadConfig(file);
572
+ const d = Object.entries(botDirsOf(cfg));
573
+ const l = readLock(file);
574
+ const run = l && pidAlive(l.pid) ? `在跑(进程 ${l.pid}${l.launchd ? ",开机自启" : ""})` : "没在跑";
575
+ let auto = "";
576
+ if (platform === "darwin") {
577
+ const st = await autostartStatus({ configFile: file, launchctl: deps.launchctl, platform }).catch(() => null);
578
+ auto = `\n开机自启:${st?.installed ? "开着" : "没开"}${st?.problems?.length ? `(${st.problems.join(";")})` : ""}`;
579
+ }
580
+ 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 的私聊里点「选择目录」)"}`);
581
+ return;
582
+ }
583
+ if (a.cmd === "version") { console.log(RUNNER_VERSION); return; }
584
+ console.log(usage(prog));
585
+ process.exitCode = a.cmd && a.cmd !== "help" ? 1 : 0;
586
+ }
587
+
588
+ const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
589
+
590
+ /** 这个文件是不是被直接运行的(经符号链接运行也算:两边都换成真实路径再比)。 */
591
+ // @意图#R664(2026-10-29): npm 装好的命令是指向源码的符号链接,原来按字面路径比较会判成「不是直接运行」、什么都不做;改成真实路径比较
592
+ export function isMainModule(argv1 = process.argv[1], metaUrl = import.meta.url) {
593
+ return !!argv1 && real(argv1) === real(fileURLToPath(metaUrl));
594
+ }
595
+
596
+ /**
597
+ * 用法和提示里怎么称呼自己:npm 装好的(经 bin 里的 agent-hub-runner 链接运行,或文件在 node_modules 里)说 agent-hub-runner,
598
+ * 仓库里直接跑说 node <相对路径>。
599
+ */
600
+ // @意图#R666(2026-10-29): npx 跑的(npm 设 npm_lifecycle_event=npx,或文件在 npx 缓存 _npx 目录里)和开机自启的本地副本(~/.agent-runner/bin 下)都写 npx 写法:
601
+ // 这两种情况电脑上都没有 agent-hub-runner 这个命令
602
+ // @意图#R664(2026-10-29): 用法和配对成功提示按运行方式区分:装好的显示 agent-hub-runner …,仓库里显示 node 写法
603
+ export function progName(entry = process.argv[1], cwd = process.cwd(), env = process.env) {
604
+ if (!entry) return PKG_BIN;
605
+ if (isNpx(entry, env)) return NPX_CMD;
606
+ const r = real(entry);
607
+ const bin = real(path.join(runnerHome(env), "bin"));
608
+ if (r === bin || r.startsWith(`${bin}${path.sep}`)) return NPX_CMD;
609
+ if (path.basename(entry) === PKG_BIN) return PKG_BIN;
610
+ if (/[\\/]node_modules[\\/]/.test(r)) return PKG_BIN;
611
+ return `node ${path.relative(cwd, r) || path.basename(r)}`;
612
+ }
613
+ /** 是不是 npx 跑起来的 */
614
+ export function isNpx(entry = process.argv[1], env = process.env) {
615
+ if (env.npm_lifecycle_event === "npx") return true;
616
+ return !!entry && /[\\/]_npx[\\/]/.test(real(entry));
617
+ }
618
+
619
+ // @意图#R666(2026-10-29): 出错时打大白话(权限不够单独说)
620
+ if (isMainModule()) {
621
+ main(process.argv.slice(2)).catch((e) => { console.error(explainError(e)); process.exit(1); });
622
+ }