agent-hub-runner 0.0.0-stage → 0.1.0
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 +66 -2
- package/bin/agent-hub-runner.mjs +6 -0
- package/edit.mjs +143 -0
- package/ops.mjs +152 -0
- package/package.json +8 -4
- package/runner.mjs +337 -0
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- @意图#R664(2026-10-29): npm 包 agent-hub-runner 的说明(随包发布):给客户看的安装、配对、再连、查看、断开、卸载和安全提示 -->
|
|
2
|
+
# agent-hub-runner
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
agent-hub 的电脑运行器。装在你自己的电脑上,配对之后,你的 bot 就能在你给它选的文件夹里读、写、改文件,跑命令(跑命令每次都要你在网页上点批准)。
|
|
5
|
+
|
|
6
|
+
- 没有第三方依赖,只用 Node 自带的功能。
|
|
7
|
+
- 在终端前台运行,按 Ctrl-C 立刻断开;不会开机自启。
|
|
8
|
+
|
|
9
|
+
## 需要
|
|
10
|
+
|
|
11
|
+
- **Node 22 或更新**(`node -v` 看版本)。
|
|
12
|
+
- **macOS**:给 bot 选目录时会在 Mac 上弹出系统的「选文件夹」窗口。别的系统目前弹不出这个窗口,选不了目录。
|
|
13
|
+
- bot 跑命令时用 `bash`(macOS、Linux 自带)。
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm i -g agent-hub-runner
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
装过的跳过这一步。
|
|
22
|
+
|
|
23
|
+
## 第一次连接
|
|
24
|
+
|
|
25
|
+
1. 在网页上打开某个 bot 的私聊,点输入框下面「我的电脑」那一行,右侧会打开抽屉,里面有配对码和一条命令。
|
|
26
|
+
2. 复制那条命令到终端运行,形如:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
agent-hub-runner pair <配对码> --start --server https://你的服务器地址
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
配对码 5 分钟有效、只能用一次;过期了在抽屉里点「换一个码」。`--start` 表示配对成功后直接连上。
|
|
33
|
+
3. 抽屉里第 2 步会自动打勾。第 3 步点「选择目录…」,在 Mac 弹出的窗口里选这个 bot 能用的文件夹。每个 bot 各选各的。
|
|
34
|
+
|
|
35
|
+
终端窗口别关:关了 bot 就用不了你的电脑。
|
|
36
|
+
|
|
37
|
+
## 以后再连
|
|
38
|
+
|
|
39
|
+
重启电脑或关了终端之后,运行:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
agent-hub-runner start
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
服务器地址和令牌配对时已经存下了,不用再给。
|
|
46
|
+
|
|
47
|
+
## 其他命令
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
agent-hub-runner status # 看连的是哪个服务器、每个 bot 选了哪个目录
|
|
51
|
+
agent-hub-runner # 看用法
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 配置文件
|
|
55
|
+
|
|
56
|
+
- 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的目录。别发给别人。
|
|
57
|
+
- 想放别处:设环境变量 `AGENT_RUNNER_CONFIG=/某个路径/config.json`。
|
|
58
|
+
|
|
59
|
+
## 断开、卸载
|
|
60
|
+
|
|
61
|
+
- 在网页抽屉「连着的电脑」里点「断开」:令牌立刻作废,再连要重新配对。
|
|
62
|
+
- 卸载:`npm rm -g agent-hub-runner`,再删掉 `~/.agent-runner/`。
|
|
63
|
+
|
|
64
|
+
## 安全提示
|
|
65
|
+
|
|
66
|
+
- 别把放着密钥、令牌的文件夹授权出去:bot 在授权文件夹里读、写、改文件不用你批准。
|
|
67
|
+
- 跑命令前网页上会弹批准卡片,看清命令再点「允许一次」;命令本身能碰到文件夹以外的东西(没有沙箱)。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @意图#R664(2026-10-29): npm 包 agent-hub-runner 的命令入口(package.json 的 bin):客户电脑 npm i -g agent-hub-runner 后直接跑
|
|
3
|
+
// agent-hub-runner pair <配对码> --start --server <地址> / start / status;逻辑全在 ../runner.mjs,这里只把参数和命令名交过去。
|
|
4
|
+
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); });
|
package/edit.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @意图#R636(2026-10-27): 运行器零依赖,edit 的匹配规则照搬 pi 1.1.0 的 edit-diff(先精确、再按引号/破折号/行尾空白放宽),
|
|
2
|
+
// 这样 bot 在你电脑上改文件和 pi 原版的 edit 行为一样;路径判断在 ops.mjs 里做,这里只管文字替换。
|
|
3
|
+
export function detectLineEnding(content) {
|
|
4
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
5
|
+
const lfIdx = content.indexOf("\n");
|
|
6
|
+
if (lfIdx === -1 || crlfIdx === -1) return "\n";
|
|
7
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
8
|
+
}
|
|
9
|
+
export const normalizeToLF = (text) => text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
10
|
+
export const restoreLineEndings = (text, ending) => (ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text);
|
|
11
|
+
|
|
12
|
+
export function normalizeForFuzzyMatch(text) {
|
|
13
|
+
return text.normalize("NFKC")
|
|
14
|
+
.split("\n").map((line) => line.trimEnd()).join("\n")
|
|
15
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
16
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
17
|
+
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
18
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
19
|
+
}
|
|
20
|
+
const splitLinesWithEndings = (content) => content.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
|
21
|
+
function getLineSpans(content) {
|
|
22
|
+
let offset = 0;
|
|
23
|
+
return splitLinesWithEndings(content).map((line) => {
|
|
24
|
+
const span = { start: offset, end: offset + line.length };
|
|
25
|
+
offset = span.end;
|
|
26
|
+
return span;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function getReplacementLineRange(lines, r) {
|
|
30
|
+
const s = r.matchIndex;
|
|
31
|
+
const e = r.matchIndex + r.matchLength;
|
|
32
|
+
let startLine = -1;
|
|
33
|
+
for (let i = 0; i < lines.length; i++) {
|
|
34
|
+
if (s >= lines[i].start && s < lines[i].end) { startLine = i; break; }
|
|
35
|
+
}
|
|
36
|
+
if (startLine === -1) throw new Error("Replacement range is outside the base content.");
|
|
37
|
+
let endLine = startLine;
|
|
38
|
+
while (endLine < lines.length && lines[endLine].end < e) endLine++;
|
|
39
|
+
if (endLine >= lines.length) throw new Error("Replacement range is outside the base content.");
|
|
40
|
+
return { startLine, endLine: endLine + 1 };
|
|
41
|
+
}
|
|
42
|
+
function applyReplacements(content, replacements, offset = 0) {
|
|
43
|
+
let result = content;
|
|
44
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
45
|
+
const r = replacements[i];
|
|
46
|
+
const at = r.matchIndex - offset;
|
|
47
|
+
result = result.substring(0, at) + r.newText + result.substring(at + r.matchLength);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
function applyReplacementsPreservingUnchangedLines(originalContent, baseContent, replacements) {
|
|
52
|
+
const originalLines = splitLinesWithEndings(originalContent);
|
|
53
|
+
const baseLines = getLineSpans(baseContent);
|
|
54
|
+
if (originalLines.length !== baseLines.length) {
|
|
55
|
+
throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
|
|
56
|
+
}
|
|
57
|
+
const groups = [];
|
|
58
|
+
for (const r of [...replacements].sort((a, b) => a.matchIndex - b.matchIndex)) {
|
|
59
|
+
const range = getReplacementLineRange(baseLines, r);
|
|
60
|
+
const cur = groups[groups.length - 1];
|
|
61
|
+
if (cur && range.startLine < cur.endLine) {
|
|
62
|
+
cur.endLine = Math.max(cur.endLine, range.endLine);
|
|
63
|
+
cur.replacements.push(r);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
groups.push({ ...range, replacements: [r] });
|
|
67
|
+
}
|
|
68
|
+
let idx = 0;
|
|
69
|
+
let result = "";
|
|
70
|
+
for (const g of groups) {
|
|
71
|
+
result += originalLines.slice(idx, g.startLine).join("");
|
|
72
|
+
const gs = baseLines[g.startLine].start;
|
|
73
|
+
const ge = baseLines[g.endLine - 1].end;
|
|
74
|
+
result += applyReplacements(baseContent.slice(gs, ge), g.replacements, gs);
|
|
75
|
+
idx = g.endLine;
|
|
76
|
+
}
|
|
77
|
+
return result + originalLines.slice(idx).join("");
|
|
78
|
+
}
|
|
79
|
+
function fuzzyFindText(content, oldText) {
|
|
80
|
+
const exact = content.indexOf(oldText);
|
|
81
|
+
if (exact !== -1) return { found: true, index: exact, matchLength: oldText.length, usedFuzzyMatch: false };
|
|
82
|
+
const fc = normalizeForFuzzyMatch(content);
|
|
83
|
+
const fo = normalizeForFuzzyMatch(oldText);
|
|
84
|
+
const fi = fc.indexOf(fo);
|
|
85
|
+
if (fi === -1) return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
|
|
86
|
+
return { found: true, index: fi, matchLength: fo.length, usedFuzzyMatch: true };
|
|
87
|
+
}
|
|
88
|
+
const countOccurrences = (content, oldText) => normalizeForFuzzyMatch(content).split(normalizeForFuzzyMatch(oldText)).length - 1;
|
|
89
|
+
|
|
90
|
+
export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
|
|
91
|
+
const ne = edits.map((e) => ({ oldText: normalizeToLF(String(e.oldText)), newText: normalizeToLF(String(e.newText)) }));
|
|
92
|
+
const n = ne.length;
|
|
93
|
+
for (let i = 0; i < n; i++) {
|
|
94
|
+
if (ne[i].oldText.length === 0) {
|
|
95
|
+
throw new Error(n === 1 ? `oldText must not be empty in ${path}.` : `edits[${i}].oldText must not be empty in ${path}.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const usedFuzzy = ne.map((e) => fuzzyFindText(normalizedContent, e.oldText)).some((m) => m.usedFuzzyMatch);
|
|
99
|
+
const base = usedFuzzy ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
|
|
100
|
+
const matched = [];
|
|
101
|
+
for (let i = 0; i < n; i++) {
|
|
102
|
+
const m = fuzzyFindText(base, ne[i].oldText);
|
|
103
|
+
if (!m.found) {
|
|
104
|
+
throw new Error(n === 1
|
|
105
|
+
? `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`
|
|
106
|
+
: `Could not find edits[${i}] in ${path}. The oldText must match exactly including all whitespace and newlines.`);
|
|
107
|
+
}
|
|
108
|
+
const occ = countOccurrences(base, ne[i].oldText);
|
|
109
|
+
if (occ > 1) {
|
|
110
|
+
throw new Error(n === 1
|
|
111
|
+
? `Found ${occ} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`
|
|
112
|
+
: `Found ${occ} occurrences of edits[${i}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
|
|
113
|
+
}
|
|
114
|
+
matched.push({ editIndex: i, matchIndex: m.index, matchLength: m.matchLength, newText: ne[i].newText });
|
|
115
|
+
}
|
|
116
|
+
matched.sort((a, b) => a.matchIndex - b.matchIndex);
|
|
117
|
+
for (let i = 1; i < matched.length; i++) {
|
|
118
|
+
const p = matched[i - 1];
|
|
119
|
+
const c = matched[i];
|
|
120
|
+
if (p.matchIndex + p.matchLength > c.matchIndex) {
|
|
121
|
+
throw new Error(`edits[${p.editIndex}] and edits[${c.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const newContent = usedFuzzy
|
|
125
|
+
? applyReplacementsPreservingUnchangedLines(normalizedContent, base, matched)
|
|
126
|
+
: applyReplacements(base, matched);
|
|
127
|
+
if (newContent === normalizedContent) {
|
|
128
|
+
throw new Error(n === 1
|
|
129
|
+
? `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`
|
|
130
|
+
: `No changes made to ${path}. The replacements produced identical content.`);
|
|
131
|
+
}
|
|
132
|
+
return { baseContent: normalizedContent, newContent };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 整个文件的改法:去 BOM、统一换行再匹配,写回时恢复原来的换行和 BOM(和 pi 原版一样)。 */
|
|
136
|
+
// @意图#R636(2026-10-27): 和 pi edit 同样的 BOM、换行处理
|
|
137
|
+
export function editText(raw, edits, path) {
|
|
138
|
+
const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
139
|
+
const content = bom ? raw.slice(1) : raw;
|
|
140
|
+
const ending = detectLineEnding(content);
|
|
141
|
+
const { newContent } = applyEditsToNormalizedContent(normalizeToLF(content), edits, path);
|
|
142
|
+
return bom + restoreLineEndings(newContent, ending);
|
|
143
|
+
}
|
package/ops.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径):
|
|
2
|
+
// 先按授权目录换算成真实路径(解开符号链接,包括指向不存在目标的符号链接),不在授权目录里的一律拒绝;bash 的工作目录就是授权目录。
|
|
3
|
+
// 第一轮没有沙箱(king 定的范围):bash 里的命令本身能碰到目录外的东西,只能靠你看清批准卡片再点。
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { editText } from "./edit.mjs";
|
|
9
|
+
|
|
10
|
+
export const MAX_READ_BYTES = 20 * 1024 * 1024;
|
|
11
|
+
/** bash 没给 timeout 时的默认上限(秒),到点杀掉整个进程组。 */
|
|
12
|
+
export const DEFAULT_BASH_TIMEOUT_S = 300;
|
|
13
|
+
|
|
14
|
+
/** 把 bot 给的路径按授权目录换成绝对路径(~ 指你的家目录,相对路径相对授权目录;去掉开头的 @,和 pi 一样)。 */
|
|
15
|
+
// @意图#R636(2026-10-27): 相对路径一律相对授权目录
|
|
16
|
+
export function expandPath(p, root) {
|
|
17
|
+
let s = String(p).replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
|
18
|
+
if (s.startsWith("@")) s = s.slice(1);
|
|
19
|
+
if (s === "~") s = os.homedir();
|
|
20
|
+
else if (s.startsWith("~/")) s = path.join(os.homedir(), s.slice(2));
|
|
21
|
+
return path.resolve(root, s);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 真实路径:存在的直接 realpath;不存在的找到最近的已存在上级再拼回去;悬空的符号链接按它指向的地方算。 */
|
|
25
|
+
// @意图#R636(2026-10-27): 挡住 ../、越界符号链接、悬空符号链接(写的时候会顺着链接写到目录外)
|
|
26
|
+
export async function realOf(abs, depth = 0) {
|
|
27
|
+
if (depth > 40) throw new Error("符号链接太多层");
|
|
28
|
+
try {
|
|
29
|
+
return await fs.realpath(abs);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
if (e?.code !== "ENOENT") throw e;
|
|
32
|
+
let st;
|
|
33
|
+
try { st = await fs.lstat(abs); } catch { st = undefined; }
|
|
34
|
+
if (st?.isSymbolicLink()) {
|
|
35
|
+
const target = path.resolve(path.dirname(abs), await fs.readlink(abs));
|
|
36
|
+
return realOf(target, depth + 1);
|
|
37
|
+
}
|
|
38
|
+
const parent = path.dirname(abs);
|
|
39
|
+
if (parent === abs) throw e;
|
|
40
|
+
return path.join(await realOf(parent, depth + 1), path.basename(abs));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const inside = (real, root) => real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
45
|
+
|
|
46
|
+
/** 换算并检查:在授权目录里返回真实路径,不在就抛「目录外路径已拒绝」。 */
|
|
47
|
+
// @意图#R636(2026-10-27): 授权目录外的路径一律拒绝
|
|
48
|
+
export async function resolveInside(p, root) {
|
|
49
|
+
if (typeof p !== "string" || !p.trim()) throw new Error("路径是空的");
|
|
50
|
+
let real;
|
|
51
|
+
try { real = await realOf(expandPath(p, root)); } catch (e) { throw new Error(`无法解析路径:${p}(${e?.code ?? e?.message ?? e})`); }
|
|
52
|
+
if (!inside(real, root)) throw new Error(`目录外路径已拒绝:${p}(授权目录是 ${root})`);
|
|
53
|
+
return real;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function imageMime(buf) {
|
|
57
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
|
|
58
|
+
if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
|
|
59
|
+
const head = buf.subarray(0, 6).toString("latin1");
|
|
60
|
+
if (head === "GIF87a" || head === "GIF89a") return "image/gif";
|
|
61
|
+
if (buf.length >= 12 && buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
|
|
62
|
+
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) return "image/bmp";
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** read:返回整份文字(截断由服务器照 pi 原版做)或图片。 */
|
|
67
|
+
// @意图#R636(2026-10-27): 读文件不用批准,但同样只能读授权目录里的
|
|
68
|
+
export async function opRead(args, root) {
|
|
69
|
+
const real = await resolveInside(args.path, root);
|
|
70
|
+
const st = await fs.stat(real);
|
|
71
|
+
if (st.isDirectory()) throw new Error(`${args.path} 是目录,不是文件(看目录里有什么用 bash 的 ls)`);
|
|
72
|
+
if (st.size > MAX_READ_BYTES) throw new Error(`${args.path} 太大(${st.size} 字节),超过 ${MAX_READ_BYTES} 字节;用 bash 的 head/sed 看一部分`);
|
|
73
|
+
const buf = await fs.readFile(real);
|
|
74
|
+
const mime = imageMime(buf);
|
|
75
|
+
if (mime) return { kind: "image", mime, data: buf.toString("base64") };
|
|
76
|
+
return { kind: "text", text: buf.toString("utf8") };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** write:建好上级目录再整份写入。 */
|
|
80
|
+
// @意图#R636(2026-10-27): 写文件(已经过批准,只执行这一次)
|
|
81
|
+
export async function opWrite(args, root) {
|
|
82
|
+
if (typeof args.content !== "string") throw new Error("content 必须是文字");
|
|
83
|
+
const real = await resolveInside(args.path, root);
|
|
84
|
+
await fs.mkdir(path.dirname(real), { recursive: true });
|
|
85
|
+
await fs.writeFile(real, args.content, "utf8");
|
|
86
|
+
return { bytes: Buffer.byteLength(args.content, "utf8") };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** edit:和 pi 原版同样的精确替换。 */
|
|
90
|
+
// @意图#R636(2026-10-27): 改文件(已经过批准,只执行这一次)
|
|
91
|
+
export async function opEdit(args, root) {
|
|
92
|
+
if (!Array.isArray(args.edits) || args.edits.length === 0) throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
|
93
|
+
const real = await resolveInside(args.path, root);
|
|
94
|
+
let raw;
|
|
95
|
+
try { raw = await fs.readFile(real, "utf8"); } catch (e) { throw new Error(`Could not edit file: ${args.path}. Error code: ${e?.code ?? "unknown"}.`); }
|
|
96
|
+
const next = editText(raw, args.edits, args.path);
|
|
97
|
+
await fs.writeFile(real, next, "utf8");
|
|
98
|
+
return { replaced: args.edits.length };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function killGroup(child) {
|
|
102
|
+
if (!child.pid) return;
|
|
103
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch { /* 进程组已经没了 */ }
|
|
104
|
+
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* bash:在授权目录里用你终端的环境变量跑(服务器的环境变量不下发)。输出边跑边交给 onData;
|
|
109
|
+
* 超时或 signal 中止时杀掉整个进程组。返回 { exitCode, timedOut, aborted }。
|
|
110
|
+
*/
|
|
111
|
+
// @意图#R636(2026-10-27): 跑命令(已经过批准,只执行这一次),有超时,超时杀进程
|
|
112
|
+
export function opBash(args, root, { onData, signal } = {}) {
|
|
113
|
+
const command = args.command;
|
|
114
|
+
if (typeof command !== "string" || !command.trim()) return Promise.reject(new Error("command 是空的"));
|
|
115
|
+
let timeoutS = DEFAULT_BASH_TIMEOUT_S;
|
|
116
|
+
if (args.timeout !== undefined && args.timeout !== null) {
|
|
117
|
+
const t = Number(args.timeout);
|
|
118
|
+
if (!Number.isFinite(t) || t <= 0) return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
|
|
119
|
+
timeoutS = t;
|
|
120
|
+
}
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const child = spawn("bash", ["-c", command], { cwd: root, detached: true, stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
123
|
+
let timedOut = false;
|
|
124
|
+
let aborted = false;
|
|
125
|
+
const timer = setTimeout(() => { timedOut = true; killGroup(child); }, timeoutS * 1000);
|
|
126
|
+
const onAbort = () => { aborted = true; killGroup(child); };
|
|
127
|
+
if (signal) {
|
|
128
|
+
if (signal.aborted) onAbort();
|
|
129
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
}
|
|
131
|
+
child.stdout.on("data", (d) => onData?.(d));
|
|
132
|
+
child.stderr.on("data", (d) => onData?.(d));
|
|
133
|
+
const done = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); };
|
|
134
|
+
child.on("error", (e) => { done(); reject(e); });
|
|
135
|
+
child.on("close", (code, sig) => {
|
|
136
|
+
done();
|
|
137
|
+
const exitCode = code ?? (sig ? 128 + (os.constants.signals[sig] ?? 0) : 1);
|
|
138
|
+
resolve({ exitCode, timedOut, aborted, timeout: timeoutS });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 按 op 分派;未知 op 拒绝。 */
|
|
144
|
+
// @意图#R636(2026-10-27): 协议里只有这四种工具调用
|
|
145
|
+
export function runOp(op, args, root, extra) {
|
|
146
|
+
const a = args && typeof args === "object" ? args : {};
|
|
147
|
+
if (op === "read") return opRead(a, root);
|
|
148
|
+
if (op === "write") return opWrite(a, root);
|
|
149
|
+
if (op === "edit") return opEdit(a, root);
|
|
150
|
+
if (op === "bash") return opBash(a, root, extra);
|
|
151
|
+
return Promise.reject(new Error(`未知操作 ${op}`));
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub-runner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "agent-hub-runner": "bin/agent-hub-runner.mjs" },
|
|
7
|
+
"files": ["runner.mjs", "ops.mjs", "edit.mjs", "bin/agent-hub-runner.mjs", "README.md"],
|
|
8
|
+
"engines": { "node": ">=22" },
|
|
9
|
+
"scripts": { "test": "node --test" }
|
|
10
|
+
}
|
package/runner.mjs
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @意图#R636(2026-10-27): 你电脑上的运行器(第一轮最小可用,king 定甲):零第三方依赖,只用 Node 22+ 自带的 WebSocket、fetch、进程和文件 API。
|
|
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
|
|
6
|
+
// @意图#R664(2026-10-29): 运行器做成客户电脑 npm 安装的小工具(包名、命令名都是 agent-hub-runner,入口 bin/agent-hub-runner.mjs):
|
|
7
|
+
// npm i -g agent-hub-runner → agent-hub-runner pair <配对码> --start --server <地址>;以后 agent-hub-runner start。仓库里 node 直接跑照旧。
|
|
8
|
+
// @意图#R663(2026-10-29): pair 加 --start(二甲):配对成功后直接接着 start 连上,网页上只给一条命令;不带 --start 的老用法不变
|
|
9
|
+
// @意图#R640(2026-10-27): 一台电脑一个运行器,但每个 bot 有自己的授权目录(bot→目录 对照表 bot_dirs),互相碰不到;king 定乙:没有默认目录,
|
|
10
|
+
// 每个 bot 都要在它的私聊右栏「我的电脑」里点「选择目录」,运行器在本机弹窗选。服务器只报 bot_id,定不了路径。pair 不再收 --dir。
|
|
11
|
+
// @意图#R639(2026-10-27): 配对不再选目录(king 定乙,替换 R637 配对时弹窗);start 连上后在网页「我的电脑」点「选择目录」,
|
|
12
|
+
// 服务器经这条 WebSocket 叫运行器在 Mac 上弹系统选文件夹窗口,选完马上生效并写回配置文件。
|
|
13
|
+
// 配对后长期令牌和授权目录存在 ~/.agent-runner/config.json(权限 600);目录只能由你在本机窗口里选,服务器只能叫你弹窗、定不了目录。
|
|
14
|
+
// (R639 时目录是一台电脑一个、配对时还能 --dir;R640 起换成每个 bot 一个,老配置里的单一 dir 读到直接忽略。)
|
|
15
|
+
// start 在终端前台运行,主动用 WebSocket 连服务器;按 Ctrl-C 立刻断开。没有开机自启、没有 npx 安装包、没有沙箱。
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { runOp } from "./ops.mjs";
|
|
23
|
+
|
|
24
|
+
export const PROTOCOL_VERSION = 1;
|
|
25
|
+
/** npm 包名,也是装好后的命令名 */
|
|
26
|
+
export const PKG_BIN = "agent-hub-runner";
|
|
27
|
+
export const HEARTBEAT_MS = 10_000;
|
|
28
|
+
export const DEFAULT_SERVER = "http://127.0.0.1:3220";
|
|
29
|
+
/** 服务器用这两个关闭码表示令牌不对 / 已在网页上断开:不再重连。 */
|
|
30
|
+
export const CLOSE_BAD_TOKEN = 4401;
|
|
31
|
+
export const CLOSE_REVOKED = 4403;
|
|
32
|
+
|
|
33
|
+
export const configPath = () => process.env.AGENT_RUNNER_CONFIG || path.join(os.homedir(), ".agent-runner", "config.json");
|
|
34
|
+
|
|
35
|
+
const stamp = () => new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
36
|
+
const say = (s) => console.log(`[${stamp()}] ${s}`);
|
|
37
|
+
|
|
38
|
+
// @意图#R636(2026-10-27): 令牌文件权限 600、目录 700
|
|
39
|
+
export function saveConfig(cfg, file = configPath()) {
|
|
40
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
41
|
+
writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
chmodSync(file, 0o600);
|
|
43
|
+
}
|
|
44
|
+
export function loadConfig(file = configPath()) {
|
|
45
|
+
if (!existsSync(file)) throw new Error(`还没配对:没有 ${file}。先在网页「我的电脑」里点「连接电脑」拿配对码,再运行 pair。`);
|
|
46
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// @意图#R663(2026-10-29): 认 --start(只对 pair 有意义,不带值)
|
|
50
|
+
export function parseArgs(argv) {
|
|
51
|
+
const [cmd, ...rest] = argv;
|
|
52
|
+
const out = { cmd, positional: [] };
|
|
53
|
+
for (let i = 0; i < rest.length; i++) {
|
|
54
|
+
const a = rest[i];
|
|
55
|
+
if (a === "--dir") out.dir = rest[++i];
|
|
56
|
+
else if (a === "--server") out.server = rest[++i];
|
|
57
|
+
else if (a === "--name") out.name = rest[++i];
|
|
58
|
+
else if (a === "--start") out.start = true;
|
|
59
|
+
else if (a.startsWith("--")) throw new Error(`不认识的参数 ${a}`);
|
|
60
|
+
else out.positional.push(a);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkNode() {
|
|
66
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
67
|
+
if (major < 22 || typeof WebSocket === "undefined") throw new Error(`需要 Node 22 或更新(现在是 ${process.versions.node})`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const PICK_PROMPT = "选择要授权给 bot 的文件夹";
|
|
71
|
+
export const PICK_SCRIPT = `POSIX path of (choose folder with prompt "${PICK_PROMPT}")`;
|
|
72
|
+
/** R640:这个 bot 在对照表里没有目录时,调用一律回这句。 */
|
|
73
|
+
export const NO_DIR = "还没给这个 bot 选目录,请在这个 bot 的私聊右栏「我的电脑」里点「选择目录」";
|
|
74
|
+
export const PICK_ONLY_MAC = "只支持 Mac:选目录的窗口只在 Mac 上弹";
|
|
75
|
+
/** R640:pair 带了 --dir 时的提示(不报错,照常配对,目录不用)。 */
|
|
76
|
+
export const PAIR_DIR_IGNORED = "目录请连上后在每个 bot 的私聊里选(--dir 已不用,这次没用它)";
|
|
77
|
+
|
|
78
|
+
/** 配置里的 bot→目录 对照表。只认 bot_dirs 里值是非空字符串的条目;老格式的单一 dir 直接忽略(所有 bot 都没有目录)。 */
|
|
79
|
+
// @意图#R640(2026-10-27): king 定乙:没有默认目录;老配置的 dir 不迁成任何 bot 的目录
|
|
80
|
+
export function botDirsOf(cfg) {
|
|
81
|
+
const out = {};
|
|
82
|
+
const m = cfg && typeof cfg.bot_dirs === "object" && cfg.bot_dirs !== null && !Array.isArray(cfg.bot_dirs) ? cfg.bot_dirs : {};
|
|
83
|
+
for (const [bot, dir] of Object.entries(m)) if (bot && typeof dir === "string" && dir) out[bot] = dir;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Mac 上弹系统「选文件夹」窗口。选了返回 POSIX 路径;点取消返回 null;signal 中止时关掉窗口进程并抛 AbortError;
|
|
89
|
+
* 弹不出来(比如没有图形界面)抛错。
|
|
90
|
+
*/
|
|
91
|
+
// @意图#R640(2026-10-27): 弹不出来时不再提 --dir(pair 不收 --dir 了)
|
|
92
|
+
// @意图#R639(2026-10-27): 改成网页点「选择目录」时调用;服务器超时发 abort 要能关掉弹窗进程
|
|
93
|
+
// @意图#R637(2026-10-27): 不写 --dir 时用 osascript 弹系统选文件夹窗口(king 定);取消和弹不出来分开报
|
|
94
|
+
export function pickDirMac({ signal } = {}) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
execFile("osascript", ["-e", PICK_SCRIPT], { encoding: "utf8", signal }, (err, stdout, stderr) => {
|
|
97
|
+
if (!err) {
|
|
98
|
+
const p = String(stdout).trim();
|
|
99
|
+
return p ? resolve(p) : resolve(null);
|
|
100
|
+
}
|
|
101
|
+
if (signal?.aborted || err.name === "AbortError") return reject(Object.assign(new Error("已中止"), { name: "AbortError" }));
|
|
102
|
+
if (/-128|User canceled|用户已取消/.test(String(stderr))) return resolve(null);
|
|
103
|
+
const why = String(stderr).trim() || err.message;
|
|
104
|
+
reject(new Error(`弹不出选文件夹的窗口(${why})`));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 检查授权目录:存在、换成真实路径、是目录、不是磁盘根目录。返回真实路径。 */
|
|
110
|
+
// @意图#R640(2026-10-27): 每个 bot 选的目录都走这套检查
|
|
111
|
+
// @意图#R639(2026-10-27): 配对时的 --dir 和网页上选的目录走同一套检查
|
|
112
|
+
export async function checkDir(dir) {
|
|
113
|
+
const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`授权目录不存在:${dir}`); });
|
|
114
|
+
if (!statSync(real).isDirectory()) throw new Error(`授权目录不是目录:${dir}`);
|
|
115
|
+
if (real === path.parse(real).root) throw new Error("不能把整个磁盘根目录授权出去");
|
|
116
|
+
return real;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 配对:用一次性配对码换长期令牌,存进本地配置。不选目录(连上后在每个 bot 的私聊里选);带了 dir 不报错、不用,返回 dirIgnored。 */
|
|
120
|
+
// @意图#R640(2026-10-27): 去掉 --dir:带了只提示「目录请连上后在每个 bot 的私聊里选」,照常配对;配置里 bot_dirs 是空表、不再有 dir
|
|
121
|
+
// @意图#R639(2026-10-27已废弃): 配对不弹窗、不要求目录(king 定乙);--dir 还能用,给了就照旧检查;没给 config.dir 为空(R640 去掉 --dir)
|
|
122
|
+
// @意图#R637(2026-10-27已废弃): Mac 上没写 --dir 就弹窗选目录、取消不请求服务器(R639 改成连上后在网页上选)
|
|
123
|
+
// @意图#R636(2026-10-27): 配对码一次性、5 分钟有效(服务器判断);授权目录先换成真实路径再存
|
|
124
|
+
export async function pair({ code, dir, server = DEFAULT_SERVER, name = os.hostname() }, file = configPath()) {
|
|
125
|
+
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 !== "" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 一条连接上的工作:执行工具调用、网页叫弹窗选目录。每个 bot 一个授权目录(对照表),选完新目录,这个 bot 之后的调用只认新目录;已经在跑的不打断。
|
|
141
|
+
* 返回 { onMessage(msg, send), killAll(), botDirs },startRunner 用它,测试也直接用它(不用起 WebSocket)。
|
|
142
|
+
*/
|
|
143
|
+
// @意图#R640(2026-10-27): 目录按 bot 分:call 按 bot_id 查对照表,没有就拒绝 NO_DIR;pick_dir 带 bot_id,选完检查后记进对照表、写回配置(600),
|
|
144
|
+
// 回 dir_result {id, ok, bot_id, dir};老配置的单一 dir 忽略
|
|
145
|
+
// @意图#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() } = {}) {
|
|
147
|
+
let dirs = botDirsOf(cfg); // bot_id → 授权目录
|
|
148
|
+
const jobs = new Map(); // 调用 id → AbortController
|
|
149
|
+
const picks = new Map(); // 弹窗 id → AbortController
|
|
150
|
+
|
|
151
|
+
async function handleCall(send, msg) {
|
|
152
|
+
const { id, op, args } = msg;
|
|
153
|
+
const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
154
|
+
const dir = bot ? dirs[bot] : undefined; // 这次调用用开始时这个 bot 的目录;中途换目录不影响它
|
|
155
|
+
if (!dir) { send({ type: "result", id, ok: false, error: NO_DIR }); return; }
|
|
156
|
+
const ac = new AbortController();
|
|
157
|
+
jobs.set(id, ac);
|
|
158
|
+
log(`[${bot}] ${op === "bash" ? `bash:${args?.command}` : `${op}:${args?.path}`}`);
|
|
159
|
+
try {
|
|
160
|
+
const res = await runOp(op, args, dir, { signal: ac.signal, onData: (d) => send({ type: "output", id, data: Buffer.from(d).toString("base64") }) });
|
|
161
|
+
if (ac.signal.aborted) send({ type: "result", id, ok: false, error: "已中止" });
|
|
162
|
+
else send({ type: "result", id, ok: true, ...res });
|
|
163
|
+
} catch (e) {
|
|
164
|
+
send({ type: "result", id, ok: false, error: e?.message ?? String(e) });
|
|
165
|
+
} finally {
|
|
166
|
+
jobs.delete(id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function handlePick(send, msg) {
|
|
171
|
+
const { id } = msg;
|
|
172
|
+
const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
173
|
+
if (!bot) { send({ type: "dir_result", id, ok: false, error: "缺 bot_id" }); return; }
|
|
174
|
+
if (platform !== "darwin") { send({ type: "dir_result", id, ok: false, bot_id: bot, error: PICK_ONLY_MAC }); return; }
|
|
175
|
+
const ac = new AbortController();
|
|
176
|
+
picks.set(id, ac);
|
|
177
|
+
log(`网页上给 bot ${bot} 点了选择目录:请在弹出的窗口里选文件夹`);
|
|
178
|
+
try {
|
|
179
|
+
const picked = await pickDir({ signal: ac.signal });
|
|
180
|
+
if (ac.signal.aborted) return; // 服务器那边已经按超时处理了
|
|
181
|
+
if (!picked) { log("没换目录(点了取消)"); send({ type: "dir_result", id, ok: false, bot_id: bot, cancelled: true }); return; }
|
|
182
|
+
const real = await checkDir(picked);
|
|
183
|
+
dirs = { ...dirs, [bot]: real };
|
|
184
|
+
cfg.bot_dirs = { ...dirs };
|
|
185
|
+
delete cfg.dir; // 老格式的单一 dir 不再写回
|
|
186
|
+
saveConfig(cfg, file);
|
|
187
|
+
log(`bot ${bot} 的授权目录换成 ${real}`);
|
|
188
|
+
send({ type: "dir_result", id, ok: true, bot_id: bot, dir: real });
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (ac.signal.aborted) { log("选目录超时,窗口已关掉"); return; }
|
|
191
|
+
send({ type: "dir_result", id, ok: false, bot_id: bot, error: e?.message ?? String(e) });
|
|
192
|
+
} finally {
|
|
193
|
+
picks.delete(id);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
get botDirs() { return { ...dirs }; },
|
|
199
|
+
onMessage(msg, send) {
|
|
200
|
+
if (msg.type === "call") return handleCall(send, msg);
|
|
201
|
+
if (msg.type === "pick_dir") return handlePick(send, msg);
|
|
202
|
+
if (msg.type === "abort") { jobs.get(msg.id)?.abort(); picks.get(msg.id)?.abort(); }
|
|
203
|
+
return undefined;
|
|
204
|
+
},
|
|
205
|
+
killAll() {
|
|
206
|
+
for (const ac of jobs.values()) ac.abort();
|
|
207
|
+
for (const ac of picks.values()) ac.abort();
|
|
208
|
+
jobs.clear();
|
|
209
|
+
picks.clear();
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 连服务器并执行转发来的工具调用。断线会自动重连(不重放旧调用);令牌不对或网页上断开了就停。
|
|
216
|
+
* 返回 { stop, done }:stop() 立刻断开并杀掉正在跑的命令。
|
|
217
|
+
*/
|
|
218
|
+
// @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
|
|
219
|
+
// @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
|
|
220
|
+
// @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
|
|
221
|
+
export function startRunner(cfg, { log = say, retryMs = 2000, pickDir = pickDirMac, platform = process.platform, file = configPath() } = {}) {
|
|
222
|
+
checkNode();
|
|
223
|
+
const session = createSession(cfg, { log, pickDir, platform, file });
|
|
224
|
+
let ws = null;
|
|
225
|
+
let stopped = false;
|
|
226
|
+
let beat = null;
|
|
227
|
+
let resolveDone;
|
|
228
|
+
const done = new Promise((r) => { resolveDone = r; });
|
|
229
|
+
const finish = (why) => { if (!stopped) { stopped = true; } resolveDone(why); };
|
|
230
|
+
const send = (sock, msg) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(msg)); };
|
|
231
|
+
|
|
232
|
+
function connect() {
|
|
233
|
+
if (stopped) return;
|
|
234
|
+
const sock = new WebSocket(wsUrlOf(cfg.server));
|
|
235
|
+
ws = sock;
|
|
236
|
+
sock.addEventListener("open", () => {
|
|
237
|
+
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()}` });
|
|
239
|
+
beat = setInterval(() => send(sock, { type: "ping" }), HEARTBEAT_MS);
|
|
240
|
+
});
|
|
241
|
+
sock.addEventListener("message", (ev) => {
|
|
242
|
+
let msg;
|
|
243
|
+
try { msg = JSON.parse(typeof ev.data === "string" ? ev.data : Buffer.from(ev.data).toString("utf8")); } catch { return; }
|
|
244
|
+
if (msg.type === "ready") {
|
|
245
|
+
const n = Object.keys(session.botDirs).length;
|
|
246
|
+
log(n ? `已连上服务器 ${cfg.server},${n} 个 bot 选了目录。按 Ctrl-C 断开。`
|
|
247
|
+
: `已连上服务器 ${cfg.server},还没有 bot 选目录:请在每个 bot 的私聊右栏「我的电脑」里点「选择目录」。按 Ctrl-C 断开。`);
|
|
248
|
+
} else if (msg.type === "revoked") { session.killAll(); log("网页上已断开这台电脑,令牌已作废。要再连,请在网页上重新拿配对码配对。"); }
|
|
249
|
+
else void session.onMessage(msg, (m) => send(sock, m));
|
|
250
|
+
});
|
|
251
|
+
sock.addEventListener("close", (ev) => {
|
|
252
|
+
clearInterval(beat);
|
|
253
|
+
session.killAll(); // 断线:正在跑的命令杀掉、弹窗关掉,服务器那边按「设备离线」返回,重连后不重放
|
|
254
|
+
if (ws === sock) ws = null;
|
|
255
|
+
if (stopped) return;
|
|
256
|
+
if (ev.code === CLOSE_BAD_TOKEN) { log("服务器不认这个令牌(可能已经在网页上断开过)。请重新配对。"); finish("bad_token"); return; }
|
|
257
|
+
if (ev.code === CLOSE_REVOKED) { log("这台电脑已在网页上断开。请重新配对。"); finish("revoked"); return; }
|
|
258
|
+
log(`和服务器的连接断了,${retryMs / 1000} 秒后重连`);
|
|
259
|
+
setTimeout(connect, retryMs);
|
|
260
|
+
});
|
|
261
|
+
sock.addEventListener("error", () => { /* close 里处理 */ });
|
|
262
|
+
}
|
|
263
|
+
connect();
|
|
264
|
+
const stop = () => {
|
|
265
|
+
if (stopped) return;
|
|
266
|
+
stopped = true;
|
|
267
|
+
session.killAll();
|
|
268
|
+
clearInterval(beat);
|
|
269
|
+
try { ws?.close(1000, "runner stopped"); } catch { /* ignore */ }
|
|
270
|
+
resolveDone("stopped");
|
|
271
|
+
};
|
|
272
|
+
return { stop, done };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// @意图#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); };
|
|
280
|
+
process.on("SIGINT", quit);
|
|
281
|
+
process.on("SIGTERM", quit);
|
|
282
|
+
const why = await r.done;
|
|
283
|
+
process.exit(why === "stopped" ? 0 : 1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// @意图#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
|
|
288
|
+
// @意图#R640(2026-10-27): pair 带 --dir 只提示不报错;status 列出每个 bot 的目录;用法文字去掉 --dir
|
|
289
|
+
export async function main(argv, prog = progName()) {
|
|
290
|
+
const a = parseArgs(argv);
|
|
291
|
+
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() });
|
|
293
|
+
if (r.dirIgnored) console.log(PAIR_DIR_IGNORED);
|
|
294
|
+
const where = "目录:连上后在网页上每个 bot 的私聊里,点输入框下面「我的电脑」那一行选。";
|
|
295
|
+
if (a.start) {
|
|
296
|
+
console.log(`配对成功。${where}\n配置存在 ${r.file}(权限 600)。\n接着连服务器(--start)……`);
|
|
297
|
+
await runStart();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
console.log(`配对成功。${where}\n配置存在 ${r.file}(权限 600)。\n现在运行:${prog} start`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (a.cmd === "start") await runStart();
|
|
304
|
+
if (a.cmd === "status") {
|
|
305
|
+
const cfg = loadConfig();
|
|
306
|
+
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 的私聊里点「选择目录」)"}`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
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;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
|
|
315
|
+
|
|
316
|
+
/** 这个文件是不是被直接运行的(经符号链接运行也算:两边都换成真实路径再比)。 */
|
|
317
|
+
// @意图#R664(2026-10-29): npm 装好的命令是指向源码的符号链接,原来按字面路径比较会判成「不是直接运行」、什么都不做;改成真实路径比较
|
|
318
|
+
export function isMainModule(argv1 = process.argv[1], metaUrl = import.meta.url) {
|
|
319
|
+
return !!argv1 && real(argv1) === real(fileURLToPath(metaUrl));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 用法和提示里怎么称呼自己:npm 装好的(经 bin 里的 agent-hub-runner 链接运行,或文件在 node_modules 里)说 agent-hub-runner,
|
|
324
|
+
* 仓库里直接跑说 node <相对路径>。
|
|
325
|
+
*/
|
|
326
|
+
// @意图#R664(2026-10-29): 用法和配对成功提示按运行方式区分:装好的显示 agent-hub-runner …,仓库里显示 node 写法
|
|
327
|
+
export function progName(entry = process.argv[1], cwd = process.cwd()) {
|
|
328
|
+
if (!entry) return PKG_BIN;
|
|
329
|
+
if (path.basename(entry) === PKG_BIN) return PKG_BIN;
|
|
330
|
+
const r = real(entry);
|
|
331
|
+
if (/[\\/]node_modules[\\/]/.test(r)) return PKG_BIN;
|
|
332
|
+
return `node ${path.relative(cwd, r) || path.basename(r)}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (isMainModule()) {
|
|
336
|
+
main(process.argv.slice(2)).catch((e) => { console.error(e?.message ?? String(e)); process.exit(1); });
|
|
337
|
+
}
|