agent-hub-runner 0.1.2 → 0.1.4
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/autostart.mjs +112 -3
- package/ops.mjs +44 -7
- package/package.json +27 -5
- package/runner.mjs +107 -20
package/autostart.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
12
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync } from "node:fs";
|
|
13
|
+
// @意图#R725(2026-11-03): Windows 开机自启用计划任务(schtasks)。和 Mac 一样:登录时拉起、崩溃了重新跑、正常退出不拉起。
|
|
13
14
|
import os from "node:os";
|
|
14
15
|
import path from "node:path";
|
|
15
16
|
|
|
@@ -21,7 +22,9 @@ export const THROTTLE_S = 10;
|
|
|
21
22
|
export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "autostart.mjs", "package.json"];
|
|
22
23
|
/** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
|
|
23
24
|
export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
|
|
24
|
-
export const ONLY_MAC = "开机自启目前只支持 Mac";
|
|
25
|
+
export const ONLY_MAC = "开机自启目前只支持 Mac 和 Windows";
|
|
26
|
+
// @意图#R725(2026-11-03): Windows 计划任务名前缀。默认配置是 AgentHubRunner,别的配置加路径 sha256 前 8 位,一份配置一个任务。
|
|
27
|
+
export const TASK_PREFIX = "AgentHubRunner";
|
|
25
28
|
|
|
26
29
|
const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
|
|
27
30
|
|
|
@@ -87,8 +90,10 @@ ${envXml}
|
|
|
87
90
|
`;
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
// @意图#R725(2026-11-03): Windows 建软链要管理员权限(或开开发者模式),小白没有。所以 Windows 用一个 current.txt 记当前版本号,
|
|
94
|
+
// 包装脚本读它来找要跑的 runner.mjs。Mac 仍用 current 软链(先建临时链再改名,换的那一下不会断)。
|
|
95
|
+
/** 把运行器复制到 <home>/bin/<版本>/。返回要跑的 runner.mjs 路径。 */
|
|
96
|
+
export function installCopy({ srcDir, version, env = process.env, platform = process.platform }) {
|
|
92
97
|
const binRoot = path.join(runnerHome(env), "bin");
|
|
93
98
|
const dest = path.join(binRoot, version);
|
|
94
99
|
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
@@ -98,6 +103,10 @@ export function installCopy({ srcDir, version, env = process.env }) {
|
|
|
98
103
|
if (existsSync(from)) copyFileSync(from, path.join(dest, f));
|
|
99
104
|
}
|
|
100
105
|
}
|
|
106
|
+
if (platform === "win32") {
|
|
107
|
+
writeFileSync(path.join(binRoot, "current.txt"), version);
|
|
108
|
+
return path.join(dest, "runner.mjs");
|
|
109
|
+
}
|
|
101
110
|
const cur = path.join(binRoot, "current");
|
|
102
111
|
const tmp = path.join(binRoot, `.current-${process.pid}`);
|
|
103
112
|
rmSync(tmp, { force: true });
|
|
@@ -179,6 +188,106 @@ export function otherAutostarts(configFile, env = process.env) {
|
|
|
179
188
|
try { return readdirSync(launchAgentsDir(env)).filter((f) => f.startsWith(LABEL) && f.endsWith(".plist") && f !== mine); } catch { return []; }
|
|
180
189
|
}
|
|
181
190
|
|
|
191
|
+
// @意图#R725(2026-11-03): Windows 开机自启用计划任务,语义对齐 Mac 的 launchd:
|
|
192
|
+
// - 触发器是「登录时」(/sc onlogon),跑在用户自己的桌面会话里。不能用 SYSTEM 或开机即跑(那样在 Session 0,选目录的窗口弹不出来)。
|
|
193
|
+
// - 退出码不是 0 时重新跑(RestartCount 999、间隔 1 分钟),对齐 KeepAlive 的 SuccessfulExit=false:被网页断开、令牌失效、被顶掉都是 0 退出,不会被反复拉起;崩溃了才会重跑。
|
|
194
|
+
// - 不设 /rl highest:普通权限就够,而且提权会弹 UAC,小白配不上。
|
|
195
|
+
// - 任务跑一个 .cmd:计划任务的 /tr 不支持带空格的参数和重定向,node 装在 Program Files 里,所以包一层。日志追加到和 Mac 同一个文件。
|
|
196
|
+
const psQuote = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
197
|
+
|
|
198
|
+
/** 计划任务名:默认配置 AgentHubRunner,别的配置加 sha8 后缀(对齐 labelFor) */
|
|
199
|
+
export const taskNameFor = (configFile, env = process.env) => { const t = configTag(configFile, env); return t ? `${TASK_PREFIX}-${t}` : TASK_PREFIX; };
|
|
200
|
+
|
|
201
|
+
/** 计划任务跑的包装脚本:<home>/bin/start-<任务名>.cmd,日志追加到 logFileFor 那个文件 */
|
|
202
|
+
export function taskCmdFor(configFile, env = process.env) {
|
|
203
|
+
return path.join(runnerHome(env), "bin", `start-${taskNameFor(configFile, env)}.cmd`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// @意图#R725(2026-11-03): 不靠计划任务的「失败重拉」。实测 schtasks 的 RestartOnFailure 在被 taskkill 强杀时不重拉
|
|
207
|
+
// (任务记的结果是 1,但不再起)。所以在包装脚本里自己循环:退出码不是 0 就等 10 秒再跑,对齐 Mac 的
|
|
208
|
+
// KeepAlive SuccessfulExit=false。正常退出(被网页断开、令牌失效、Ctrl-C,都是 0)不重跑。
|
|
209
|
+
// 脚本路径不写死:从 current.txt 读当前版本号再拼(升级后不用改这个包装脚本)。binDir 是 <home>/bin。
|
|
210
|
+
/** 生成包装脚本的内容。node 是绝对路径,配置路径经环境变量传(和 Mac 的 plist 一样)。 */
|
|
211
|
+
export function taskCmdText({ node, binDir, configFile, log }) {
|
|
212
|
+
return [
|
|
213
|
+
"@echo off",
|
|
214
|
+
"setlocal EnableDelayedExpansion",
|
|
215
|
+
`set AGENT_RUNNER_CONFIG=${configFile}`,
|
|
216
|
+
"set AGENT_RUNNER_LAUNCHD=1",
|
|
217
|
+
":loop",
|
|
218
|
+
"set /p VER=<\"" + binDir + "\\current.txt\"",
|
|
219
|
+
"\"" + node + "\" \"" + binDir + "\\!VER!\\runner.mjs\" start >> \"" + log + "\" 2>&1",
|
|
220
|
+
"if %ERRORLEVEL%==0 goto end",
|
|
221
|
+
"timeout /t 10 /nobreak > nul",
|
|
222
|
+
"goto loop",
|
|
223
|
+
":end",
|
|
224
|
+
"",
|
|
225
|
+
].join("\r\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 调 schtasks,返回 { code, stdout, stderr },不抛错。AGENT_RUNNER_SCHTASKS 可换成假程序(测试用)。输出是 GBK,转成 UTF-8。 */
|
|
229
|
+
export function runSchtasks(args, env = process.env) {
|
|
230
|
+
return new Promise((resolve) => {
|
|
231
|
+
execFile(env.AGENT_RUNNER_SCHTASKS || "schtasks", args, { encoding: "buffer" }, (err, stdout, stderr) => {
|
|
232
|
+
const dec = (b) => { try { return new TextDecoder("gbk").decode(b); } catch { return String(b ?? ""); } };
|
|
233
|
+
resolve({ code: err ? (typeof err.code === "number" ? err.code : 1) : 0, stdout: dec(stdout), stderr: dec(stderr) });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 开启开机自启(Windows):复制自己、写包装脚本、建计划任务(登录时跑,失败重跑),马上起一次。返回 { task, cmd, node, log }。 */
|
|
239
|
+
export async function autostartOnWindows({ configFile, version, srcDir, execPath = process.execPath, env = process.env, schtasks }) {
|
|
240
|
+
schtasks ??= (a) => runSchtasks(a, env);
|
|
241
|
+
const cfg = path.resolve(configFile);
|
|
242
|
+
if (!existsSync(cfg)) throw new Error(`还没配对:没有 ${cfg},配对成功后才能设开机自启`);
|
|
243
|
+
const script = installCopy({ srcDir, version, env, platform: "win32" });
|
|
244
|
+
const node = pickNode(execPath);
|
|
245
|
+
const log = logFileFor(cfg, env);
|
|
246
|
+
mkdirSync(path.dirname(log), { recursive: true });
|
|
247
|
+
const cmd = taskCmdFor(cfg, env);
|
|
248
|
+
writeFileSync(cmd, taskCmdText({ node, binDir: path.join(runnerHome(env), "bin"), configFile: cfg, log }));
|
|
249
|
+
const name = taskNameFor(cfg, env);
|
|
250
|
+
const user = env.USERNAME;
|
|
251
|
+
if (!user) throw new Error("开机自启没设成:读不到当前用户名");
|
|
252
|
+
const args = ["/create", "/tn", name, "/tr", cmd, "/sc", "onlogon", "/ru", user, "/rl", "limited", "/f"];
|
|
253
|
+
const r = await schtasks(args);
|
|
254
|
+
if (r.code !== 0) throw new Error(`开机自启没设成:计划任务创建失败(${(r.stderr || r.stdout).trim() || r.code})`);
|
|
255
|
+
// 崩溃重跑由包装脚本自己的循环做(见 taskCmdText)。这里只补「用电池也跑」,笔记本合盖不中断。
|
|
256
|
+
const ps = `$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0); Set-ScheduledTask -TaskName ${psQuote(name)} -Settings $s | Out-Null`;
|
|
257
|
+
await new Promise((resolve) => execFile("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], () => resolve()));
|
|
258
|
+
await schtasks(["/run", "/tn", name]); // 马上起一次,不用等下次登录
|
|
259
|
+
return { task: name, cmd, node, log };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 关掉开机自启(Windows):停掉并删计划任务、删包装脚本。返回 { wasOn }。 */
|
|
263
|
+
export async function autostartOffWindows({ configFile, env = process.env, schtasks }) {
|
|
264
|
+
schtasks ??= (a) => runSchtasks(a, env);
|
|
265
|
+
const cfg = path.resolve(configFile);
|
|
266
|
+
const name = taskNameFor(cfg, env);
|
|
267
|
+
const cmd = taskCmdFor(cfg, env);
|
|
268
|
+
const wasOn = existsSync(cmd);
|
|
269
|
+
await schtasks(["/end", "/tn", name]);
|
|
270
|
+
await schtasks(["/delete", "/tn", name, "/f"]);
|
|
271
|
+
rmSync(cmd, { force: true });
|
|
272
|
+
return { wasOn };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 开机自启现在的样子(Windows):有没有包装脚本、任务在不在、状态。 */
|
|
276
|
+
export async function autostartStatusWindows({ configFile, env = process.env, schtasks }) {
|
|
277
|
+
schtasks ??= (a) => runSchtasks(a, env);
|
|
278
|
+
const cfg = path.resolve(configFile);
|
|
279
|
+
const name = taskNameFor(cfg, env);
|
|
280
|
+
const cmd = taskCmdFor(cfg, env);
|
|
281
|
+
const out = { supported: true, installed: existsSync(cmd), loaded: false, pid: null, task: name, cmd, log: logFileFor(cfg, env), problems: [] };
|
|
282
|
+
const r = await schtasks(["/query", "/tn", name, "/fo", "csv"]);
|
|
283
|
+
if (r.code === 0) {
|
|
284
|
+
out.loaded = true;
|
|
285
|
+
const row = r.stdout.split(/\r?\n/).find((l) => l.includes(name));
|
|
286
|
+
if (row && /running/i.test(row)) out.pid = 0; // 计划任务查不到进程号,0 表示在跑但不知道是哪个
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
182
291
|
/** 日志超过 max 字节就清空(launchd 用追加方式打开,清空后接着往后写不会乱)。清了返回 true。 */
|
|
183
292
|
export function truncateLogIfBig(file, max = LOG_MAX_BYTES) {
|
|
184
293
|
try {
|
package/ops.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径):
|
|
2
2
|
// 先按授权目录换算成真实路径(解开符号链接,包括指向不存在目标的符号链接),不在授权目录里的一律拒绝;bash 的工作目录就是授权目录。
|
|
3
3
|
// 第一轮没有沙箱(king 定的范围):bash 里的命令本身能碰到目录外的东西,只能靠你看清批准卡片再点。
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
@@ -68,7 +68,7 @@ function imageMime(buf) {
|
|
|
68
68
|
export async function opRead(args, root) {
|
|
69
69
|
const real = await resolveInside(args.path, root);
|
|
70
70
|
const st = await fs.stat(real);
|
|
71
|
-
if (st.isDirectory()) throw new Error(`${args.path} 是目录,不是文件(看目录里有什么用 bash 的 ls)`);
|
|
71
|
+
if (st.isDirectory()) throw new Error(`${args.path} 是目录,不是文件(看目录里有什么用 ${process.platform === "win32" ? "PowerShell 的 Get-ChildItem" : "bash 的 ls"})`);
|
|
72
72
|
if (st.size > MAX_READ_BYTES) throw new Error(`${args.path} 太大(${st.size} 字节),超过 ${MAX_READ_BYTES} 字节;用 bash 的 head/sed 看一部分`);
|
|
73
73
|
const buf = await fs.readFile(real);
|
|
74
74
|
const mime = imageMime(buf);
|
|
@@ -102,18 +102,54 @@ export async function opEdit(args, root, { onChange } = {}) {
|
|
|
102
102
|
return { replaced: args.edits.length };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function killGroup(child) {
|
|
105
|
+
function killGroup(child, platform = process.platform) {
|
|
106
106
|
if (!child.pid) return;
|
|
107
|
+
// @意图#R721(2026-11-02): Windows 没有负进程组,要 taskkill /T /F 才能连子进程一起杀
|
|
108
|
+
if (platform === "win32") {
|
|
109
|
+
try { spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); } catch { /* 进程已经没了 */ }
|
|
110
|
+
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
107
113
|
try { process.kill(-child.pid, "SIGKILL"); } catch { /* 进程组已经没了 */ }
|
|
108
114
|
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
/**
|
|
118
|
+
* 按平台选执行器。
|
|
119
|
+
* Windows 上没有 bash:用 PowerShell,整段脚本转 UTF-16LE base64 传给 -EncodedCommand。
|
|
120
|
+
* 这样一次解决两个实现时踩到的坑:① 中文参数走 -Command 会按 ANSI 代码页解码成乱码;
|
|
121
|
+
* ② powershell -Command 的退出码恒为 0,得靠脚本尾巴的 exit $LASTEXITCODE 才拿得到真实结果。
|
|
122
|
+
*/
|
|
123
|
+
// @意图#R721(2026-11-02): Windows 用 PowerShell 跑命令(客户是小白,不能要求装 Git Bash)
|
|
124
|
+
export function shellFor(command, platform = process.platform) {
|
|
125
|
+
if (platform === "win32") {
|
|
126
|
+
// $ProgressPreference 必须关:不加的话 PowerShell 会把进度流序列化成 #< CLIXML ... 写到 stderr,模型会看到一堆垃圾。
|
|
127
|
+
// `2>&1 | ForEach-Object { "$_" }`:错误也要转成纯字符串,否则 ErrorRecord 会被序列化成 CLIXML;
|
|
128
|
+
// 用管道逐条处理,保持边跑边输出(不缓冲)。
|
|
129
|
+
const script = [
|
|
130
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
131
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
132
|
+
"$OutputEncoding=[Text.Encoding]::UTF8",
|
|
133
|
+
"& {",
|
|
134
|
+
command,
|
|
135
|
+
'} 2>&1 | ForEach-Object { "$_" }',
|
|
136
|
+
"exit $LASTEXITCODE",
|
|
137
|
+
"",
|
|
138
|
+
].join("\n");
|
|
139
|
+
return {
|
|
140
|
+
file: "powershell.exe",
|
|
141
|
+
args: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { file: "bash", args: ["-c", command] };
|
|
145
|
+
}
|
|
146
|
+
|
|
111
147
|
/**
|
|
112
148
|
* bash:在授权目录里用你终端的环境变量跑(服务器的环境变量不下发)。输出边跑边交给 onData;
|
|
113
149
|
* 超时或 signal 中止时杀掉整个进程组。返回 { exitCode, timedOut, aborted }。
|
|
114
150
|
*/
|
|
115
151
|
// @意图#R636(2026-10-27): 跑命令(已经过批准,只执行这一次),有超时,超时杀进程
|
|
116
|
-
export function opBash(args, root, { onData, signal } = {}) {
|
|
152
|
+
export function opBash(args, root, { onData, signal, platform = process.platform } = {}) {
|
|
117
153
|
const command = args.command;
|
|
118
154
|
if (typeof command !== "string" || !command.trim()) return Promise.reject(new Error("command 是空的"));
|
|
119
155
|
let timeoutS = DEFAULT_BASH_TIMEOUT_S;
|
|
@@ -122,12 +158,13 @@ export function opBash(args, root, { onData, signal } = {}) {
|
|
|
122
158
|
if (!Number.isFinite(t) || t <= 0) return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
|
|
123
159
|
timeoutS = t;
|
|
124
160
|
}
|
|
161
|
+
const shell = shellFor(command, platform);
|
|
125
162
|
return new Promise((resolve, reject) => {
|
|
126
|
-
const child = spawn(
|
|
163
|
+
const child = spawn(shell.file, shell.args, { cwd: root, detached: platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env, windowsHide: true });
|
|
127
164
|
let timedOut = false;
|
|
128
165
|
let aborted = false;
|
|
129
|
-
const timer = setTimeout(() => { timedOut = true; killGroup(child); }, timeoutS * 1000);
|
|
130
|
-
const onAbort = () => { aborted = true; killGroup(child); };
|
|
166
|
+
const timer = setTimeout(() => { timedOut = true; killGroup(child, platform); }, timeoutS * 1000);
|
|
167
|
+
const onAbort = () => { aborted = true; killGroup(child, platform); };
|
|
131
168
|
if (signal) {
|
|
132
169
|
if (signal.aborted) onAbort();
|
|
133
170
|
else signal.addEventListener("abort", onAbort, { once: true });
|
package/package.json
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准)",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-hub-runner": "bin/agent-hub-runner.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"runner.mjs",
|
|
11
|
+
"ops.mjs",
|
|
12
|
+
"edit.mjs",
|
|
13
|
+
"changes.mjs",
|
|
14
|
+
"autostart.mjs",
|
|
15
|
+
"bin/agent-hub-runner.mjs",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/cs1145994973/agent-hub-runner.git"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
}
|
|
10
32
|
}
|
package/runner.mjs
CHANGED
|
@@ -23,11 +23,11 @@ import fs from "node:fs/promises";
|
|
|
23
23
|
import os from "node:os";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import readline from "node:readline/promises";
|
|
26
|
-
import { execFile } from "node:child_process";
|
|
26
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { runOp } from "./ops.mjs";
|
|
29
29
|
import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
|
|
30
|
-
import { autostartOff, autostartOn, autostartStatus, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
|
|
30
|
+
import { autostartOff, autostartOffWindows, autostartOn, autostartOnWindows, autostartStatus, autostartStatusWindows, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
|
|
31
31
|
|
|
32
32
|
export const PROTOCOL_VERSION = 1;
|
|
33
33
|
/** npm 包名,也是装好后的命令名 */
|
|
@@ -98,10 +98,19 @@ function checkNode() {
|
|
|
98
98
|
|
|
99
99
|
export const PICK_PROMPT = "选择要授权给 bot 的文件夹";
|
|
100
100
|
export const PICK_SCRIPT = `POSIX path of (choose folder with prompt "${PICK_PROMPT}")`;
|
|
101
|
+
/** Windows 上的弹窗脚本:系统自带的 .NET 文件夹选择框,不需要任何额外安装。 */
|
|
102
|
+
export const PICK_SCRIPT_WIN = [
|
|
103
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
104
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
105
|
+
"$d = New-Object System.Windows.Forms.FolderBrowserDialog",
|
|
106
|
+
`$d.Description = '${PICK_PROMPT}'`,
|
|
107
|
+
"$d.ShowNewFolderButton = $true",
|
|
108
|
+
"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }",
|
|
109
|
+
].join("\n");
|
|
101
110
|
/** R640:这个 bot 在对照表里没有目录时,调用一律回这句。 */
|
|
102
111
|
// @意图#R666(2026-10-29): 「右栏」是 R663 以前的位置,改成输入框下面「我的电脑」那一行(和服务器 devices.ts 的 NO_DIR 一字不差)
|
|
103
112
|
export const NO_DIR = "还没给这个 bot 选目录,请在这个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」";
|
|
104
|
-
export const PICK_ONLY_MAC = "只支持 Mac:选目录的窗口只在 Mac 上弹";
|
|
113
|
+
export const PICK_ONLY_MAC = "只支持 Mac 和 Windows:选目录的窗口只在 Mac / Windows 上弹";
|
|
105
114
|
/** R640:pair 带了 --dir 时的提示(不报错,照常配对,目录不用)。 */
|
|
106
115
|
export const PAIR_DIR_IGNORED = "目录请连上后在每个 bot 的私聊里选(--dir 已不用,这次没用它)";
|
|
107
116
|
|
|
@@ -138,6 +147,43 @@ export function pickDirMac({ signal } = {}) {
|
|
|
138
147
|
|
|
139
148
|
/** 检查授权目录:存在、换成真实路径、是目录、不是磁盘根目录。返回真实路径。 */
|
|
140
149
|
// @意图#R640(2026-10-27): 每个 bot 选的目录都走这套检查
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Windows 上弹系统「选文件夹」窗口(.NET FolderBrowserDialog,系统自带,不用装任何东西)。
|
|
153
|
+
* 选了返回路径;点取消返回 null;signal 中止时 taskkill 关掉窗口并抛 AbortError;弹不出来(没有桌面会话)抛错。
|
|
154
|
+
*/
|
|
155
|
+
// @意图#R716(2026-11-02): Windows 客户是小白,不能要求装 Git Bash,也不能靠手填路径;弹窗用系统自带的 .NET 窗口
|
|
156
|
+
export function pickDirWindows({ signal, spawn: spawnFn = spawn } = {}) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const b64 = Buffer.from(PICK_SCRIPT_WIN + "\n", "utf16le").toString("base64");
|
|
159
|
+
// 带 -STA:FolderBrowserDialog 要求单线程公寓;不带 -NonInteractive:要的就是交互窗口
|
|
160
|
+
const child = spawnFn("powershell.exe", ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", b64], {
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
});
|
|
164
|
+
let out = "";
|
|
165
|
+
let err = "";
|
|
166
|
+
let settled = false;
|
|
167
|
+
const settle = (fn, v) => { if (!settled) { settled = true; signal?.removeEventListener("abort", onAbort); fn(v); } };
|
|
168
|
+
const killIt = () => {
|
|
169
|
+
if (child.pid) { try { spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); } catch { /* 已退出 */ } }
|
|
170
|
+
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
171
|
+
};
|
|
172
|
+
const onAbort = () => { killIt(); settle(reject, Object.assign(new Error("已中止"), { name: "AbortError" })); };
|
|
173
|
+
if (signal) { if (signal.aborted) return onAbort(); signal.addEventListener("abort", onAbort, { once: true }); }
|
|
174
|
+
child.stdout?.on("data", (d) => { out += d.toString("utf8"); });
|
|
175
|
+
child.stderr?.on("data", (d) => { err += d.toString("utf8"); });
|
|
176
|
+
child.on("error", (e) => settle(reject, new Error(`弹不出选文件夹的窗口(${e?.message ?? e})`)));
|
|
177
|
+
child.on("close", () => {
|
|
178
|
+
const picked = out.trim();
|
|
179
|
+
if (picked) return settle(resolve, picked);
|
|
180
|
+
const why = err.trim().split("\n")[0].trim();
|
|
181
|
+
if (why) return settle(reject, new Error(`弹不出选文件夹的窗口(${why})`));
|
|
182
|
+
settle(resolve, null); // 点了取消:对话框没点确定,没有输出
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
141
187
|
// @意图#R639(2026-10-27): 配对时的 --dir 和网页上选的目录走同一套检查
|
|
142
188
|
export async function checkDir(dir) {
|
|
143
189
|
const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`授权目录不存在:${dir}`); });
|
|
@@ -257,7 +303,7 @@ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
|
|
|
257
303
|
// @意图#R639(2026-10-27已废弃): 目录不再是连接时固定的常量;没目录时 call 一律拒绝;pick_dir 选完检查、写回配置(600)、回 dir_result(R640 改成按 bot 分)
|
|
258
304
|
// @意图#R690(2026-11-01): write / edit 带 change_id(服务器记灰字时生成的 auto_id)就在写改成功后存一份改动记录(存失败只记日志);
|
|
259
305
|
// 新消息 change_get {id, change_id} → change_result {id, ok:true, found, record},不看这个 bot 有没有目录(换了目录也能看旧记录)
|
|
260
|
-
export function createSession(cfg, { log = say,
|
|
306
|
+
export function createSession(cfg, { log = say, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), changes } = {}) {
|
|
261
307
|
let dirs = botDirsOf(cfg); // bot_id → 授权目录
|
|
262
308
|
const jobs = new Map(); // 调用 id → AbortController
|
|
263
309
|
const picks = new Map(); // 弹窗 id → AbortController
|
|
@@ -300,7 +346,7 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
|
|
|
300
346
|
const { id } = msg;
|
|
301
347
|
const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
302
348
|
if (!bot) { send({ type: "dir_result", id, ok: false, error: "缺 bot_id" }); return; }
|
|
303
|
-
if (platform !== "darwin") { send({ type: "dir_result", id, ok: false, bot_id: bot, error: PICK_ONLY_MAC }); return; }
|
|
349
|
+
if (platform !== "darwin" && platform !== "win32") { send({ type: "dir_result", id, ok: false, bot_id: bot, error: PICK_ONLY_MAC }); return; }
|
|
304
350
|
const ac = new AbortController();
|
|
305
351
|
picks.set(id, ac);
|
|
306
352
|
log(`网页上给 bot ${bot} 点了选择目录:请在弹出的窗口里选文件夹`);
|
|
@@ -353,7 +399,7 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
|
|
|
353
399
|
// @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
|
|
354
400
|
// @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
|
|
355
401
|
// @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
|
|
356
|
-
export function startRunner(cfg, { log = say, retryMs = 2000,
|
|
402
|
+
export function startRunner(cfg, { log = say, retryMs = 2000, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), background = process.env.AGENT_RUNNER_LAUNCHD === "1" } = {}) {
|
|
357
403
|
checkNode();
|
|
358
404
|
let fails = 0;
|
|
359
405
|
const session = createSession(cfg, { log, pickDir, platform, file });
|
|
@@ -432,10 +478,17 @@ export function takeLock(file = configPath(), { pid = process.pid, launchd = pro
|
|
|
432
478
|
return { ok: true, release: () => { if (readLock(file)?.pid === pid) rmSync(lockPath(file), { force: true }); } };
|
|
433
479
|
}
|
|
434
480
|
|
|
435
|
-
|
|
481
|
+
// @意图#R725(2026-11-03): Windows 被断开时也关掉计划任务,不然下次登录又白起一次
|
|
482
|
+
/** 被网页断开 / 令牌失效后把这份配置的开机自启关掉(不然每次登录都白起一次);不是 Mac / Windows、没开都不要紧 */
|
|
436
483
|
async function autostartOffQuietly(file, deps) {
|
|
437
|
-
|
|
438
|
-
|
|
484
|
+
const platform = deps.platform ?? process.platform;
|
|
485
|
+
if (platform !== "darwin" && platform !== "win32") return false;
|
|
486
|
+
try {
|
|
487
|
+
const r = platform === "win32"
|
|
488
|
+
? await autostartOffWindows({ configFile: file, schtasks: deps.schtasks })
|
|
489
|
+
: await autostartOff({ configFile: file, launchctl: deps.launchctl });
|
|
490
|
+
return r.wasOn;
|
|
491
|
+
} catch { return false; }
|
|
439
492
|
}
|
|
440
493
|
|
|
441
494
|
// @意图#R666(2026-10-29): 先拿锁(后台已在跑就提示退出 0);开机自启时启动前日志超 5MB 清空、配置坏了/没配对也退出 0(不让 launchd 反复拉起);
|
|
@@ -467,11 +520,13 @@ async function runStart(prog = progName(), deps = {}) {
|
|
|
467
520
|
process.exit(0);
|
|
468
521
|
}
|
|
469
522
|
|
|
470
|
-
|
|
523
|
+
// @意图#R725(2026-11-03): Windows 也支持开机自启(计划任务,见 autostart.mjs)。Mac 和 Windows 都问;别的系统不开。
|
|
524
|
+
/** R666:配完要不要开机自启:flag(--autostart / --no-autostart)优先;不是 Mac / Windows 不开;是终端就问;不是终端(脚本、管道)不问、只提示 */
|
|
471
525
|
export function decideAutostart({ flag, platform = process.platform, tty = !!(process.stdin.isTTY && process.stdout.isTTY) }) {
|
|
472
|
-
|
|
526
|
+
const supported = platform === "darwin" || platform === "win32";
|
|
527
|
+
if (flag === true) return supported ? "on" : "unsupported";
|
|
473
528
|
if (flag === false) return "off";
|
|
474
|
-
if (
|
|
529
|
+
if (!supported) return "off";
|
|
475
530
|
return tty ? "ask" : "hint";
|
|
476
531
|
}
|
|
477
532
|
|
|
@@ -497,11 +552,16 @@ async function stopHolder(file) {
|
|
|
497
552
|
|
|
498
553
|
const here = () => path.dirname(fileURLToPath(import.meta.url));
|
|
499
554
|
// @意图#R666(2026-10-29): 开启自启:前台有一个在跑就先停掉它(不然后台那个起来就因为锁退出了),再交给 autostart.mjs(复制自己、写 plist、加载)
|
|
555
|
+
// @意图#R725(2026-11-03): Windows 走计划任务(autostartOnWindows),Mac 走 launchd。提示里的「这台 Mac」按平台写。
|
|
500
556
|
async function turnAutostartOn(file, prog, deps = {}) {
|
|
501
557
|
const held = readLock(file);
|
|
502
558
|
if (held && !held.launchd) await stopHolder(file);
|
|
503
|
-
const
|
|
504
|
-
|
|
559
|
+
const platform = deps.platform ?? process.platform;
|
|
560
|
+
const r = platform === "win32"
|
|
561
|
+
? await autostartOnWindows({ configFile: file, version: RUNNER_VERSION, srcDir: here(), execPath: deps.execPath, schtasks: deps.schtasks })
|
|
562
|
+
: await autostartOn({ configFile: file, version: RUNNER_VERSION, srcDir: here(), launchctl: deps.launchctl, platform });
|
|
563
|
+
const where = platform === "win32" ? "这台电脑" : "这台 Mac";
|
|
564
|
+
console.log(`已设开机自启:以后登录${where}会自动连上,不用开终端。现在已经在后台连着,可以关掉这个终端窗口了。\n日志:${r.log}\n关掉开机自启:${prog} autostart off`);
|
|
505
565
|
return r;
|
|
506
566
|
}
|
|
507
567
|
|
|
@@ -512,10 +572,11 @@ const usage = (prog) => `用法:
|
|
|
512
572
|
${prog} start 连上服务器(终端窗口别关;Ctrl-C 断开)
|
|
513
573
|
${prog} stop 停掉在跑的运行器
|
|
514
574
|
${prog} status 看配置、各 bot 的目录、在不在跑、开机自启
|
|
515
|
-
${prog}
|
|
575
|
+
${prog} set-dir <bot_id> <路径> 直接指定这个 bot 的授权目录(和网页上点「选择目录」走同一套检查;弹窗弹不出来时用)
|
|
576
|
+
${prog} autostart on|off|status 开机自启(Mac 用 launchd,Windows 用计划任务;on 也用来升级到新版本)
|
|
516
577
|
${prog} uninstall [--purge] 关掉自启、删掉本地副本和日志(--purge 连配置一起删)
|
|
517
578
|
${prog} --version
|
|
518
|
-
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac 上会弹窗让你选。`;
|
|
579
|
+
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac / Windows 上会弹窗让你选。`;
|
|
519
580
|
|
|
520
581
|
// @意图#R666(2026-10-29): pair 默认配完直接 start(--no-start 只配对);配完按 decideAutostart 问 / 提示 / 开启开机自启,开了就不在前台跑;
|
|
521
582
|
// 新命令 stop、autostart on|off|status、uninstall、--version;status 多显示在不在跑、版本、开机自启;用法里的命令按运行方式写(npx 时写 npx 写法)
|
|
@@ -557,15 +618,21 @@ export async function main(argv, prog = progName(), deps = {}) {
|
|
|
557
618
|
const sub = a.positional[0] ?? "status";
|
|
558
619
|
const file = configPath();
|
|
559
620
|
if (sub === "on") { loadConfig(file); await turnAutostartOn(file, prog, deps); return; }
|
|
560
|
-
if (platform !== "darwin") { console.log(ONLY_MAC); return; }
|
|
621
|
+
if (platform !== "darwin" && platform !== "win32") { console.log(ONLY_MAC); return; }
|
|
622
|
+
// @意图#R725(2026-11-03): Windows 的 off / status 走计划任务,Mac 走 launchd
|
|
561
623
|
if (sub === "off") {
|
|
562
|
-
const r =
|
|
624
|
+
const r = platform === "win32"
|
|
625
|
+
? await autostartOffWindows({ configFile: file, schtasks: deps.schtasks })
|
|
626
|
+
: await autostartOff({ configFile: file, launchctl: deps.launchctl });
|
|
563
627
|
console.log(r.wasOn ? "已关掉开机自启(后台那个也停了)。" : "本来就没开开机自启。");
|
|
564
628
|
return;
|
|
565
629
|
}
|
|
566
630
|
if (sub === "status") {
|
|
567
|
-
const st =
|
|
568
|
-
|
|
631
|
+
const st = platform === "win32"
|
|
632
|
+
? await autostartStatusWindows({ configFile: file, schtasks: deps.schtasks })
|
|
633
|
+
: await autostartStatus({ configFile: file, launchctl: deps.launchctl, platform });
|
|
634
|
+
const where = st.task ? `计划任务:${st.task}` : `plist:${st.plist}`;
|
|
635
|
+
console.log(st.installed ? `开机自启:开着(${st.loaded ? (st.pid ? `后台在跑,进程 ${st.pid}` : "已加载,现在没在跑") : "还没加载"})\n${where}\n日志:${st.log}` : "开机自启:没开");
|
|
569
636
|
for (const p of st.problems) console.log(`注意:${p}`);
|
|
570
637
|
return;
|
|
571
638
|
}
|
|
@@ -588,6 +655,26 @@ export async function main(argv, prog = progName(), deps = {}) {
|
|
|
588
655
|
console.log(`已卸载:开机自启关了、本地副本和日志删了。${purge ? `配置也删了(${file});记得在网页「我的电脑」里点「断开」作废令牌。` : `配置留着(${file});连配置一起删:${prog} uninstall --purge`}`);
|
|
589
656
|
return;
|
|
590
657
|
}
|
|
658
|
+
if (a.cmd === "set-dir") {
|
|
659
|
+
// @意图#R716(2026-11-02): 不靠弹窗也能设目录(无桌面会话 / 弹不出来时应急),走和网页选目录同一套检查
|
|
660
|
+
const file = configPath();
|
|
661
|
+
const bot = a.positional[0];
|
|
662
|
+
const dir = a.positional[1];
|
|
663
|
+
if (!bot || !dir) { console.log(`用法:${prog} set-dir <bot_id> <路径>\n bot_id 在网页这个 bot 的私聊里、输入框下面「我的电脑」那一行能看到`); process.exitCode = 1; return; }
|
|
664
|
+
let cfg;
|
|
665
|
+
try { cfg = loadConfig(file); } catch (e) { console.log(e?.message ?? String(e)); process.exitCode = 1; return; }
|
|
666
|
+
if (!cfg?.token) { console.log(`还没配对:${file} 里没有令牌。先在网页上配对:${prog} pair <配对码>@<服务器地址>`); process.exitCode = 1; return; }
|
|
667
|
+
let real;
|
|
668
|
+
try { real = await checkDir(dir); } catch (e) { console.log(e?.message ?? String(e)); process.exitCode = 1; return; }
|
|
669
|
+
cfg.bot_dirs = { ...botDirsOf(cfg), [bot]: real };
|
|
670
|
+
delete cfg.dir; // 老格式的单一 dir 不再写回
|
|
671
|
+
saveConfig(cfg, file);
|
|
672
|
+
const l = readLock(file);
|
|
673
|
+
const running = l && pidAlive(l.pid);
|
|
674
|
+
console.log(`已设:${bot} → ${real}`);
|
|
675
|
+
console.log(running ? `运行器正在跑(进程 ${l.pid}),重启后生效:${prog} stop && ${prog} start` : `配置文件:${file}`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
591
678
|
if (a.cmd === "status") {
|
|
592
679
|
const file = configPath();
|
|
593
680
|
const cfg = loadConfig(file);
|