agent-hub-runner 0.1.0 → 0.1.2

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,65 +1,84 @@
1
+ <!-- @意图#R690(2026-11-01): 加「改动记录」一节:存在哪、打码、64 KB、30 天、怎么删 -->
2
+ <!-- @意图#R666(2026-10-29): 改成不用全局安装的 npx 一条命令(码@地址)、Mac 开机自启、stop / uninstall、常见报错 -->
1
3
  <!-- @意图#R664(2026-10-29): npm 包 agent-hub-runner 的说明(随包发布):给客户看的安装、配对、再连、查看、断开、卸载和安全提示 -->
2
4
  # agent-hub-runner
3
5
 
4
6
  agent-hub 的电脑运行器。装在你自己的电脑上,配对之后,你的 bot 就能在你给它选的文件夹里读、写、改文件,跑命令(跑命令每次都要你在网页上点批准)。
5
7
 
6
- - 没有第三方依赖,只用 Node 自带的功能。
7
- - 在终端前台运行,按 Ctrl-C 立刻断开;不会开机自启。
8
+ - 没有第三方依赖,只用 Node 自带的功能。不用先装:用 `npx` 直接运行。
9
+ - Mac 上可以设成开机自动连上(在后台跑,不用开终端);不设的话在终端前台跑,按 Ctrl-C 断开。
8
10
 
9
11
  ## 需要
10
12
 
