agent-hub-runner 0.1.3 → 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/package.json +1 -1
- package/runner.mjs +34 -14
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/package.json
CHANGED
package/runner.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { execFile, spawn, spawnSync } from "node:child_process";
|
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { runOp } from "./ops.mjs";
|
|
29
29
|
import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
|
|
30
|
-
import { 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 包名,也是装好后的命令名 */
|
|
@@ -478,10 +478,17 @@ export function takeLock(file = configPath(), { pid = process.pid, launchd = pro
|
|
|
478
478
|
return { ok: true, release: () => { if (readLock(file)?.pid === pid) rmSync(lockPath(file), { force: true }); } };
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
-
|
|
481
|
+
// @意图#R725(2026-11-03): Windows 被断开时也关掉计划任务,不然下次登录又白起一次
|
|
482
|
+
/** 被网页断开 / 令牌失效后把这份配置的开机自启关掉(不然每次登录都白起一次);不是 Mac / Windows、没开都不要紧 */
|
|
482
483
|
async function autostartOffQuietly(file, deps) {
|
|
483
|
-
|
|
484
|
-
|
|
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; }
|
|
485
492
|
}
|
|
486
493
|
|
|
487
494
|
// @意图#R666(2026-10-29): 先拿锁(后台已在跑就提示退出 0);开机自启时启动前日志超 5MB 清空、配置坏了/没配对也退出 0(不让 launchd 反复拉起);
|
|
@@ -513,11 +520,13 @@ async function runStart(prog = progName(), deps = {}) {
|
|
|
513
520
|
process.exit(0);
|
|
514
521
|
}
|
|
515
522
|
|
|
516
|
-
|
|
523
|
+
// @意图#R725(2026-11-03): Windows 也支持开机自启(计划任务,见 autostart.mjs)。Mac 和 Windows 都问;别的系统不开。
|
|
524
|
+
/** R666:配完要不要开机自启:flag(--autostart / --no-autostart)优先;不是 Mac / Windows 不开;是终端就问;不是终端(脚本、管道)不问、只提示 */
|
|
517
525
|
export function decideAutostart({ flag, platform = process.platform, tty = !!(process.stdin.isTTY && process.stdout.isTTY) }) {
|
|
518
|
-
|
|
526
|
+
const supported = platform === "darwin" || platform === "win32";
|
|
527
|
+
if (flag === true) return supported ? "on" : "unsupported";
|
|
519
528
|
if (flag === false) return "off";
|
|
520
|
-
if (
|
|
529
|
+
if (!supported) return "off";
|
|
521
530
|
return tty ? "ask" : "hint";
|
|
522
531
|
}
|
|
523
532
|
|
|
@@ -543,11 +552,16 @@ async function stopHolder(file) {
|
|
|
543
552
|
|
|
544
553
|
const here = () => path.dirname(fileURLToPath(import.meta.url));
|
|
545
554
|
// @意图#R666(2026-10-29): 开启自启:前台有一个在跑就先停掉它(不然后台那个起来就因为锁退出了),再交给 autostart.mjs(复制自己、写 plist、加载)
|
|
555
|
+
// @意图#R725(2026-11-03): Windows 走计划任务(autostartOnWindows),Mac 走 launchd。提示里的「这台 Mac」按平台写。
|
|
546
556
|
async function turnAutostartOn(file, prog, deps = {}) {
|
|
547
557
|
const held = readLock(file);
|
|
548
558
|
if (held && !held.launchd) await stopHolder(file);
|
|
549
|
-
const
|
|
550
|
-
|
|
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`);
|
|
551
565
|
return r;
|
|
552
566
|
}
|
|
553
567
|
|
|
@@ -559,7 +573,7 @@ const usage = (prog) => `用法:
|
|
|
559
573
|
${prog} stop 停掉在跑的运行器
|
|
560
574
|
${prog} status 看配置、各 bot 的目录、在不在跑、开机自启
|
|
561
575
|
${prog} set-dir <bot_id> <路径> 直接指定这个 bot 的授权目录(和网页上点「选择目录」走同一套检查;弹窗弹不出来时用)
|
|
562
|
-
${prog} autostart on|off|status Mac
|
|
576
|
+
${prog} autostart on|off|status 开机自启(Mac 用 launchd,Windows 用计划任务;on 也用来升级到新版本)
|
|
563
577
|
${prog} uninstall [--purge] 关掉自启、删掉本地副本和日志(--purge 连配置一起删)
|
|
564
578
|
${prog} --version
|
|
565
579
|
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac / Windows 上会弹窗让你选。`;
|
|
@@ -604,15 +618,21 @@ export async function main(argv, prog = progName(), deps = {}) {
|
|
|
604
618
|
const sub = a.positional[0] ?? "status";
|
|
605
619
|
const file = configPath();
|
|
606
620
|
if (sub === "on") { loadConfig(file); await turnAutostartOn(file, prog, deps); return; }
|
|
607
|
-
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
|
|
608
623
|
if (sub === "off") {
|
|
609
|
-
const r =
|
|
624
|
+
const r = platform === "win32"
|
|
625
|
+
? await autostartOffWindows({ configFile: file, schtasks: deps.schtasks })
|
|
626
|
+
: await autostartOff({ configFile: file, launchctl: deps.launchctl });
|
|
610
627
|
console.log(r.wasOn ? "已关掉开机自启(后台那个也停了)。" : "本来就没开开机自启。");
|
|
611
628
|
return;
|
|
612
629
|
}
|
|
613
630
|
if (sub === "status") {
|
|
614
|
-
const st =
|
|
615
|
-
|
|
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}` : "开机自启:没开");
|
|
616
636
|
for (const p of st.problems) console.log(`注意:${p}`);
|
|
617
637
|
return;
|
|
618
638
|
}
|