11
- - **Node 22 或更新**(`node -v` 看版本)。
12
- - **macOS**:给 bot 选目录时会在 Mac 上弹出系统的「选文件夹」窗口。别的系统目前弹不出这个窗口,选不了目录。
13
+ - **Node 22 或更新**(终端里运行 `node -v` 看版本)。还没装:去 [nodejs.org](https://nodejs.org) 下载 LTS 版(Mac 选 .pkg 安装包),或者 `brew install node`。
14
+ - **macOS**:给 bot 选目录时会在 Mac 上弹出系统的「选文件夹」窗口;开机自启也只支持 Mac。别的系统能连上,但选不了目录。
13
15
  - bot 跑命令时用 `bash`(macOS、Linux 自带)。
14
16
 
15
- ## 安装
16
-
17
- ```bash
18
- npm i -g agent-hub-runner
19
- ```
20
-
21
- 装过的跳过这一步。
22
-
23
17
  ## 第一次连接
24
18
 
25
- 1. 在网页上打开某个 bot 的私聊,点输入框下面「我的电脑」那一行,右侧会打开抽屉,里面有配对码和一条命令。
19
+ 1. 在网页上打开某个 bot 的私聊,点输入框下面「我的电脑」那一行,右侧会打开抽屉,里面有一条命令。
26
20
  2. 复制那条命令到终端运行,形如:
27
21
 
28
22
  ```bash
29
- agent-hub-runner pair <配对码> --start --server https://你的服务器地址
23
+ npx -y agent-hub-runner@latest pair <配对码>@你的服务器地址
30
24
  ```
31
25
 
32
- 配对码 5 分钟有效、只能用一次;过期了在抽屉里点「换一个码」。`--start` 表示配对成功后直接连上。
33
- 3. 抽屉里第 2 步会自动打勾。第 3 步点「选择目录…」,在 Mac 弹出的窗口里选这个 bot 能用的文件夹。每个 bot 各选各的。
26
+ 配对码 5 分钟有效、只能用一次;过期了在抽屉里点「换一个码」。配对成功后直接连上。
27
+ 3. Mac 上会问「开机自动连上?(Y/n)」:回车就是「是」,以后开机自己连上,终端可以关;选「否」的话终端窗口别关。
28
+ 4. 抽屉里第 2 步会自动打勾。第 3 步点「选择目录…」,在 Mac 弹出的窗口里选这个 bot 能用的文件夹。每个 bot 各选各的。
34
29
 
35
- 终端窗口别关:关了 bot 就用不了你的电脑。
30
+ `@` 后面的地址默认是 https;`localhost`、`127.x` 默认 http;也可以写全 `码@http://…`。老写法 `pair <配对码> --server <地址>` 照样能用。只配对不连:加 `--no-start`。
36
31
 
37
32
  ## 以后再连
38
33
 
39
- 重启电脑或关了终端之后,运行:
34
+ 开了开机自启的不用管。没开的,重启电脑或关了终端之后运行:
40
35
 
41
36
  ```bash
42
- agent-hub-runner start
37
+ npx -y agent-hub-runner@latest start
43
38
  ```
44
39
 
45
- 服务器地址和令牌配对时已经存下了,不用再给。
40
+ 服务器地址和令牌配对时已经存下了,不用再给。后台已经连着时再 `start` 会说「已经在后台连着,不用再开」。
46
41
 
47
42
  ## 其他命令
48
43
 
49
44
  ```bash
50
- agent-hub-runner status # 看连的是哪个服务器、每个 bot 选了哪个目录
51
- agent-hub-runner # 看用法
45
+ npx -y agent-hub-runner@latest status # 版本、连的哪个服务器、在不在跑、开机自启、每个 bot 的目录
46
+ npx -y agent-hub-runner@latest autostart on # 开机自动连上(Mac);升级到新版也用这条
47
+ npx -y agent-hub-runner@latest autostart off # 关掉开机自启
48
+ npx -y agent-hub-runner@latest autostart status
49
+ npx -y agent-hub-runner@latest stop # 停掉在跑的运行器(开机自启的下次开机还会起)
50
+ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复制的程序;会问要不要删配置
52
51
  ```
53
52
 
53
+ 开机自启时,程序复制到 `~/.agent-runner/bin/<版本>/`,日志在 `~/.agent-runner/logs/runner.log`(超过 5MB 自动清空)。网页上提示「有新版本」时,按提示运行那一条升级命令。
54
+
55
+ 仓库开发者(R699):要让仓库里的源码 `runner/runner.mjs` 直接连测试服、开机自启,用 `agent-hub/scripts/runner-test.sh autostart on|off | start | stop | status | log`(launchd 服务 `com.agenthub.runner.src`,日志 `~/.agent-runner/logs/runner-src.log`,不复制程序,改了代码 `start` 一下就生效)。普通用户不用管这个。
56
+
54
57
  ## 配置文件
55
58
 
56
59
  - 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的目录。别发给别人。
57
60
  - 想放别处:设环境变量 `AGENT_RUNNER_CONFIG=/某个路径/config.json`。
61
+ - 文件坏了:删掉它,回网页拿新配对码重新配对。
62
+
63
+ ## 常见问题
64
+
65
+ - `command not found: npx`:还没装 Node。
66
+ - 「需要 Node 22 或更新」(上面还有几行英文 `npm warn EBADENGINE`):Node 太旧,装新的 LTS 版后关掉终端重开。
67
+ - 「权限不够」/ `EACCES`:别用 sudo;运行 `sudo chown -R "$(whoami)" ~/.npm ~/.agent-runner` 修好再试。
68
+ - 「找不到服务器」「连不上服务器」「超时」:检查网络;命令要整条原样复制。
69
+ - 「配对码过期了 / 已经用过了 / 已经作废了」:网页上点「换一个码」再运行新命令。
70
+ - 「这个账号已经连着一台电脑了」:一个账号同时只能连一台,先在网页抽屉「连着的电脑」里点「断开」。
58
71
 
59
72
  ## 断开、卸载
60
73
 
61
- - 在网页抽屉「连着的电脑」里点「断开」:令牌立刻作废,再连要重新配对。
62
- - 卸载:`npm rm -g agent-hub-runner`,再删掉 `~/.agent-runner/`。
74
+ - 在网页抽屉「连着的电脑」里点「断开」:令牌立刻作废,再连要重新配对;开机自启也会自己关掉。
75
+ - 卸载:`npx -y agent-hub-runner@latest uninstall`(加 `--purge` 连配置一起删,不再问)。
76
+
77
+ ## 改动记录(0.1.2 起)
78
+
79
+ - bot 每次写文件、改文件,运行器在配置文件旁边的 `edits/` 文件夹(默认 `~/.agent-runner/edits/`,只有你能读)存一份改动记录,网页上点「已改文件 / 已写文件(自动允许)」那行灰字就能看到改了什么。内容只在你电脑上,服务器不存。
80
+ - 存之前会把像密钥的字符串换成 `***`(`sk-…`、`ghp_…`、连接串里的密码、私钥、`XXX_KEY=…` 这类赋值);每条最多 64 KB;只留 30 天,过期自动删。电脑不在线时网页上看不到。
81
+ - 不想留:直接删掉 `edits/` 文件夹,不影响别的。
63
82
 
64
83
  ## 安全提示
65
84
 
package/autostart.mjs ADDED
@@ -0,0 +1,189 @@
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
+ // @意图#R690(2026-11-01): 多复制 changes.mjs(runner.mjs 要 import 它,少了开机自启的副本起不来)
21
+ export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "autostart.mjs", "package.json"];
22
+ /** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
23
+ export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
24
+ export const ONLY_MAC = "开机自启目前只支持 Mac";
25
+
26
+ const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
27
+
28
+ /** 运行器自己的目录(配置、副本、日志都在这下面);测试可用 AGENT_RUNNER_HOME 换掉 */
29
+ export const runnerHome = (env = process.env) => env.AGENT_RUNNER_HOME || path.join(os.homedir(), ".agent-runner");
30
+ export const defaultConfigFile = (env = process.env) => path.join(runnerHome(env), "config.json");
31
+ export const launchAgentsDir = (env = process.env) => env.AGENT_RUNNER_LAUNCH_AGENTS || path.join(os.homedir(), "Library", "LaunchAgents");
32
+
33
+ /** 默认配置没有后缀;别的配置用绝对路径 sha256 前 8 位 */
34
+ export function configTag(configFile, env = process.env) {
35
+ const abs = path.resolve(configFile);
36
+ if (abs === path.resolve(defaultConfigFile(env))) return "";
37
+ return createHash("sha256").update(abs).digest("hex").slice(0, 8);
38
+ }
39
+ export const labelFor = (configFile, env = process.env) => { const t = configTag(configFile, env); return t ? `${LABEL}.${t}` : LABEL; };
40
+ export const plistPathFor = (configFile, env = process.env) => path.join(launchAgentsDir(env), `${labelFor(configFile, env)}.plist`);
41
+ export const logFileFor = (configFile, env = process.env) => {
42
+ const t = configTag(configFile, env);
43
+ return path.join(runnerHome(env), "logs", t ? `runner-${t}.log` : "runner.log");
44
+ };
45
+
46
+ /** node 的绝对路径:稳定软链和当前 node 是同一个文件就用软链,否则用当前的 */
47
+ export function pickNode(execPath = process.execPath, candidates = STABLE_NODES) {
48
+ const me = real(execPath);
49
+ for (const c of candidates) if (existsSync(c) && real(c) === me) return c;
50
+ return execPath;
51
+ }
52
+
53
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
54
+
55
+ /** 生成 LaunchAgent 的 plist(XML) */
56
+ export function plistXml({ label, args, env, log }) {
57
+ const envXml = Object.entries(env).map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`).join("\n");
58
+ return `<?xml version="1.0" encoding="UTF-8"?>
59
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
60
+ <plist version="1.0">
61
+ <dict>
62
+ <key>Label</key>
63
+ <string>${esc(label)}</string>
64
+ <key>ProgramArguments</key>
65
+ <array>
66
+ ${args.map((a) => ` <string>${esc(a)}</string>`).join("\n")}
67
+ </array>
68
+ <key>EnvironmentVariables</key>
69
+ <dict>
70
+ ${envXml}
71
+ </dict>
72
+ <key>RunAtLoad</key>
73
+ <true/>
74
+ <key>KeepAlive</key>
75
+ <dict>
76
+ <key>SuccessfulExit</key>
77
+ <false/>
78
+ </dict>
79
+ <key>ThrottleInterval</key>
80
+ <integer>${THROTTLE_S}</integer>
81
+ <key>StandardOutPath</key>
82
+ <string>${esc(log)}</string>
83
+ <key>StandardErrorPath</key>
84
+ <string>${esc(log)}</string>
85
+ </dict>
86
+ </plist>
87
+ `;
88
+ }
89
+
90
+ /** 把运行器复制到 <home>/bin/<版本>/,current 软链指过去(先建临时链再改名,换的那一下不会断)。返回 current/runner.mjs 的路径。 */
91
+ export function installCopy({ srcDir, version, env = process.env }) {
92
+ const binRoot = path.join(runnerHome(env), "bin");
93
+ const dest = path.join(binRoot, version);
94
+ mkdirSync(dest, { recursive: true, mode: 0o700 });
95
+ if (real(srcDir) !== real(dest)) {
96
+ for (const f of COPY_FILES) {
97
+ const from = path.join(srcDir, f);
98
+ if (existsSync(from)) copyFileSync(from, path.join(dest, f));
99
+ }
100
+ }
101
+ const cur = path.join(binRoot, "current");
102
+ const tmp = path.join(binRoot, `.current-${process.pid}`);
103
+ rmSync(tmp, { force: true });
104
+ symlinkSync(version, tmp);
105
+ renameSync(tmp, cur);
106
+ return path.join(cur, "runner.mjs");
107
+ }
108
+
109
+ /** 默认的 launchctl:返回 { code, stdout, stderr },不抛错。AGENT_RUNNER_LAUNCHCTL 可换成别的程序(测试用假的,不真加载) */
110
+ export function runLaunchctl(args, env = process.env) {
111
+ return new Promise((resolve) => {
112
+ 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 ?? "") }));
113
+ });
114
+ }
115
+ const uid = () => (typeof process.getuid === "function" ? process.getuid() : 0);
116
+
117
+ /**
118
+ * 开启开机自启:复制自己、写 plist、(先卸掉旧的)再加载。加载后 launchd 马上起一个后台运行器(RunAtLoad)。
119
+ * 返回 { label, plist, script, node, log }。
120
+ */
121
+ export async function autostartOn({ configFile, version, srcDir, execPath = process.execPath, env = process.env, launchctl, platform = process.platform }) {
122
+ launchctl ??= (a) => runLaunchctl(a, env);
123
+ if (platform !== "darwin") throw new Error(ONLY_MAC);
124
+ const cfg = path.resolve(configFile);
125
+ if (!existsSync(cfg)) throw new Error(`还没配对:没有 ${cfg},配对成功后才能设开机自启`);
126
+ const script = installCopy({ srcDir, version, env });
127
+ const node = pickNode(execPath);
128
+ const label = labelFor(cfg, env);
129
+ const log = logFileFor(cfg, env);
130
+ mkdirSync(path.dirname(log), { recursive: true, mode: 0o700 });
131
+ 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" };
132
+ if (env.AGENT_RUNNER_HOME) vars.AGENT_RUNNER_HOME = env.AGENT_RUNNER_HOME;
133
+ const plist = plistPathFor(cfg, env);
134
+ mkdirSync(path.dirname(plist), { recursive: true });
135
+ writeFileSync(plist, plistXml({ label, args: [node, script, "start"], env: vars, log }), { mode: 0o644 });
136
+ await launchctl(["bootout", `gui/${uid()}/${label}`]); // 以前加载过(升级):先卸掉,没有也不要紧
137
+ const r = await launchctl(["bootstrap", `gui/${uid()}`, plist]);
138
+ if (r.code !== 0) throw new Error(`开机自启没设成:launchctl bootstrap 失败(${(r.stderr || r.stdout).trim() || r.code})`);
139
+ return { label, plist, script, node, log };
140
+ }
141
+
142
+ /** 关掉开机自启:卸载任务(后台那个会被停掉)、删 plist。返回 { wasOn }(原来有没有 plist)。 */
143
+ export async function autostartOff({ configFile, env = process.env, launchctl }) {
144
+ launchctl ??= (a) => runLaunchctl(a, env);
145
+ const cfg = path.resolve(configFile);
146
+ const plist = plistPathFor(cfg, env);
147
+ const wasOn = existsSync(plist);
148
+ rmSync(plist, { force: true });
149
+ await launchctl(["bootout", `gui/${uid()}/${labelFor(cfg, env)}`]);
150
+ return { wasOn };
151
+ }
152
+
153
+ /** 开机自启现在的样子:装没装(有没有 plist)、加载没有、后台进程号;plist 里的 node / 脚本找不到就写进 problems */
154
+ export async function autostartStatus({ configFile, env = process.env, launchctl, platform = process.platform }) {
155
+ launchctl ??= (a) => runLaunchctl(a, env);
156
+ const cfg = path.resolve(configFile);
157
+ const label = labelFor(cfg, env);
158
+ const plist = plistPathFor(cfg, env);
159
+ const out = { supported: platform === "darwin", installed: existsSync(plist), loaded: false, pid: null, label, plist, log: logFileFor(cfg, env), problems: [] };
160
+ if (!out.supported) return out;
161
+ if (out.installed) {
162
+ const xml = readFileSync(plist, "utf8");
163
+ 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, "&"));
164
+ if (args[0] && !existsSync(args[0])) out.problems.push(`找不到 node:${args[0]}(换过 Node 的装法?重新运行 autostart on)`);
165
+ if (args[1] && !existsSync(args[1])) out.problems.push(`找不到运行器副本:${args[1]}(重新运行 autostart on)`);
166
+ }
167
+ const r = await launchctl(["print", `gui/${uid()}/${label}`]);
168
+ if (r.code === 0) {
169
+ out.loaded = true;
170
+ const m = /\bpid = (\d+)/.exec(r.stdout);
171
+ if (m) out.pid = Number(m[1]);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /** 还有没有别的配置开着自启(uninstall 时决定删不删共用的 bin 副本) */
177
+ export function otherAutostarts(configFile, env = process.env) {
178
+ const mine = `${labelFor(configFile, env)}.plist`;
179
+ try { return readdirSync(launchAgentsDir(env)).filter((f) => f.startsWith(LABEL) && f.endsWith(".plist") && f !== mine); } catch { return []; }
180
+ }
181
+
182
+ /** 日志超过 max 字节就清空(launchd 用追加方式打开,清空后接着往后写不会乱)。清了返回 true。 */
183
+ export function truncateLogIfBig(file, max = LOG_MAX_BYTES) {
184
+ try {
185
+ if (statSync(file).size <= max) return false;
186
+ truncateSync(file, 0);
187
+ return true;
188
+ } catch { return false; }
189
+ }
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // @意图#R664(2026-10-29): npm 包 agent-hub-runner 的命令入口(package.json 的 bin):客户电脑 npm i -g agent-hub-runner 后直接跑
3
3
  // agent-hub-runner pair <配对码> --start --server <地址> / start / status;逻辑全在 ../runner.mjs,这里只把参数和命令名交过去。
4
- import { main, progName } from "../runner.mjs";
5
-
6
- main(process.argv.slice(2), progName()).catch((e) => { console.error(e?.message ?? String(e)); process.exit(1); });
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/changes.mjs ADDED
@@ -0,0 +1,195 @@
1
+ // @意图#R690(2026-11-01): 私聊里「已改文件 / 已写文件(自动允许)」点开看改动(king 同意 R690):改动内容只存在你电脑上(运行器配置旁边的 edits/<auto_id>.json,权限 600),
2
+ // 服务器库里只记 id。改文件存按行对比(带改前、改后行号和前后 3 行,网页左右对照用),写文件存写进去的内容;存之前先打码(和服务器 redactSecrets 同一套规则,
3
+ // 共用测试样例 test/fixtures/redact-cases.json);每条最多 64 KB(超出截断并标记);只留 30 天(启动时和每次存时顺手清,最多一小时一次)。存失败只记日志,不影响写改本身。
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+
7
+ export const CHANGE_ID_RE = /^auto-[0-9a-f]{32}$/;
8
+ export const MAX_RECORD_BYTES = 64 * 1024;
9
+ export const KEEP_MS = 30 * 24 * 60 * 60_000;
10
+ export const CLEAN_EVERY_MS = 60 * 60_000;
11
+ export const CONTEXT_LINES = 3;
12
+ /** 中间改动部分(去掉首尾相同的行后)行数乘积超过这个就不细算,整块当作删了再加 */
13
+ export const MAX_DIFF_CELLS = 4_000_000;
14
+
15
+ /** 改动记录放在配置文件旁边的 edits/ 里(跟着 AGENT_RUNNER_CONFIG 走,dev 和正式配置各管各的文件,名字是 auto_id 不会撞)。 */
16
+ // @意图#R690(2026-11-01): king 定甲:放配置旁边
17
+ export const changesDirFor = (configFile) => path.join(path.dirname(configFile), "edits");
18
+
19
+ // 变量名以 KEY / TOKEN / SECRET / PASSWORD / PASSWD 结尾(API_KEY、db.password、apiKey、token 都算;monkey、tokens 不算)
20
+ const NAME = String.raw`(?:(?:[A-Za-z0-9]+[_.-])*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|key|token|secret|password|passwd|Key|Token|Secret|Password)|[a-z][A-Za-z0-9]*(?:Key|Token|Secret|Password))`;
21
+ const QUOTED_ASSIGN = new RegExp(String.raw`(^|[^\w.-])(["']?)(${NAME})\2([ \t]*[=:][ \t]*)(["'])([^"'\r\n]+)\5`, "gm");
22
+ const LINE_ASSIGN = new RegExp(String.raw`^([ \t]*(?:export[ \t]+)?)(${NAME})([ \t]*[=:][ \t]*)([^\s"'#][^\s#]*)`, "gm");
23
+
24
+ /**
25
+ * 打码(和服务器 server/src/model-keys.ts 的 redactSecrets 规则一字不差,样例见 test/fixtures/redact-cases.json):
26
+ * sk- / ghp_ 开头的串、连接串里的密码、私钥块(BEGIN/END 两行留着,中间每行换成 ***,行数不变)、
27
+ * 变量名像密钥的赋值(带引号的值任何位置都打;不带引号的只认行首的 .env / yaml 写法)。
28
+ */
29
+ // @意图#R690(2026-11-01): king 定乙:服务器三条规则 + 私钥块 + .env 式 XXX_KEY / TOKEN / SECRET / PASSWORD = 值;打码不改变行数(行号对得上)
30
+ export function redactSecrets(text) {
31
+ let s = String(text);
32
+ s = s.replace(/(-----BEGIN [A-Z0-9 ]*-----)([\s\S]*?)(-----END [A-Z0-9 ]*-----)/g, (_m, a, body, b) => a + body.replace(/[^\r\n]+/g, "***") + b);
33
+ s = s.replace(/(-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----)((?:(?!-----END )[\s\S])*)$/, (_m, a, body) => a + body.replace(/[^\r\n]+/g, "***")); // 截断了没有 END 的私钥块
34
+ s = s.replace(/(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]+/g, "sk-***").replace(/(?<![A-Za-z0-9])ghp_[A-Za-z0-9_]+/g, "ghp_***")
35
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):[^\s@/]+@/gi, "$1:***@");
36
+ s = s.replace(QUOTED_ASSIGN, (m, pre, q1, name, eq, q2, val) => (val === "***" ? m : `${pre}${q1}${name}${q1}${eq}${q2}***${q2}`));
37
+ s = s.replace(LINE_ASSIGN, "$1$2$3***");
38
+ return s;
39
+ }
40
+
41
+ /** 按行拆(去 BOM、换行统一成 LF;末尾换行不算多一行)。 */
42
+ export function splitLines(text) {
43
+ let t = String(text);
44
+ if (t.startsWith("\uFEFF")) t = t.slice(1);
45
+ t = t.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
46
+ if (t === "") return [];
47
+ const a = t.split("\n");
48
+ if (a[a.length - 1] === "") a.pop();
49
+ return a;
50
+ }
51
+
52
+ /** 逐行对比:返回每一行 {t:" "|"-"|"+", o:改前行号|null, n:改后行号|null, s:内容};连着的一段改动里先删后加。 */
53
+ // @意图#R690(2026-11-01): 带两边行号,网页既能画左右对照也能画红绿一栏
54
+ export function diffLines(a, b) {
55
+ let p = 0;
56
+ while (p < a.length && p < b.length && a[p] === b[p]) p++;
57
+ let q = 0;
58
+ while (q < a.length - p && q < b.length - p && a[a.length - 1 - q] === b[b.length - 1 - q]) q++;
59
+ const A = a.slice(p, a.length - q), B = b.slice(p, b.length - q);
60
+ const mid = []; // " " / "-" / "+" 序列
61
+ if (A.length && B.length && A.length * B.length <= MAX_DIFF_CELLS) {
62
+ const n = A.length, m = B.length, w = m + 1;
63
+ const dp = new Uint32Array((n + 1) * w);
64
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
65
+ dp[i * w + j] = A[i] === B[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
66
+ }
67
+ let i = 0, j = 0;
68
+ while (i < n || j < m) {
69
+ if (i < n && j < m && A[i] === B[j]) { mid.push(" "); i++; j++; }
70
+ else if (j >= m || (i < n && dp[(i + 1) * w + j] >= dp[i * w + j + 1])) { mid.push("-"); i++; }
71
+ else { mid.push("+"); j++; }
72
+ }
73
+ } else {
74
+ for (let i = 0; i < A.length; i++) mid.push("-");
75
+ for (let j = 0; j < B.length; j++) mid.push("+");
76
+ }
77
+ // 连着的改动里先删后加
78
+ const tags = [];
79
+ for (let k = 0; k < mid.length;) {
80
+ if (mid[k] === " ") { tags.push(" "); k++; continue; }
81
+ let d = 0, ad = 0;
82
+ while (k < mid.length && mid[k] !== " ") { if (mid[k] === "-") d++; else ad++; k++; }
83
+ for (let x = 0; x < d; x++) tags.push("-");
84
+ for (let x = 0; x < ad; x++) tags.push("+");
85
+ }
86
+ const out = [];
87
+ let o = 0, nn = 0;
88
+ for (let k = 0; k < p; k++) { out.push({ t: " ", o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
89
+ for (const t of tags) {
90
+ if (t === " ") { out.push({ t, o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
91
+ else if (t === "-") { out.push({ t, o: o + 1, n: null, s: a[o] }); o++; }
92
+ else { out.push({ t, o: null, n: nn + 1, s: b[nn] }); nn++; }
93
+ }
94
+ for (let k = 0; k < q; k++) { out.push({ t: " ", o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
95
+ return out;
96
+ }
97
+
98
+ /** 把逐行对比切成几处改动,每处前后带 ctx 行没变的;两处之间没变的不超过 2×ctx 行就并成一处。 */
99
+ export function hunksOf(rows, ctx = CONTEXT_LINES) {
100
+ const idx = [];
101
+ rows.forEach((r, i) => { if (r.t !== " ") idx.push(i); });
102
+ const hunks = [];
103
+ let k = 0;
104
+ while (k < idx.length) {
105
+ let start = idx[k], end = idx[k];
106
+ while (k + 1 < idx.length && idx[k + 1] - end - 1 <= 2 * ctx) { k++; end = idx[k]; }
107
+ k++;
108
+ const from = Math.max(0, start - ctx), to = Math.min(rows.length, end + ctx + 1);
109
+ const lines = rows.slice(from, to);
110
+ let oStart = 1, nStart = 1;
111
+ for (let i = 0; i < from; i++) { if (rows[i].t !== "+") oStart++; if (rows[i].t !== "-") nStart++; }
112
+ hunks.push({ old_start: oStart, new_start: nStart, lines });
113
+ }
114
+ return hunks;
115
+ }
116
+
117
+ const jsonBytes = (v) => Buffer.byteLength(JSON.stringify(v), "utf8") + 1; // 存进去占的字节(加逗号)
118
+
119
+ /**
120
+ * 生成一条改动记录(存之前先打码、截到 64 KB)。
121
+ * edit:{ v, id, tool, path, at, hunks, added, removed, truncated };write:{ v, id, tool, path, at, lines, total_lines, chars, truncated }
122
+ */
123
+ // @意图#R690(2026-11-01): 改文件存红绿对比(king 定),写文件只存写进去的内容、不存旧文件(king 定乙);每条最多 64 KB(king 定甲)
124
+ export function buildChangeRecord({ id, tool, path: p, before, after, content, at = new Date().toISOString() }) {
125
+ const budget = MAX_RECORD_BYTES - 1024;
126
+ const head = { v: 1, id, tool, path: String(p ?? ""), at };
127
+ if (tool === "write") {
128
+ const text = String(content ?? "");
129
+ const all = splitLines(redactSecrets(text));
130
+ const lines = [];
131
+ let used = 0, truncated = false;
132
+ for (const l of all) {
133
+ const b = jsonBytes(l);
134
+ if (used + b > budget) { truncated = true; break; }
135
+ lines.push(l); used += b;
136
+ }
137
+ return { ...head, lines, total_lines: all.length, chars: text.length, truncated };
138
+ }
139
+ const rows = diffLines(splitLines(redactSecrets(before ?? "")), splitLines(redactSecrets(after ?? "")));
140
+ let added = 0, removed = 0;
141
+ for (const r of rows) { if (r.t === "+") added++; else if (r.t === "-") removed++; }
142
+ const hunks = [];
143
+ let used = 0, truncated = false;
144
+ for (const h of hunksOf(rows)) {
145
+ used += jsonBytes({ old_start: h.old_start, new_start: h.new_start, lines: [] });
146
+ const keep = [];
147
+ for (const l of h.lines) {
148
+ const b = jsonBytes(l);
149
+ if (used + b > budget) { truncated = true; break; }
150
+ keep.push(l); used += b;
151
+ }
152
+ if (keep.length) hunks.push({ ...h, lines: keep });
153
+ if (truncated) break;
154
+ }
155
+ return { ...head, hunks, added, removed, truncated };
156
+ }
157
+
158
+ /** 改动记录的存取和清理(目录用到时才建,700;文件 600)。 */
159
+ // @意图#R690(2026-11-01): 只留 30 天(king 定甲):启动时清一次,每次存时顺手清(最多一小时一次),按文件修改时间算
160
+ export function createChangeStore(dir, { now = Date.now, log = () => {} } = {}) {
161
+ let lastClean = 0;
162
+ const fileOf = (id) => path.join(dir, `${id}.json`);
163
+ async function clean(force = false) {
164
+ if (!force && now() - lastClean < CLEAN_EVERY_MS) return 0;
165
+ lastClean = now();
166
+ let names;
167
+ try { names = await fs.readdir(dir); } catch { return 0; }
168
+ let removed = 0;
169
+ for (const f of names) {
170
+ if (!/^auto-[0-9a-f]{32}\.json$/.test(f)) continue;
171
+ try {
172
+ const st = await fs.stat(path.join(dir, f));
173
+ if (now() - st.mtimeMs > KEEP_MS) { await fs.unlink(path.join(dir, f)); removed++; }
174
+ } catch { /* 别的进程删了 */ }
175
+ }
176
+ return removed;
177
+ }
178
+ return {
179
+ dir,
180
+ clean,
181
+ async save(id, record) {
182
+ if (!CHANGE_ID_RE.test(String(id))) throw new Error("改动记录 id 不对");
183
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
184
+ const f = fileOf(id);
185
+ await fs.writeFile(f, JSON.stringify(record), { mode: 0o600 });
186
+ await fs.chmod(f, 0o600);
187
+ await clean().catch((e) => log(`清理旧改动记录失败:${e?.message ?? e}`));
188
+ },
189
+ /** 找不到(没存、已清理、id 不对)返回 null */
190
+ async get(id) {
191
+ if (!CHANGE_ID_RE.test(String(id))) return null;
192
+ try { return JSON.parse(await fs.readFile(fileOf(id), "utf8")); } catch { return null; }
193
+ },
194
+ };
195
+ }
package/ops.mjs CHANGED
@@ -77,24 +77,28 @@ export async function opRead(args, root) {
77
77
  }
78
78
 
79
79
  /** write:建好上级目录再整份写入。 */
80
+ // @意图#R690(2026-11-01): 写成功后交给 onChange 存一份改动记录(写进去的内容),存记录的事不影响写本身
80
81
  // @意图#R636(2026-10-27): 写文件(已经过批准,只执行这一次)
81
- export async function opWrite(args, root) {
82
+ export async function opWrite(args, root, { onChange } = {}) {
82
83
  if (typeof args.content !== "string") throw new Error("content 必须是文字");
83
84
  const real = await resolveInside(args.path, root);
84
85
  await fs.mkdir(path.dirname(real), { recursive: true });
85
86
  await fs.writeFile(real, args.content, "utf8");
87
+ await onChange?.({ tool: "write", path: args.path, content: args.content });
86
88
  return { bytes: Buffer.byteLength(args.content, "utf8") };
87
89
  }
88
90
 
89
91
  /** edit:和 pi 原版同样的精确替换。 */
92
+ // @意图#R690(2026-11-01): 写回成功后把改前(读到的原文)、改后(写回的内容)交给 onChange 算对比、存记录:同一个函数里拿到的两份,不会和别的改动串
90
93
  // @意图#R636(2026-10-27): 改文件(已经过批准,只执行这一次)
91
- export async function opEdit(args, root) {
94
+ export async function opEdit(args, root, { onChange } = {}) {
92
95
  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
96
  const real = await resolveInside(args.path, root);
94
97
  let raw;
95
98
  try { raw = await fs.readFile(real, "utf8"); } catch (e) { throw new Error(`Could not edit file: ${args.path}. Error code: ${e?.code ?? "unknown"}.`); }
96
99
  const next = editText(raw, args.edits, args.path);
97
100
  await fs.writeFile(real, next, "utf8");
101
+ await onChange?.({ tool: "edit", path: args.path, before: raw, after: next });
98
102
  return { replaced: args.edits.length };
99
103
  }
100
104
 
@@ -141,12 +145,13 @@ export function opBash(args, root, { onData, signal } = {}) {
141
145
  }
142
146
 
143
147
  /** 按 op 分派;未知 op 拒绝。 */
148
+ // @意图#R690(2026-11-01): write、edit 也收 extra(onChange 存改动记录)
144
149
  // @意图#R636(2026-10-27): 协议里只有这四种工具调用
145
150
  export function runOp(op, args, root, extra) {
146
151
  const a = args && typeof args === "object" ? args : {};
147
152
  if (op === "read") return opRead(a, root);
148
- if (op === "write") return opWrite(a, root);
149
- if (op === "edit") return opEdit(a, root);
153
+ if (op === "write") return opWrite(a, root, extra);
154
+ if (op === "edit") return opEdit(a, root, extra);
150
155
  if (op === "bash") return opBash(a, root, extra);
151
156
  return Promise.reject(new Error(`未知操作 ${op}`));
152
157
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "agent-hub-runner",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准)",
5
5
  "type": "module",
6
6
  "bin": { "agent-hub-runner": "bin/agent-hub-runner.mjs" },
7
- "files": ["runner.mjs", "ops.mjs", "edit.mjs", "bin/agent-hub-runner.mjs", "README.md"],
7
+ "files": ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "autostart.mjs", "bin/agent-hub-runner.mjs", "README.md"],
8
8
  "engines": { "node": ">=22" },
9
9
  "scripts": { "test": "node --test" }
10
10
  }
package/runner.mjs CHANGED
@@ -1,36 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  // @意图#R636(2026-10-27): 你电脑上的运行器(第一轮最小可用,king 定甲):零第三方依赖,只用 Node 22+ 自带的 WebSocket、fetch、进程和文件 API。
3
3
  // 用法:
4
- // node agent-hub/runner/runner.mjs pair <配对码> [--start] [--server http://127.0.0.1:3220]
5
- // node agent-hub/runner/runner.mjs start
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 报自己的版本和是不是开机自启,网页据此提示「有新版本」。
6
11
  // @意图#R664(2026-10-29): 运行器做成客户电脑 npm 安装的小工具(包名、命令名都是 agent-hub-runner,入口 bin/agent-hub-runner.mjs):
7
12
  // npm i -g agent-hub-runner → agent-hub-runner pair <配对码> --start --server <地址>;以后 agent-hub-runner start。仓库里 node 直接跑照旧。
8
13
  // @意图#R663(2026-10-29): pair 加 --start(二甲):配对成功后直接接着 start 连上,网页上只给一条命令;不带 --start 的老用法不变
9
14
  // @意图#R640(2026-10-27): 一台电脑一个运行器,但每个 bot 有自己的授权目录(bot→目录 对照表 bot_dirs),互相碰不到;king 定乙:没有默认目录,
10
- // 每个 bot 都要在它的私聊右栏「我的电脑」里点「选择目录」,运行器在本机弹窗选。服务器只报 bot_id,定不了路径。pair 不再收 --dir。
15
+ // 每个 bot 都要在它的私聊里「我的电脑」(R663 起是输入框下面那一行,原来是右栏)点「选择目录」,运行器在本机弹窗选。服务器只报 bot_id,定不了路径。pair 不再收 --dir。
11
16
  // @意图#R639(2026-10-27): 配对不再选目录(king 定乙,替换 R637 配对时弹窗);start 连上后在网页「我的电脑」点「选择目录」,
12
17
  // 服务器经这条 WebSocket 叫运行器在 Mac 上弹系统选文件夹窗口,选完马上生效并写回配置文件。
13
18
  // 配对后长期令牌和授权目录存在 ~/.agent-runner/config.json(权限 600);目录只能由你在本机窗口里选,服务器只能叫你弹窗、定不了目录。
14
19
  // (R639 时目录是一台电脑一个、配对时还能 --dir;R640 起换成每个 bot 一个,老配置里的单一 dir 读到直接忽略。)
15
- // start 在终端前台运行,主动用 WebSocket 连服务器;按 Ctrl-C 立刻断开。没有开机自启、没有 npx 安装包、没有沙箱。
16
- import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
20
+ // start 在终端前台运行,主动用 WebSocket 连服务器;按 Ctrl-C 立刻断开。没有沙箱。(R666 起有开机自启、npx 用法。)
21
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
17
22
  import fs from "node:fs/promises";
18
23
  import os from "node:os";
19
24
  import path from "node:path";
25
+ import readline from "node:readline/promises";
20
26
  import { execFile } from "node:child_process";
21
27
  import { fileURLToPath } from "node:url";
22
28
  import { runOp } from "./ops.mjs";
29
+ import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
30
+ import { autostartOff, autostartOn, autostartStatus, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
23
31
 
24
32
  export const PROTOCOL_VERSION = 1;
25
33
  /** npm 包名,也是装好后的命令名 */
26
34
  export const PKG_BIN = "agent-hub-runner";
35
+ /** R666:抽屉里给的、npx 运行时提示里写的命令前缀(@latest:每次都用最新版,也不会被电脑上旧的全局安装盖掉) */
36
+ export const NPX_CMD = `npx -y ${PKG_BIN}@latest`;
37
+ /** R666:自己的版本号(读同目录的 package.json;开机自启的副本也带着它),hello 时报给服务器 */
38
+ export const RUNNER_VERSION = (() => { try { return String(JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version); } catch { return "0.0.0"; } })();
39
+ /** R666:配对请求、连接超时(秒) */
40
+ export const PAIR_TIMEOUT_MS = 15_000;
27
41
  export const HEARTBEAT_MS = 10_000;
28
42
  export const DEFAULT_SERVER = "http://127.0.0.1:3220";
29
43
  /** 服务器用这两个关闭码表示令牌不对 / 已在网页上断开:不再重连。 */
30
44
  export const CLOSE_BAD_TOKEN = 4401;
31
45
  export const CLOSE_REVOKED = 4403;
46
+ /** R666:同一台电脑又连上来一个(同一份配置开了两个运行器),服务器把旧的这条用 4409 断开:不再重连 */
47
+ export const CLOSE_REPLACED = 4409;
32
48
 
33
- export const configPath = () => process.env.AGENT_RUNNER_CONFIG || path.join(os.homedir(), ".agent-runner", "config.json");
49
+ // @意图#R666(2026-10-29): 默认目录可用 AGENT_RUNNER_HOME 换(测试、开机自启的副本用);不设时和原来一样是 ~/.agent-runner/config.json
50
+ export const configPath = () => process.env.AGENT_RUNNER_CONFIG || path.join(runnerHome(), "config.json");
34
51
 
35
52
  const stamp = () => new Date().toLocaleTimeString("zh-CN", { hour12: false });
36
53
  const say = (s) => console.log(`[${stamp()}] ${s}`);
@@ -41,36 +58,49 @@ export function saveConfig(cfg, file = configPath()) {
41
58
  writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
42
59
  chmodSync(file, 0o600);
43
60
  }
61
+ // @意图#R666(2026-10-29): 提示改成现在网页上的位置(输入框下面「我的电脑」那一行),不再说「连接电脑」;配置文件坏了说清楚怎么办
44
62
  export function loadConfig(file = configPath()) {
45
- if (!existsSync(file)) throw new Error(`还没配对:没有 ${file}。先在网页「我的电脑」里点「连接电脑」拿配对码,再运行 pair。`);
46
- return JSON.parse(readFileSync(file, "utf8"));
63
+ if (!existsSync(file)) throw new Error(`还没配对:没有 ${file}。先在网页上 bot 的私聊里点输入框下面「我的电脑」那一行,复制里面的命令运行。`);
64
+ const text = readFileSync(file, "utf8");
65
+ try { return JSON.parse(text); }
66
+ catch { throw new Error(`配置文件坏了:${file}。删掉它(rm "${file}"),再回网页拿新配对码重新配对。`); }
47
67
  }
48
68
 
69
+ // @意图#R666(2026-10-29): pair 默认配完直接 start:--start 照样认(老命令),--no-start 只配对;--autostart / --no-autostart 跳过「开机自动连上?」的提问;
70
+ // uninstall --purge 连配置一起删;--version / -v 打版本
49
71
  // @意图#R663(2026-10-29): 认 --start(只对 pair 有意义,不带值)
50
72
  export function parseArgs(argv) {
51
73
  const [cmd, ...rest] = argv;
52
- const out = { cmd, positional: [] };
74
+ const out = { cmd: cmd === "--version" || cmd === "-v" ? "version" : cmd === "--help" || cmd === "-h" ? "help" : cmd, positional: [] };
53
75
  for (let i = 0; i < rest.length; i++) {
54
76
  const a = rest[i];
55
77
  if (a === "--dir") out.dir = rest[++i];
56
78
  else if (a === "--server") out.server = rest[++i];
57
79
  else if (a === "--name") out.name = rest[++i];
58
80
  else if (a === "--start") out.start = true;
81
+ else if (a === "--no-start") out.start = false;
82
+ else if (a === "--autostart") out.autostart = true;
83
+ else if (a === "--no-autostart") out.autostart = false;
84
+ else if (a === "--purge") out.purge = true;
59
85
  else if (a.startsWith("--")) throw new Error(`不认识的参数 ${a}`);
60
86
  else out.positional.push(a);
61
87
  }
62
88
  return out;
63
89
  }
64
90
 
91
+ /** R666:Node 太低时的大白话(bin 入口在加载别的代码之前也用同样的话) */
92
+ export const nodeTooOld = (v) => `需要 Node 22 或更新,你这台电脑上是 ${v}。\n(上面如果有几行英文 npm warn EBADENGINE,说的就是这个,不用管它。)\n去 https://nodejs.org 下载 LTS 版装上(Mac 选 .pkg 安装包),装好后关掉终端重新打开,再运行同一条命令。`;
93
+ // @意图#R666(2026-10-29): 版本太低的提示改成大白话(怎么装、英文警告不用管)
65
94
  function checkNode() {
66
95
  const major = Number(process.versions.node.split(".")[0]);
67
- if (major < 22 || typeof WebSocket === "undefined") throw new Error(`需要 Node 22 或更新(现在是 ${process.versions.node})`);
96
+ if (major < 22 || typeof WebSocket === "undefined") throw new Error(nodeTooOld(process.versions.node));
68
97
  }
69
98
 
70
99
  export const PICK_PROMPT = "选择要授权给 bot 的文件夹";
71
100
  export const PICK_SCRIPT = `POSIX path of (choose folder with prompt "${PICK_PROMPT}")`;
72
101
  /** R640:这个 bot 在对照表里没有目录时,调用一律回这句。 */
73
- export const NO_DIR = "还没给这个 bot 选目录,请在这个 bot 的私聊右栏「我的电脑」里点「选择目录」";
102
+ // @意图#R666(2026-10-29): 「右栏」是 R663 以前的位置,改成输入框下面「我的电脑」那一行(和服务器 devices.ts 的 NO_DIR 一字不差)
103
+ export const NO_DIR = "还没给这个 bot 选目录,请在这个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」";
74
104
  export const PICK_ONLY_MAC = "只支持 Mac:选目录的窗口只在 Mac 上弹";
75
105
  /** R640:pair 带了 --dir 时的提示(不报错,照常配对,目录不用)。 */
76
106
  export const PAIR_DIR_IGNORED = "目录请连上后在每个 bot 的私聊里选(--dir 已不用,这次没用它)";
@@ -121,17 +151,99 @@ export async function checkDir(dir) {
121
151
  // @意图#R639(2026-10-27已废弃): 配对不弹窗、不要求目录(king 定乙);--dir 还能用,给了就照旧检查;没给 config.dir 为空(R640 去掉 --dir)
122
152
  // @意图#R637(2026-10-27已废弃): Mac 上没写 --dir 就弹窗选目录、取消不请求服务器(R639 改成连上后在网页上选)
123
153
  // @意图#R636(2026-10-27): 配对码一次性、5 分钟有效(服务器判断);授权目录先换成真实路径再存
124
- export async function pair({ code, dir, server = DEFAULT_SERVER, name = os.hostname() }, file = configPath()) {
154
+ // @意图#R666(2026-10-29): 配对码可以写成「码@服务器」(code 里带 @ 时拆开,见 parsePairTarget;显式给了 server 就用 server);15 秒超时;
155
+ // 连不上、服务器回错都翻成大白话(explainNetError / explainPairFailure);缺码的提示不再写 runner.mjs
156
+ export async function pair({ code, dir, server, name = os.hostname(), timeoutMs = PAIR_TIMEOUT_MS }, file = configPath()) {
125
157
  checkNode();
126
- if (!code) throw new Error("缺配对码:runner.mjs pair <配对码>");
127
- const r = await fetch(`${server.replace(/\/$/, "")}/devices/pair`, {
128
- method: "POST", headers: { "content-type": "application/json" },
129
- body: JSON.stringify({ code, name, os: `${process.platform} ${os.release()}` }),
130
- });
131
- const body = await r.json().catch(() => ({}));
132
- if (r.status !== 201) throw new Error(`配对失败(${r.status}):${body.error ?? "未知原因"}`);
133
- saveConfig({ server: server.replace(/\/$/, ""), device_id: body.device_id, token: body.token, name, bot_dirs: {} }, file);
134
- return { deviceId: body.device_id, file, dirIgnored: dir !== undefined && dir !== null && dir !== "" };
158
+ const t = parsePairTarget(code, server);
159
+ if (!t.code) throw new Error(`缺配对码:${progName()} pair <配对码>@<服务器地址>(请从网页上原样复制整条命令)`);
160
+ let r;
161
+ try {
162
+ r = await fetch(`${t.server}/devices/pair`, {
163
+ method: "POST", headers: { "content-type": "application/json" },
164
+ body: JSON.stringify({ code: t.code, name, os: `${process.platform} ${os.release()}` }),
165
+ signal: AbortSignal.timeout(timeoutMs),
166
+ });
167
+ } catch (e) { throw new Error(explainNetError(e, t.server, timeoutMs)); }
168
+ const text = await r.text().catch(() => "");
169
+ let body = null;
170
+ try { body = JSON.parse(text); } catch { body = null; }
171
+ if (r.status !== 201 || !body || typeof body.token !== "string") throw new Error(explainPairFailure(r.status, body));
172
+ saveConfig({ server: t.server, device_id: body.device_id, token: body.token, name, bot_dirs: {} }, file);
173
+ return { deviceId: body.device_id, file, server: t.server, dirIgnored: dir !== undefined && dir !== null && dir !== "" };
174
+ }
175
+
176
+ const isLoopback = (h) => h === "localhost" || /^127\./.test(h) || h === "[::1]" || h === "::1";
177
+ /**
178
+ * 「码@服务器」拆成 { code, server }(R666):@ 后面没写 http:// / https:// 时默认 https,localhost、127.x、[::1] 用 http;可以带端口、路径;
179
+ * 去掉末尾的 /。显式给了 server(--server)就用它;没 @ 也没 --server 用 DEFAULT_SERVER(老行为)。地址看不懂、带 ? # 或账号密码就报错。
180
+ */
181
+ // @意图#R666(2026-10-29): 配对码里带服务器信息(king 定一甲:码@域名),命令里不用再写 --server;老写法 pair 码 --server 地址照样能用
182
+ export function parsePairTarget(raw, server) {
183
+ const s = String(raw ?? "").trim();
184
+ const at = s.indexOf("@");
185
+ const code = (at < 0 ? s : s.slice(0, at)).trim();
186
+ const where = at < 0 ? "" : s.slice(at + 1).trim();
187
+ if (server) return { code, server: String(server).trim().replace(/\/+$/, "") };
188
+ if (!where) return { code, server: DEFAULT_SERVER };
189
+ const explicit = /^https?:\/\//i.test(where);
190
+ let u;
191
+ try { u = new URL(explicit ? where : `https://${where}`); }
192
+ catch { throw new Error(`配对码后面的服务器地址看不懂:${where}。请从网页上原样复制整条命令。`); }
193
+ if (u.search || u.hash || u.username || u.password) throw new Error(`服务器地址不能带 ? # 或账号密码:${where}。请从网页上原样复制整条命令。`);
194
+ if (!explicit && isLoopback(u.hostname)) u.protocol = "http:";
195
+ return { code, server: `${u.origin}${u.pathname}`.replace(/\/+$/, "") };
196
+ }
197
+
198
+ const hostOf = (server) => { try { return new URL(server).host; } catch { return server; } };
199
+ /** fetch 失败(连不上)翻成大白话:找不到、没开、证书、超时、被断开、其他 */
200
+ // @意图#R666(2026-10-29): 原来只打一句英文 fetch failed;按 cause.code 分开说(king 定五)
201
+ export function explainNetError(e, server, timeoutMs = PAIR_TIMEOUT_MS) {
202
+ const code = String(e?.cause?.code ?? e?.cause?.cause?.code ?? e?.code ?? "");
203
+ const host = hostOf(server);
204
+ if (e?.name === "TimeoutError" || e?.name === "AbortError" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ETIMEDOUT") {
205
+ return `连服务器超时(${Math.round(timeoutMs / 1000)} 秒没回应):${host}。检查这台电脑能不能上网,或者稍后再试。`;
206
+ }
207
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") return `找不到服务器 ${host}:命令里 @ 后面的地址可能写错了,或者这台电脑没联网。`;
208
+ if (code === "ECONNREFUSED") return `连不上服务器 ${server}:服务器没开,或者地址、端口不对。`;
209
+ if (/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS|SSL/.test(code)) return `服务器 ${host} 的 https 证书有问题(${code}):可能证书过期了,或者网络在拦截。请告诉服务器的管理员。`;
210
+ if (code === "ECONNRESET" || code === "EPIPE" || code === "UND_ERR_SOCKET") return `和服务器 ${host} 的连接被断开了:稍后再试;一直这样请检查代理或网络。`;
211
+ return `连不上服务器 ${server}(${code || e?.message || "未知原因"}):检查网络和命令里的地址。`;
212
+ }
213
+
214
+ /** R666:服务器回的错误码(/devices/pair 返回的 code)对应的大白话 */
215
+ export const PAIR_ERR = {
216
+ bad_code: "配对码不对:请从网页上原样复制整条命令再运行(码只能用一次,5 分钟有效)。",
217
+ used: "这个配对码已经用过了:回网页点「换一个码」,再复制新命令运行。",
218
+ replaced: "这个配对码已经作废了(网页上换过新码):请复制网页上最新的那条命令运行。",
219
+ expired: "配对码过期了(5 分钟有效):回网页点「换一个码」,再复制新命令运行。",
220
+ device_exists: "这个账号已经连着一台电脑了:先在网页「我的电脑」抽屉最下面「连着的电脑」里点「断开」,再换一个码重新配对。",
221
+ bad_input: "命令不完整:请从网页上原样复制整条命令再运行。",
222
+ };
223
+ const NOT_HUB = (status) => `这个地址不是 agent-hub 服务器(回了 ${status}):检查命令里 @ 后面的地址,请从网页上原样复制整条命令。`;
224
+ /** 配对请求回的不是 201:先看服务器给的 code,老服务器没 code 就按状态码猜;回的不是 JSON 多半不是 agent-hub */
225
+ // @意图#R666(2026-10-29): 400/410/409/404/405/非 JSON/5xx 各给一句(king 定五)
226
+ export function explainPairFailure(status, body) {
227
+ if (body && typeof body === "object" && PAIR_ERR[body.code]) return `配对失败:${PAIR_ERR[body.code]}`;
228
+ if (status === 404 || status === 405) return `配对失败:${NOT_HUB(status)}`;
229
+ if (status >= 500) return `配对失败:服务器出错了(${status}),稍后再试。`;
230
+ if (!body || typeof body !== "object") return `配对失败:${NOT_HUB(status)}`;
231
+ if (status === 410) return `配对失败:${PAIR_ERR.expired}`;
232
+ if (status === 409) return `配对失败:${PAIR_ERR.device_exists}`;
233
+ if (status === 400) return `配对失败:${PAIR_ERR.bad_code}`;
234
+ return `配对失败(${status}):${body.error ?? "未知原因"}`;
235
+ }
236
+
237
+ /** 整个命令出错时打给人看的话:权限不够(多半用 sudo 跑过)单独说怎么修,别的原样 */
238
+ // @意图#R666(2026-10-29): EACCES / EPERM 提示别用 sudo + chown(king 定五)
239
+ export function explainError(e) {
240
+ const code = e?.code;
241
+ if (code === "EACCES" || code === "EPERM") {
242
+ const p = e.path ? String(e.path) : runnerHome();
243
+ const dir = p.startsWith(runnerHome()) ? runnerHome() : path.dirname(p);
244
+ return `权限不够,写不了 ${p}:别用 sudo 运行这个命令。如果以前用 sudo 跑过,先运行 sudo chown -R "$(whoami)" "${dir}",再重新运行。`;
245
+ }
246
+ return e?.message ?? String(e);
135
247
  }
136
248
 
137
249
  const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
@@ -143,10 +255,26 @@ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
143
255
  // @意图#R640(2026-10-27): 目录按 bot 分:call 按 bot_id 查对照表,没有就拒绝 NO_DIR;pick_dir 带 bot_id,选完检查后记进对照表、写回配置(600),
144
256
  // 回 dir_result {id, ok, bot_id, dir};老配置的单一 dir 忽略
145
257
  // @意图#R639(2026-10-27已废弃): 目录不再是连接时固定的常量;没目录时 call 一律拒绝;pick_dir 选完检查、写回配置(600)、回 dir_result(R640 改成按 bot 分)
146
- export function createSession(cfg, { log = say, pickDir = pickDirMac, platform = process.platform, file = configPath() } = {}) {
258
+ // @意图#R690(2026-11-01): write / edit 带 change_id(服务器记灰字时生成的 auto_id)就在写改成功后存一份改动记录(存失败只记日志);
259
+ // 新消息 change_get {id, change_id} → change_result {id, ok:true, found, record},不看这个 bot 有没有目录(换了目录也能看旧记录)
260
+ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform = process.platform, file = configPath(), changes } = {}) {
147
261
  let dirs = botDirsOf(cfg); // bot_id → 授权目录
148
262
  const jobs = new Map(); // 调用 id → AbortController
149
263
  const picks = new Map(); // 弹窗 id → AbortController
264
+ let store = changes; // 改动记录(R690),用到时才建
265
+ const changeStore = () => (store ??= createChangeStore(changesDirFor(file), { log }));
266
+ const recorder = (args) => {
267
+ const id = typeof args?.change_id === "string" ? args.change_id : "";
268
+ if (!CHANGE_ID_RE.test(id)) return undefined;
269
+ return async (info) => {
270
+ try { await changeStore().save(id, buildChangeRecord({ id, ...info })); } catch (e) { log(`改动记录没存上(不影响这次写改):${e?.message ?? e}`); }
271
+ };
272
+ };
273
+
274
+ async function handleChangeGet(send, msg) {
275
+ const record = await changeStore().get(msg.change_id).catch(() => null);
276
+ send({ type: "change_result", id: msg.id, ok: true, found: !!record, ...(record ? { record } : {}) });
277
+ }
150
278
 
151
279
  async function handleCall(send, msg) {
152
280
  const { id, op, args } = msg;
@@ -157,7 +285,8 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
157
285
  jobs.set(id, ac);
158
286
  log(`[${bot}] ${op === "bash" ? `bash:${args?.command}` : `${op}:${args?.path}`}`);
159
287
  try {
160
- const res = await runOp(op, args, dir, { signal: ac.signal, onData: (d) => send({ type: "output", id, data: Buffer.from(d).toString("base64") }) });
288
+ const onChange = op === "write" || op === "edit" ? recorder(args) : undefined;
289
+ const res = await runOp(op, args, dir, { signal: ac.signal, onChange, onData: (d) => send({ type: "output", id, data: Buffer.from(d).toString("base64") }) });
161
290
  if (ac.signal.aborted) send({ type: "result", id, ok: false, error: "已中止" });
162
291
  else send({ type: "result", id, ok: true, ...res });
163
292
  } catch (e) {
@@ -196,9 +325,12 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
196
325
 
197
326
  return {
198
327
  get botDirs() { return { ...dirs }; },
328
+ /** R690:清掉 30 天前的改动记录(startRunner 启动时调一次) */
329
+ cleanChanges() { return changeStore().clean(true).catch(() => 0); },
199
330
  onMessage(msg, send) {
200
331
  if (msg.type === "call") return handleCall(send, msg);
201
332
  if (msg.type === "pick_dir") return handlePick(send, msg);
333
+ if (msg.type === "change_get") return handleChangeGet(send, msg);
202
334
  if (msg.type === "abort") { jobs.get(msg.id)?.abort(); picks.get(msg.id)?.abort(); }
203
335
  return undefined;
204
336
  },
@@ -215,12 +347,17 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
215
347
  * 连服务器并执行转发来的工具调用。断线会自动重连(不重放旧调用);令牌不对或网页上断开了就停。
216
348
  * 返回 { stop, done }:stop() 立刻断开并杀掉正在跑的命令。
217
349
  */
350
+ // @意图#R666(2026-10-29): hello 多报 runner_version(网页提示有新版本)和 autostart(是不是开机自启起的,网页据此给升级命令);
351
+ // 服务器用 4409 顶掉这条(同一份配置又开了一个):不再重连、结束("replaced");连不上时只在第一次和之后每 30 次打一行,开机自启没网时日志不刷屏;
352
+ // 连上那句不再说「右栏」,开机自启时不说「按 Ctrl-C」
218
353
  // @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
219
354
  // @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
220
355
  // @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
221
- export function startRunner(cfg, { log = say, retryMs = 2000, pickDir = pickDirMac, platform = process.platform, file = configPath() } = {}) {
356
+ export function startRunner(cfg, { log = say, retryMs = 2000, pickDir = pickDirMac, platform = process.platform, file = configPath(), background = process.env.AGENT_RUNNER_LAUNCHD === "1" } = {}) {
222
357
  checkNode();
358
+ let fails = 0;
223
359
  const session = createSession(cfg, { log, pickDir, platform, file });
360
+ void session.cleanChanges(); // R690:启动时清一次 30 天前的改动记录
224
361
  let ws = null;
225
362
  let stopped = false;
226
363
  let beat = null;
@@ -235,16 +372,18 @@ export function startRunner(cfg, { log = say, retryMs = 2000, pickDir = pickDirM
235
372
  ws = sock;
236
373
  sock.addEventListener("open", () => {
237
374
  send(sock, { type: "hello", version: PROTOCOL_VERSION, device_id: cfg.device_id, token: cfg.token, bot_dirs: session.botDirs,
238
- name: cfg.name ?? os.hostname(), os: `${process.platform} ${os.release()}` });
375
+ name: cfg.name ?? os.hostname(), os: `${process.platform} ${os.release()}`, runner_version: RUNNER_VERSION, autostart: background });
239
376
  beat = setInterval(() => send(sock, { type: "ping" }), HEARTBEAT_MS);
240
377
  });
241
378
  sock.addEventListener("message", (ev) => {
242
379
  let msg;
243
380
  try { msg = JSON.parse(typeof ev.data === "string" ? ev.data : Buffer.from(ev.data).toString("utf8")); } catch { return; }
244
381
  if (msg.type === "ready") {
382
+ fails = 0;
245
383
  const n = Object.keys(session.botDirs).length;
246
- log(n ? `已连上服务器 ${cfg.server},${n} 个 bot 选了目录。按 Ctrl-C 断开。`
247
- : `已连上服务器 ${cfg.server},还没有 bot 选目录:请在每个 bot 的私聊右栏「我的电脑」里点「选择目录」。按 Ctrl-C 断开。`);
384
+ const tail = background ? "(开机自启,在后台运行)" : "按 Ctrl-C 断开。";
385
+ log(n ? `已连上服务器 ${cfg.server},${n} 个 bot 选了目录。${tail}`
386
+ : `已连上服务器 ${cfg.server},还没有 bot 选目录:请在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」。${tail}`);
248
387
  } else if (msg.type === "revoked") { session.killAll(); log("网页上已断开这台电脑,令牌已作废。要再连,请在网页上重新拿配对码配对。"); }
249
388
  else void session.onMessage(msg, (m) => send(sock, m));
250
389
  });
@@ -255,7 +394,9 @@ export function startRunner(cfg, { log = say, retryMs = 2000, pickDir = pickDirM
255
394
  if (stopped) return;
256
395
  if (ev.code === CLOSE_BAD_TOKEN) { log("服务器不认这个令牌(可能已经在网页上断开过)。请重新配对。"); finish("bad_token"); return; }
257
396
  if (ev.code === CLOSE_REVOKED) { log("这台电脑已在网页上断开。请重新配对。"); finish("revoked"); return; }
258
- log(`和服务器的连接断了,${retryMs / 1000} 秒后重连`);
397
+ if (ev.code === CLOSE_REPLACED) { log("同一份配置在别处又开了一个运行器,服务器把这个顶掉了:这个先退出(不再重连)。"); finish("replaced"); return; }
398
+ fails += 1;
399
+ if (fails === 1 || fails % 30 === 0) log(`和服务器的连接断了,${retryMs / 1000} 秒后重连${fails > 1 ? `(已经连不上 ${fails} 次,会一直重试)` : ""}`);
259
400
  setTimeout(connect, retryMs);
260
401
  });
261
402
  sock.addEventListener("error", () => { /* close 里处理 */ });
@@ -272,43 +413,198 @@ export function startRunner(cfg, { log = say, retryMs = 2000, pickDir = pickDirM
272
413
  return { stop, done };
273
414
  }
274
415
 
416
+ /** R666:同一份配置只能跑一个:配置旁边的 <配置>.lock 记着进程号 */
417
+ export const lockPath = (file = configPath()) => `${file}.lock`;
418
+ export function readLock(file = configPath()) {
419
+ try { const j = JSON.parse(readFileSync(lockPath(file), "utf8")); return Number.isInteger(j?.pid) ? j : null; } catch { return null; }
420
+ }
421
+ export const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; } };
422
+ /**
423
+ * 拿锁:别的活着的进程拿着就返回 { ok: false, pid, launchd };否则写上自己(600)返回 { ok: true, release }。
424
+ * 进程死了留下的旧锁直接覆盖。
425
+ */
426
+ // @意图#R666(2026-10-29): 防互踢(king 定必须做):开机自启的在后台跑着,再在终端里 start 就提示「已经在后台连着」退出,不再两个互相顶
427
+ export function takeLock(file = configPath(), { pid = process.pid, launchd = process.env.AGENT_RUNNER_LAUNCHD === "1" } = {}) {
428
+ const cur = readLock(file);
429
+ if (cur && cur.pid !== pid && pidAlive(cur.pid)) return { ok: false, pid: cur.pid, launchd: !!cur.launchd };
430
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
431
+ writeFileSync(lockPath(file), `${JSON.stringify({ pid, launchd, started_at: new Date().toISOString() })}\n`, { mode: 0o600 });
432
+ return { ok: true, release: () => { if (readLock(file)?.pid === pid) rmSync(lockPath(file), { force: true }); } };
433
+ }
434
+
435
+ /** 被网页断开 / 令牌失效后把这份配置的开机自启关掉(不然每次登录都白起一次);不是 Mac、没开都不要紧 */
436
+ async function autostartOffQuietly(file, deps) {
437
+ if ((deps.platform ?? process.platform) !== "darwin") return false;
438
+ try { return (await autostartOff({ configFile: file, launchctl: deps.launchctl })).wasOn; } catch { return false; }
439
+ }
440
+
441
+ // @意图#R666(2026-10-29): 先拿锁(后台已在跑就提示退出 0);开机自启时启动前日志超 5MB 清空、配置坏了/没配对也退出 0(不让 launchd 反复拉起);
442
+ // 结束一律退出码 0(Ctrl-C、被网页断开、令牌失效、被顶掉),被断开 / 令牌失效时顺手关掉自启
275
443
  // @意图#R663(2026-10-29): 前台跑 start,Ctrl-C 断开(pair --start 和 start 共用)
276
- async function runStart() {
277
- const cfg = loadConfig();
278
- const r = startRunner(cfg);
279
- const quit = () => { r.stop(); setTimeout(() => process.exit(0), 100); };
444
+ async function runStart(prog = progName(), deps = {}) {
445
+ const file = configPath();
446
+ const background = process.env.AGENT_RUNNER_LAUNCHD === "1";
447
+ if (background && truncateLogIfBig(logFileFor(file))) say("日志超过 5MB,已清空");
448
+ let cfg;
449
+ try { cfg = loadConfig(file); } catch (e) {
450
+ if (background) { console.error(explainError(e)); process.exit(0); }
451
+ throw e;
452
+ }
453
+ const lock = takeLock(file);
454
+ if (!lock.ok) {
455
+ console.log(`已经在后台连着,不用再开(进程 ${lock.pid}${lock.launchd ? ",开机自启起的" : ""})。要停掉它:${prog} stop`);
456
+ process.exit(0);
457
+ }
458
+ const r = startRunner(cfg, { file, background });
459
+ const quit = () => { r.stop(); lock.release(); setTimeout(() => process.exit(0), 100); };
280
460
  process.on("SIGINT", quit);
281
461
  process.on("SIGTERM", quit);
282
462
  const why = await r.done;
283
- process.exit(why === "stopped" ? 0 : 1);
463
+ lock.release();
464
+ if (why === "bad_token" || why === "revoked") {
465
+ if (await autostartOffQuietly(file, deps)) say("已关掉开机自启(这台电脑在网页上断开了)。");
466
+ }
467
+ process.exit(0);
468
+ }
469
+
470
+ /** R666:配完要不要开机自启:flag(--autostart / --no-autostart)优先;不是 Mac 不开;是终端就问;不是终端(脚本、管道)不问、只提示 */
471
+ export function decideAutostart({ flag, platform = process.platform, tty = !!(process.stdin.isTTY && process.stdout.isTTY) }) {
472
+ if (flag === true) return platform === "darwin" ? "on" : "unsupported";
473
+ if (flag === false) return "off";
474
+ if (platform !== "darwin") return "off";
475
+ return tty ? "ask" : "hint";
284
476
  }
285
477
 
478
+ /** 终端里问一句 是/否;直接回车 = dflt */
479
+ export async function askYesNo(question, dflt = true, { input = process.stdin, output = process.stdout } = {}) {
480
+ const rl = readline.createInterface({ input, output });
481
+ try {
482
+ const a = (await rl.question(question)).trim().toLowerCase();
483
+ if (!a) return dflt;
484
+ return ["y", "yes", "是", "好", "要"].includes(a);
485
+ } finally { rl.close(); }
486
+ }
487
+
488
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
489
+ /** 停掉拿着锁的那个运行器(不是自己);等它真退出(最多 5 秒)。返回被停的锁信息,没有在跑的返回 null */
490
+ async function stopHolder(file) {
491
+ const l = readLock(file);
492
+ if (!l || l.pid === process.pid || !pidAlive(l.pid)) return null;
493
+ try { process.kill(l.pid, "SIGTERM"); } catch { /* 已经没了 */ }
494
+ for (let i = 0; i < 50 && pidAlive(l.pid); i++) await sleep(100);
495
+ return l;
496
+ }
497
+
498
+ const here = () => path.dirname(fileURLToPath(import.meta.url));
499
+ // @意图#R666(2026-10-29): 开启自启:前台有一个在跑就先停掉它(不然后台那个起来就因为锁退出了),再交给 autostart.mjs(复制自己、写 plist、加载)
500
+ async function turnAutostartOn(file, prog, deps = {}) {
501
+ const held = readLock(file);
502
+ if (held && !held.launchd) await stopHolder(file);
503
+ const r = await autostartOn({ configFile: file, version: RUNNER_VERSION, srcDir: here(), launchctl: deps.launchctl, platform: deps.platform });
504
+ console.log(`已设开机自启:以后登录这台 Mac 会自动连上,不用开终端。现在已经在后台连着,可以关掉这个终端窗口了。\n日志:${r.log}\n关掉开机自启:${prog} autostart off`);
505
+ return r;
506
+ }
507
+
508
+ const usage = (prog) => `用法:
509
+ ${prog} pair <配对码>@<服务器地址> [--no-start] [--autostart | --no-autostart]
510
+ (网页上「我的电脑」里复制的就是这一条;配完直接连上,Mac 上会问要不要开机自动连上;--no-start 只配对不连)
511
+ (老写法 ${prog} pair <配对码> --server <地址> 也能用)
512
+ ${prog} start 连上服务器(终端窗口别关;Ctrl-C 断开)
513
+ ${prog} stop 停掉在跑的运行器
514
+ ${prog} status 看配置、各 bot 的目录、在不在跑、开机自启
515
+ ${prog} autostart on|off|status Mac 开机自启(on 也用来升级到新版本)
516
+ ${prog} uninstall [--purge] 关掉自启、删掉本地副本和日志(--purge 连配置一起删)
517
+ ${prog} --version
518
+ 目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac 上会弹窗让你选。`;
519
+
520
+ // @意图#R666(2026-10-29): pair 默认配完直接 start(--no-start 只配对);配完按 decideAutostart 问 / 提示 / 开启开机自启,开了就不在前台跑;
521
+ // 新命令 stop、autostart on|off|status、uninstall、--version;status 多显示在不在跑、版本、开机自启;用法里的命令按运行方式写(npx 时写 npx 写法)
286
522
  // @意图#R664(2026-10-29): 导出给 bin/agent-hub-runner.mjs 调;用法和「现在运行:… start」里的命令名用 progName(装好的 agent-hub-runner,仓库里 node 写法)
287
- // @意图#R663(2026-10-29): pair --start 配对成功后直接进 start(二甲);提示里「我的电脑」改成在私聊输入框下面那一行点开(R663 挪了位置);不带 --start 照旧提示再运行 start
523
+ // @意图#R663(2026-10-29): pair --start 配对成功后直接进 start(二甲);提示里「我的电脑」改成在私聊输入框下面那一行点开(R663 挪了位置);不带 --start 照旧提示再运行 start(R666 起默认就 start,--no-start 才提示)
288
524
  // @意图#R640(2026-10-27): pair 带 --dir 只提示不报错;status 列出每个 bot 的目录;用法文字去掉 --dir
289
- export async function main(argv, prog = progName()) {
525
+ export async function main(argv, prog = progName(), deps = {}) {
526
+ checkNode();
290
527
  const a = parseArgs(argv);
528
+ const platform = deps.platform ?? process.platform;
291
529
  if (a.cmd === "pair") {
292
- const r = await pair({ code: a.positional[0], dir: a.dir, server: a.server ?? DEFAULT_SERVER, name: a.name ?? os.hostname() });
530
+ const r = await pair({ code: a.positional[0], dir: a.dir, server: a.server, name: a.name ?? os.hostname() });
293
531
  if (r.dirIgnored) console.log(PAIR_DIR_IGNORED);
294
532
  const where = "目录:连上后在网页上每个 bot 的私聊里,点输入框下面「我的电脑」那一行选。";
295
- if (a.start) {
296
- console.log(`配对成功。${where}\n配置存在 ${r.file}(权限 600)。\n接着连服务器(--start)……`);
297
- await runStart();
533
+ console.log(`配对成功(服务器 ${r.server})。${where}\n配置存在 ${r.file}(权限 600)。`);
534
+ const how = decideAutostart({ flag: a.autostart, platform, tty: deps.tty });
535
+ let on = how === "on";
536
+ if (how === "ask") on = await (deps.ask ?? askYesNo)("开机自动连上?以后不用开终端 (Y/n) ", true);
537
+ if (how === "unsupported") console.log(ONLY_MAC + ",这次不设。");
538
+ if (on) {
539
+ try { await turnAutostartOn(r.file, prog, deps); return; }
540
+ catch (e) { console.log(`开机自启没设成:${explainError(e)}\n这次先在前台连着(终端窗口别关)。`); }
541
+ }
542
+ if (how === "ask") console.log(`好的,不设开机自启。以后想开:${prog} autostart on`);
543
+ if (how === "hint") console.log(`想开机自动连上(不用开终端):运行 ${prog} autostart on`);
544
+ if (a.start === false) { console.log(`现在运行:${prog} start`); return; }
545
+ console.log("接着连服务器……");
546
+ await runStart(prog, deps);
547
+ return;
548
+ }
549
+ if (a.cmd === "start") { await runStart(prog, deps); return; }
550
+ if (a.cmd === "stop") {
551
+ const l = await stopHolder(configPath());
552
+ if (!l) { console.log("没有在跑的运行器。"); return; }
553
+ console.log(`已停掉(进程 ${l.pid})。${l.launchd ? `开机自启还开着,下次登录会自己连上;不想要:${prog} autostart off` : ""}`);
554
+ return;
555
+ }
556
+ if (a.cmd === "autostart") {
557
+ const sub = a.positional[0] ?? "status";
558
+ const file = configPath();
559
+ if (sub === "on") { loadConfig(file); await turnAutostartOn(file, prog, deps); return; }
560
+ if (platform !== "darwin") { console.log(ONLY_MAC); return; }
561
+ if (sub === "off") {
562
+ const r = await autostartOff({ configFile: file, launchctl: deps.launchctl });
563
+ console.log(r.wasOn ? "已关掉开机自启(后台那个也停了)。" : "本来就没开开机自启。");
298
564
  return;
299
565
  }
300
- console.log(`配对成功。${where}\n配置存在 ${r.file}(权限 600)。\n现在运行:${prog} start`);
566
+ if (sub === "status") {
567
+ const st = await autostartStatus({ configFile: file, launchctl: deps.launchctl, platform });
568
+ console.log(st.installed ? `开机自启:开着(${st.loaded ? (st.pid ? `后台在跑,进程 ${st.pid}` : "已加载,现在没在跑") : "还没加载"})\nplist:${st.plist}\n日志:${st.log}` : "开机自启:没开");
569
+ for (const p of st.problems) console.log(`注意:${p}`);
570
+ return;
571
+ }
572
+ throw new Error(`autostart 后面只能是 on、off 或 status(${sub} 不认识)`);
573
+ }
574
+ if (a.cmd === "uninstall") {
575
+ const file = configPath();
576
+ if (platform === "darwin") await autostartOff({ configFile: file, launchctl: deps.launchctl }).catch(() => {});
577
+ await stopHolder(file);
578
+ const home = runnerHome();
579
+ if (!otherAutostarts(file).length) {
580
+ rmSync(path.join(home, "bin"), { recursive: true, force: true });
581
+ rmSync(path.join(home, "logs"), { recursive: true, force: true });
582
+ }
583
+ let purge = !!a.purge;
584
+ if (!purge && existsSync(file) && (deps.tty ?? !!(process.stdin.isTTY && process.stdout.isTTY))) {
585
+ purge = await (deps.ask ?? askYesNo)("要连配对信息(令牌)一起删掉吗?删了以后要重新配对 (y/N) ", false);
586
+ }
587
+ if (purge) { rmSync(file, { force: true }); rmSync(lockPath(file), { force: true }); }
588
+ console.log(`已卸载:开机自启关了、本地副本和日志删了。${purge ? `配置也删了(${file});记得在网页「我的电脑」里点「断开」作废令牌。` : `配置留着(${file});连配置一起删:${prog} uninstall --purge`}`);
301
589
  return;
302
590
  }
303
- if (a.cmd === "start") await runStart();
304
591
  if (a.cmd === "status") {
305
- const cfg = loadConfig();
592
+ const file = configPath();
593
+ const cfg = loadConfig(file);
306
594
  const d = Object.entries(botDirsOf(cfg));
307
- console.log(`服务器:${cfg.server}\n设备:${cfg.device_id}\n各 bot 的授权目录:${d.length ? d.map(([b, p]) => `\n ${b}:${p}`).join("") : "(还没有,start 连上后在每个 bot 的私聊里点「选择目录」)"}`);
595
+ const l = readLock(file);
596
+ const run = l && pidAlive(l.pid) ? `在跑(进程 ${l.pid}${l.launchd ? ",开机自启" : ""})` : "没在跑";
597
+ let auto = "";
598
+ if (platform === "darwin") {
599
+ const st = await autostartStatus({ configFile: file, launchctl: deps.launchctl, platform }).catch(() => null);
600
+ auto = `\n开机自启:${st?.installed ? "开着" : "没开"}${st?.problems?.length ? `(${st.problems.join(";")})` : ""}`;
601
+ }
602
+ 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 的私聊里点「选择目录」)"}`);
308
603
  return;
309
604
  }
310
- console.log(`用法:\n ${prog} pair <配对码> [--start] [--server http://127.0.0.1:3220]\n (--start:配对成功后直接连上,不用再跑 start;目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac 上会弹窗让你选)\n ${prog} start\n ${prog} status`);
311
- process.exitCode = a.cmd ? 1 : 0;
605
+ if (a.cmd === "version") { console.log(RUNNER_VERSION); return; }
606
+ console.log(usage(prog));
607
+ process.exitCode = a.cmd && a.cmd !== "help" ? 1 : 0;
312
608
  }
313
609
 
314
610
  const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
@@ -323,15 +619,26 @@ export function isMainModule(argv1 = process.argv[1], metaUrl = import.meta.url)
323
619
  * 用法和提示里怎么称呼自己:npm 装好的(经 bin 里的 agent-hub-runner 链接运行,或文件在 node_modules 里)说 agent-hub-runner,
324
620
  * 仓库里直接跑说 node <相对路径>。
325
621
  */
622
+ // @意图#R666(2026-10-29): npx 跑的(npm 设 npm_lifecycle_event=npx,或文件在 npx 缓存 _npx 目录里)和开机自启的本地副本(~/.agent-runner/bin 下)都写 npx 写法:
623
+ // 这两种情况电脑上都没有 agent-hub-runner 这个命令
326
624
  // @意图#R664(2026-10-29): 用法和配对成功提示按运行方式区分:装好的显示 agent-hub-runner …,仓库里显示 node 写法
327
- export function progName(entry = process.argv[1], cwd = process.cwd()) {
625
+ export function progName(entry = process.argv[1], cwd = process.cwd(), env = process.env) {
328
626
  if (!entry) return PKG_BIN;
329
- if (path.basename(entry) === PKG_BIN) return PKG_BIN;
627
+ if (isNpx(entry, env)) return NPX_CMD;
330
628
  const r = real(entry);
629
+ const bin = real(path.join(runnerHome(env), "bin"));
630
+ if (r === bin || r.startsWith(`${bin}${path.sep}`)) return NPX_CMD;
631
+ if (path.basename(entry) === PKG_BIN) return PKG_BIN;
331
632
  if (/[\\/]node_modules[\\/]/.test(r)) return PKG_BIN;
332
633
  return `node ${path.relative(cwd, r) || path.basename(r)}`;
333
634
  }
635
+ /** 是不是 npx 跑起来的 */
636
+ export function isNpx(entry = process.argv[1], env = process.env) {
637
+ if (env.npm_lifecycle_event === "npx") return true;
638
+ return !!entry && /[\\/]_npx[\\/]/.test(real(entry));
639
+ }
334
640
 
641
+ // @意图#R666(2026-10-29): 出错时打大白话(权限不够单独说)
335
642
  if (isMainModule()) {
336
- main(process.argv.slice(2)).catch((e) => { console.error(e?.message ?? String(e)); process.exit(1); });
643
+ main(process.argv.slice(2)).catch((e) => { console.error(explainError(e)); process.exit(1); });
337
644
  }