agent-hub-runner 0.2.2 → 0.3.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,3 +1,5 @@
1
+ <!-- @意图#R747(2026-11-06): 说明 .claude/skills 需运行器 0.3.1 -->
2
+ <!-- @意图#R745(2026-11-06): 说明 context_snapshot / 0.3.0 -->
1
3
  <!-- @意图#R729(2026-11-04): 加 browser install / status 和「bot 的浏览器」一节(0.2.0) -->
2
4
  <!-- @意图#R690(2026-11-01): 加「改动记录」一节:存在哪、打码、64 KB、30 天、怎么删 -->
3
5
  <!-- @意图#R732(2026-11-04): 常见问题加「升级了网页还说版本太旧」(0.2.2 起新版自动顶掉旧进程) -->
@@ -65,6 +67,11 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
65
67
  - 想放别处:设环境变量 `AGENT_RUNNER_CONFIG=/某个路径/config.json`。
66
68
  - 文件坏了:删掉它,回网页拿新配对码重新配对。
67
69
 
70
+
71
+ ## 项目上下文快照(0.3.0;`.claude/skills` 起 0.3.1)
72
+
73
+ 服务器在每轮主对话开始时可能调用只读操作 `context_snapshot`(没有路径参数):在**当前 bot 的授权文件夹根**里读取一份 AGENTS/CLAUDE 规则,并列出 `.pi/skills`、`.agents/skills`、`.claude/skills` 下技能的名称与说明(不上传技能全文)。目录外路径和越界符号链接一律拒绝。基础快照需要运行器 **0.3.0+**;发现 `.claude/skills` 需要 **0.3.1+**。
74
+
68
75
  ## 常见问题
69
76
 
70
77
  - `command not found: npx`:还没装 Node。
@@ -75,6 +82,7 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
75
82
  - 「这个账号已经连着一台电脑了」:一个账号同时只能连一台,先在网页抽屉「连着的电脑」里点「断开」。
76
83
  - 开机自启报「Bootstrap failed: 5: Input/output error」:这个后台任务以前被 macOS 记成了「已禁用」。0.2.1 起 `autostart on` 会自己解除并重试,直接再运行一次 `npx -y agent-hub-runner@latest autostart on` 就行;还不行就按它给的那条 `launchctl enable …` 命令运行后再试,或重启电脑再试。
77
84
  - 升级后网页还说「运行器版本太旧」:旧版本的运行器还在后台跑着(升级前开的终端,或者旧的开机自启),新版一看有人连着就退出了。0.2.2 起新版会自己把旧版本停掉、接上服务器;从 0.2.1 或更早升上来的,运行一次 `npx -y agent-hub-runner@latest autostart on` 就行,它会先停掉正在跑的那个,等新版本真连上了才说「已设开机自启」。
85
+ - 网页上点「升级」(0.3.2 起):抽屉里有新版本时点一下,运行器自己去 npm 拉最新版、校验后换上并重启,不用开终端。0.3.1 及更早不认这个按钮,仍用上面那条命令升一次。
78
86
 
79
87
  ## 断开、卸载
80
88
 
package/autostart.mjs CHANGED
@@ -20,7 +20,7 @@ export const THROTTLE_S = 10;
20
20
  /** 复制到 ~/.agent-runner/bin/<版本>/ 的文件(package.json 用来读版本号) */
21
21
  // @意图#R729(2026-11-04): 多复制 browser.mjs(runner.mjs 要 import 它)
22
22
  // @意图#R690(2026-11-01): 多复制 changes.mjs(runner.mjs 要 import 它,少了开机自启的副本起不来)
23
- export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "browser.mjs", "autostart.mjs", "package.json"];
23
+ export const COPY_FILES = ["runner.mjs", "ops.mjs", "project-context.mjs", "edit.mjs", "changes.mjs", "browser.mjs", "autostart.mjs", "package.json"];
24
24
  /** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
25
25
  export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
26
26
  export const ONLY_MAC = "开机自启目前只支持 Mac 和 Windows";
@@ -116,6 +116,17 @@ export function installCopy({ srcDir, version, env = process.env, platform = pro
116
116
  return path.join(cur, "runner.mjs");
117
117
  }
118
118
 
119
+ /**
120
+ * R750:校验 npm 包的 integrity。npm 给的是 "sha512-<base64>",对不上返回 false,调用方不得替换正在跑的版本。
121
+ * 只认 sha512。别的算法(sha1、没有 integrity)一律 false,不将就。
122
+ */
123
+ export function integrityOk(buf, integrity) {
124
+ const m = /^sha512-(.+)$/.exec(String(integrity ?? "").trim());
125
+ if (!m) return false;
126
+ const got = createHash("sha512").update(buf).digest("base64");
127
+ return got === m[1];
128
+ }
129
+
119
130
  /** 默认的 launchctl:返回 { code, stdout, stderr },不抛错。AGENT_RUNNER_LAUNCHCTL 可换成别的程序(测试用假的,不真加载) */
120
131
  export function runLaunchctl(args, env = process.env) {
121
132
  return new Promise((resolve) => {
package/ops.mjs CHANGED
@@ -6,6 +6,7 @@ import fs from "node:fs/promises";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { editText } from "./edit.mjs";
9
+ import { opContextSnapshot } from "./project-context.mjs";
9
10
 
10
11
  export const MAX_READ_BYTES = 20 * 1024 * 1024;
11
12
  /** bash 没给 timeout 时的默认上限(秒),到点杀掉整个进程组。 */
@@ -183,12 +184,15 @@ export function opBash(args, root, { onData, signal, platform = process.platform
183
184
 
184
185
  /** 按 op 分派;未知 op 拒绝。 */
185
186
  // @意图#R690(2026-11-01): write、edit 也收 extra(onChange 存改动记录)
186
- // @意图#R636(2026-10-27): 协议里只有这四种工具调用
187
+ // @意图#R745(2026-11-06): 另加只读 context_snapshot(无路径入参)
188
+ // @意图#R636(2026-10-27): 协议里原有四种工具调用
189
+ // @意图#R745(2026-11-06): 从客户已授权目录加载项目规则并按需提供技能
187
190
  export function runOp(op, args, root, extra) {
188
191
  const a = args && typeof args === "object" ? args : {};
189
192
  if (op === "read") return opRead(a, root);
190
193
  if (op === "write") return opWrite(a, root, extra);
191
194
  if (op === "edit") return opEdit(a, root, extra);
192
195
  if (op === "bash") return opBash(a, root, extra);
196
+ if (op === "context_snapshot") return opContextSnapshot(a, root); // 固定扫授权根,忽略路径参数
193
197
  return Promise.reject(new Error(`未知操作 ${op}`));
194
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub-runner",
3
- "version": "0.2.2",
3
+ "version": "0.3.2",
4
4
  "description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准),还能用它自己的独立浏览器",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "runner.mjs",
11
11
  "ops.mjs",
12
+ "project-context.mjs",
12
13
  "edit.mjs",
13
14
  "changes.mjs",
14
15
  "browser.mjs",
@@ -0,0 +1,402 @@
1
+ // @意图#R747(2026-11-06): 授权根增加 .claude/skills,兼容 Claude Code 项目技能目录
2
+ // @意图#R745(2026-11-06): 从客户已授权目录加载项目规则并按需提供技能
3
+ // 只扫授权根:一份 AGENTS/CLAUDE 候选 + .pi/skills + .agents/skills + .claude/skills;不读祖先、不读 ~/.agents / ~/.pi / ~/.claude。
4
+ import fs from "node:fs/promises";
5
+ import { existsSync, readFileSync, readdirSync, statSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ // 路径校验与 ops.mjs 同语义,避免与 ops 循环依赖。
9
+ export const inside = (real, root) => real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
10
+
11
+ export async function realOf(abs, depth = 0) {
12
+ if (depth > 40) throw new Error("符号链接太多层");
13
+ try {
14
+ return await fs.realpath(abs);
15
+ } catch (e) {
16
+ if (e?.code !== "ENOENT") throw e;
17
+ let st;
18
+ try { st = lstatSync(abs); } catch { st = undefined; }
19
+ if (st?.isSymbolicLink()) {
20
+ const target = path.resolve(path.dirname(abs), readlinkSync(abs));
21
+ return realOf(target, depth + 1);
22
+ }
23
+ const parent = path.dirname(abs);
24
+ if (parent === abs) throw e;
25
+ return path.join(await realOf(parent, depth + 1), path.basename(abs));
26
+ }
27
+ }
28
+
29
+ export const AGENTS_CANDIDATES = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
30
+ export const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
31
+ export const MAX_FILE_BYTES = 128 * 1024;
32
+ export const MAX_SKILLS = 100;
33
+ export const SKILL_DIRS = [".pi/skills", ".agents/skills", ".claude/skills"];
34
+ export const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
35
+
36
+ export function stripBom(s) {
37
+ return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
38
+ }
39
+
40
+ function toPosix(p) {
41
+ return p.split(path.sep).join("/");
42
+ }
43
+
44
+ /** 极简 ignore:注释、否定、末尾 /、* 与 **(不含完整 gitignore 语义,够 pi 技能树常用规则)。 */
45
+ export function createIgnoreMatcher() {
46
+ const rules = [];
47
+ return {
48
+ add(patterns) {
49
+ for (const raw of patterns) {
50
+ const line = String(raw);
51
+ const trimmed = line.trim();
52
+ if (!trimmed || (trimmed.startsWith("#") && !trimmed.startsWith("\\#"))) continue;
53
+ let negated = false;
54
+ let pattern = line;
55
+ if (pattern.startsWith("!")) { negated = true; pattern = pattern.slice(1); }
56
+ else if (pattern.startsWith("\\!")) pattern = pattern.slice(1);
57
+ if (pattern.startsWith("/")) pattern = pattern.slice(1);
58
+ rules.push({ negated, pattern: pattern.replace(/\\/g, "/"), dirOnly: pattern.endsWith("/") });
59
+ }
60
+ },
61
+ ignores(rel, isDir = false) {
62
+ const target = toPosix(rel).replace(/^\/+/, "");
63
+ let ignored = false;
64
+ for (const r of rules) {
65
+ const pat = r.dirOnly ? r.pattern.slice(0, -1) : r.pattern;
66
+ if (r.dirOnly && !isDir) continue;
67
+ if (matchGlob(pat, target) || matchGlob(pat, target.split("/").pop() || "")) {
68
+ ignored = !r.negated;
69
+ }
70
+ }
71
+ return ignored;
72
+ },
73
+ };
74
+ }
75
+
76
+ function matchGlob(pat, text) {
77
+ if (pat === text) return true;
78
+ if (pat.endsWith("/**")) {
79
+ const base = pat.slice(0, -3);
80
+ return text === base || text.startsWith(base + "/");
81
+ }
82
+ if (pat.includes("**")) {
83
+ const parts = pat.split("**");
84
+ if (parts.length !== 2) return false;
85
+ const [a, b] = parts;
86
+ if (a && !text.startsWith(a.replace(/\/$/, ""))) {
87
+ if (!text.includes("/" + a.replace(/^\/|\/$/g, ""))) return false;
88
+ }
89
+ const rest = a ? text.slice(Math.max(text.indexOf(a.replace(/\/$/, "")) + a.replace(/\/$/, "").length, 0)) : text;
90
+ const needle = b.replace(/^\//, "");
91
+ return !needle || rest.includes(needle) || rest.endsWith(needle.replace(/^\//, ""));
92
+ }
93
+ if (!pat.includes("*") && !pat.includes("?")) {
94
+ return text === pat || text.endsWith("/" + pat);
95
+ }
96
+ const esc = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
97
+ return new RegExp(`(?:^|/)${esc}$`).test(text) || new RegExp(`^${esc}$`).test(text);
98
+ }
99
+
100
+ function addIgnoreRules(ig, dir, root) {
101
+ const relativeDir = path.relative(root, dir);
102
+ const prefix = relativeDir && relativeDir !== "." ? `${toPosix(relativeDir)}/` : "";
103
+ for (const filename of IGNORE_FILE_NAMES) {
104
+ const ignorePath = path.join(dir, filename);
105
+ if (!existsSync(ignorePath)) continue;
106
+ try {
107
+ const content = readFileSync(ignorePath, "utf8");
108
+ const patterns = content.split(/\r?\n/).map((line) => {
109
+ const trimmed = line.trim();
110
+ if (!trimmed || (trimmed.startsWith("#") && !trimmed.startsWith("\\#"))) return null;
111
+ let pattern = line;
112
+ let negated = false;
113
+ if (pattern.startsWith("!")) { negated = true; pattern = pattern.slice(1); }
114
+ else if (pattern.startsWith("\\!")) pattern = pattern.slice(1);
115
+ if (pattern.startsWith("/")) pattern = pattern.slice(1);
116
+ const prefixed = prefix ? `${prefix}${pattern}` : pattern;
117
+ return negated ? `!${prefixed}` : prefixed;
118
+ }).filter(Boolean);
119
+ if (patterns.length) ig.add(patterns);
120
+ } catch { /* ignore unreadable */ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 小型 frontmatter:只认 name / description / disable-model-invocation(及 camelCase)。
126
+ * 支持单双引号、| / > / |- / >- / |> / >| 多行块。
127
+ */
128
+ export function parseSkillFrontmatter(raw) {
129
+ const text = stripBom(String(raw ?? ""));
130
+ if (!text.startsWith("---")) return null;
131
+ const nl = text.indexOf("\n");
132
+ if (nl < 0) return null;
133
+ const end = text.indexOf("\n---", nl);
134
+ if (end < 0) return null;
135
+ const body = text.slice(nl + 1, end);
136
+ const out = {};
137
+ const lines = body.split(/\r?\n/);
138
+ let i = 0;
139
+ while (i < lines.length) {
140
+ const line = lines[i];
141
+ const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
142
+ if (!m) { i += 1; continue; }
143
+ const key = m[1];
144
+ let val = m[2];
145
+ if (val === "" || /^[|>](?:[+-]\d?|\d[+-]?)$/.test(val) || val === "|>" || val === ">|") {
146
+ const block = [];
147
+ i += 1;
148
+ while (i < lines.length) {
149
+ const L = lines[i];
150
+ if (L === "" || L.startsWith(" ") || L.startsWith("\t")) {
151
+ block.push(L.replace(/^ /, "").replace(/^\t/, ""));
152
+ i += 1;
153
+ continue;
154
+ }
155
+ if (/^[A-Za-z0-9_-]+\s*:/.test(L)) break;
156
+ block.push(L);
157
+ i += 1;
158
+ }
159
+ while (block.length && block[block.length - 1] === "") block.pop();
160
+ out[key] = block.join("\n");
161
+ continue;
162
+ }
163
+ if (val.startsWith('"')) {
164
+ // 双引号:优先按 JSON 字符串解(支持 \" \\ \n);失败再剥一层皮
165
+ try { out[key] = JSON.parse(val); }
166
+ catch {
167
+ out[key] = val.length >= 2 && val.endsWith('"') ? val.slice(1, -1) : val.slice(1);
168
+ }
169
+ } else if (val.startsWith("'")) {
170
+ // 单引号:YAML 里 '' 表示一个 '
171
+ let s = val.endsWith("'") ? val.slice(1, -1) : val.slice(1);
172
+ out[key] = s.replace(/''/g, "'");
173
+ } else if (val === "true") out[key] = true;
174
+ else if (val === "false") out[key] = false;
175
+ else out[key] = val;
176
+ i += 1;
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function skillFromFile(filePath) {
182
+ let raw;
183
+ try { raw = readFileSync(filePath, "utf8"); } catch { return { skill: null, notice: `无法读取技能文件:${filePath}` }; }
184
+ let bytes;
185
+ try { bytes = Buffer.byteLength(raw, "utf8"); } catch { bytes = raw.length; }
186
+ if (bytes > MAX_FILE_BYTES) {
187
+ return { skill: null, notice: `技能文件超过 ${MAX_FILE_BYTES} 字节,已跳过:${filePath}` };
188
+ }
189
+ const fm = parseSkillFrontmatter(raw);
190
+ const declared = path.basename(filePath) === "SKILL.md";
191
+ if (!fm) return { skill: null, notice: declared ? `技能缺少有效 YAML frontmatter,已跳过:${filePath}` : null };
192
+ const description = typeof fm.description === "string" ? fm.description.trim().slice(0, 1024) : "";
193
+ if (!description) return { skill: null, notice: declared ? `技能缺少 description,已跳过:${filePath}` : null };
194
+ const parentDirName = path.basename(path.dirname(filePath));
195
+ const name = typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : parentDirName;
196
+ const disable = fm["disable-model-invocation"] === true || fm.disableModelInvocation === true;
197
+ return {
198
+ skill: {
199
+ name,
200
+ description,
201
+ filePath,
202
+ baseDir: path.dirname(filePath),
203
+ disableModelInvocation: disable,
204
+ _bytes: Buffer.byteLength(JSON.stringify({ name, description, filePath, baseDir: path.dirname(filePath) }), "utf8"),
205
+ },
206
+ notice: null,
207
+ };
208
+ }
209
+
210
+ function markSkillsCapped(acc, detail) {
211
+ if (acc.capped) return;
212
+ acc.capped = true;
213
+ acc.stopped = true;
214
+ acc.notices.push(detail || `技能超过 ${MAX_SKILLS} 个上限,已停止发现`);
215
+ }
216
+
217
+ async function ensureInside(abs, authRoot, notices, label) {
218
+ let real;
219
+ try { real = await realOf(abs); } catch (e) {
220
+ notices.push(`无法解析${label}:${abs}(${e?.code ?? e?.message ?? e})`);
221
+ return null;
222
+ }
223
+ if (!inside(real, authRoot)) {
224
+ notices.push(`目录外路径已拒绝:${label}(授权目录是 ${authRoot})`);
225
+ return null;
226
+ }
227
+ return real;
228
+ }
229
+
230
+ async function loadSkillsFromDirInternal(dir, includeRootFiles, ig, root, acc, authRoot) {
231
+ if (!existsSync(dir)) return;
232
+ if (acc.skills.length >= MAX_SKILLS) { markSkillsCapped(acc); return; }
233
+ // 循环 symlink 指向已扫过的目录时跳过,不能卡住运行器事件循环。
234
+ if (acc.visited.has(dir)) return;
235
+ acc.visited.add(dir);
236
+ addIgnoreRules(ig, dir, root);
237
+ let entries;
238
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
239
+
240
+ // SKILL.md 优先:有则加载并停止向子目录递归(pi 行为)
241
+ for (const entry of entries) {
242
+ if (entry.name !== "SKILL.md") continue;
243
+ const fullPath = path.join(dir, entry.name);
244
+ let isFile = entry.isFile();
245
+ if (entry.isSymbolicLink()) {
246
+ try { isFile = statSync(fullPath).isFile(); } catch { continue; }
247
+ }
248
+ const relPath = toPosix(path.relative(root, fullPath));
249
+ if (!isFile || ig.ignores(relPath, false)) continue;
250
+ const realFile = await ensureInside(fullPath, authRoot, acc.notices, relPath || entry.name);
251
+ if (!realFile) return; // 越界 symlink:记 notice,本目录当作技能根已处理完
252
+ const { skill, notice } = skillFromFile(realFile);
253
+ if (notice) acc.notices.push(notice);
254
+ if (skill) {
255
+ if (acc.total + skill._bytes > MAX_TOTAL_BYTES) {
256
+ acc.notices.push(`技能总大小将超过 ${MAX_TOTAL_BYTES} 字节,已停止发现:${fullPath}`);
257
+ acc.stopped = true;
258
+ return;
259
+ }
260
+ if (acc.skills.length >= MAX_SKILLS) { markSkillsCapped(acc); return; }
261
+ if (acc.names.has(skill.name)) acc.notices.push(`技能重名,已跳过后一份:${skill.name}(${skill.filePath})`);
262
+ if (!acc.names.has(skill.name)) {
263
+ acc.names.add(skill.name);
264
+ acc.total += skill._bytes;
265
+ const { _bytes, ...rest } = skill;
266
+ acc.skills.push(rest);
267
+ }
268
+ }
269
+ return; // 有 SKILL.md 不再递归
270
+ }
271
+
272
+ for (const entry of entries) {
273
+ if (acc.stopped || acc.skills.length >= MAX_SKILLS) return;
274
+ // 技能树内部仍跳过隐藏项;根上的 .pi / .agents / .claude 由 SKILL_DIRS 显式进入,不会走到这里
275
+ if (entry.name.startsWith(".")) continue;
276
+ if (entry.name === "node_modules") continue;
277
+ const fullPath = path.join(dir, entry.name);
278
+ let isDirectory = entry.isDirectory();
279
+ let isFile = entry.isFile();
280
+ if (entry.isSymbolicLink()) {
281
+ try {
282
+ const st = statSync(fullPath);
283
+ isDirectory = st.isDirectory();
284
+ isFile = st.isFile();
285
+ } catch { continue; }
286
+ }
287
+ const relPath = toPosix(path.relative(root, fullPath));
288
+ if (ig.ignores(relPath, isDirectory)) continue;
289
+ if (isDirectory) {
290
+ const realDir = await ensureInside(fullPath, authRoot, acc.notices, relPath || entry.name);
291
+ if (!realDir) continue; // 越界目录 symlink:跳过并已记 notice
292
+ await loadSkillsFromDirInternal(realDir, false, ig, root, acc, authRoot);
293
+ continue;
294
+ }
295
+ if (!isFile || !includeRootFiles || !entry.name.endsWith(".md")) continue;
296
+ const realFile = await ensureInside(fullPath, authRoot, acc.notices, relPath || entry.name);
297
+ if (!realFile) continue;
298
+ const { skill, notice } = skillFromFile(realFile);
299
+ if (notice) acc.notices.push(notice);
300
+ if (!skill) continue;
301
+ if (acc.total + skill._bytes > MAX_TOTAL_BYTES) {
302
+ acc.notices.push(`技能总大小将超过 ${MAX_TOTAL_BYTES} 字节,已停止发现:${fullPath}`);
303
+ acc.stopped = true;
304
+ return;
305
+ }
306
+ if (acc.skills.length >= MAX_SKILLS) { markSkillsCapped(acc); return; }
307
+ if (acc.names.has(skill.name)) acc.notices.push(`技能重名,已跳过后一份:${skill.name}(${skill.filePath})`);
308
+ if (!acc.names.has(skill.name)) {
309
+ acc.names.add(skill.name);
310
+ acc.total += skill._bytes;
311
+ const { _bytes, ...rest } = skill;
312
+ acc.skills.push(rest);
313
+ }
314
+ }
315
+ }
316
+
317
+ async function resolveRootFile(root, name) {
318
+ const abs = path.join(root, name);
319
+ if (!existsSync(abs)) return null;
320
+ let st;
321
+ try { st = statSync(abs); } catch { return null; }
322
+ if (!st.isFile()) return null;
323
+ let real;
324
+ try { real = await realOf(abs); } catch (e) {
325
+ return { error: `无法解析路径:${name}(${e?.code ?? e?.message ?? e})` };
326
+ }
327
+ if (!inside(real, root)) return { error: `目录外路径已拒绝:${name}(授权目录是 ${root})` };
328
+ return { real, size: st.size };
329
+ }
330
+
331
+ /**
332
+ * 在授权根目录发现项目上下文。无路径入参;越界 symlink 拒绝。
333
+ * @returns {{ kind: 'project_context', contextFiles: {path,content}[], skills: object[], notices: string[] }}
334
+ */
335
+ // @意图#R747(2026-11-06): 授权根增加 .claude/skills,兼容 Claude Code 项目技能目录
336
+ // @意图#R745(2026-11-06): 从客户已授权目录加载项目规则并按需提供技能
337
+ export async function discoverProjectContext(root) {
338
+ const notices = [];
339
+ const contextFiles = [];
340
+ let total = 0;
341
+
342
+ for (const name of AGENTS_CANDIDATES) {
343
+ const got = await resolveRootFile(root, name);
344
+ if (!got) continue;
345
+ if (got.error) { notices.push(got.error); break; }
346
+ if (got.size > MAX_FILE_BYTES) {
347
+ notices.push(`${name} 超过 ${MAX_FILE_BYTES} 字节,未注入项目规则(避免撑爆提示)`);
348
+ break;
349
+ }
350
+ if (total + got.size > MAX_TOTAL_BYTES) {
351
+ notices.push(`${name} 会使总大小超过 ${MAX_TOTAL_BYTES} 字节,未注入项目规则`);
352
+ break;
353
+ }
354
+ let raw;
355
+ try { raw = await fs.readFile(got.real, "utf8"); } catch (e) {
356
+ notices.push(`无法读取 ${name}:${e?.message ?? e}`);
357
+ break;
358
+ }
359
+ const content = stripBom(raw);
360
+ const bytes = Buffer.byteLength(content, "utf8");
361
+ if (bytes > MAX_FILE_BYTES) {
362
+ notices.push(`${name} 超过 ${MAX_FILE_BYTES} 字节,未注入项目规则(避免撑爆提示)`);
363
+ break;
364
+ }
365
+ contextFiles.push({ path: got.real, content });
366
+ total += bytes;
367
+ break; // 只取最高优先级一份
368
+ }
369
+
370
+ const acc = { skills: [], notices, names: new Set(), visited: new Set(), total, stopped: false, capped: false };
371
+ for (const rel of SKILL_DIRS) {
372
+ if (acc.stopped) break;
373
+ const dir = path.join(root, rel);
374
+ if (!existsSync(dir)) continue;
375
+ let realDir;
376
+ try { realDir = await realOf(dir); } catch (e) {
377
+ notices.push(`无法解析技能目录 ${rel}:${e?.code ?? e?.message ?? e}`);
378
+ continue;
379
+ }
380
+ if (!inside(realDir, root)) {
381
+ notices.push(`目录外路径已拒绝:${rel}(授权目录是 ${root})`);
382
+ continue;
383
+ }
384
+ const ig = createIgnoreMatcher();
385
+ // authRoot 用授权根(非 skills 子根),越界 symlink 相对整棵授权树判断
386
+ await loadSkillsFromDirInternal(realDir, true, ig, realDir, acc, root);
387
+ }
388
+
389
+ return {
390
+ kind: "project_context",
391
+ contextFiles,
392
+ skills: acc.skills,
393
+ notices,
394
+ };
395
+ }
396
+
397
+ /** ops 入口:忽略任何路径参数,只扫授权根。 */
398
+ // @意图#R747(2026-11-06): 授权根增加 .claude/skills,兼容 Claude Code 项目技能目录
399
+ // @意图#R745(2026-11-06): 从客户已授权目录加载项目规则并按需提供技能
400
+ export async function opContextSnapshot(_args, root) {
401
+ return discoverProjectContext(root);
402
+ }
package/runner.mjs CHANGED
@@ -28,7 +28,7 @@ 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
30
  import { browserStatus, createBrowserManager, installBrowser } from "./browser.mjs";
31
- import { autostartOff, autostartOffWindows, autostartOn, autostartOnWindows, autostartStatus, autostartStatusWindows, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
31
+ import { autostartOff, autostartOffWindows, autostartOn, autostartOnWindows, autostartStatus, autostartStatusWindows, COPY_FILES, installCopy, integrityOk, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
32
32
 
33
33
  export const PROTOCOL_VERSION = 1;
34
34
  /** npm 包名,也是装好后的命令名 */
@@ -438,7 +438,7 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
438
438
  // @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
439
439
  // @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
440
440
  // @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
441
- 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" } = {}) {
441
+ 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", selfUpdate: selfUpdateFn } = {}) {
442
442
  checkNode();
443
443
  let fails = 0;
444
444
  const session = createSession(cfg, { log, pickDir, platform, file });
@@ -446,12 +446,16 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
446
446
  let ws = null;
447
447
  let stopped = false;
448
448
  let beat = null;
449
+ let retry = null;
449
450
  let resolveDone;
450
451
  const done = new Promise((r) => { resolveDone = r; });
451
452
  const finish = (why) => { if (!stopped) { stopped = true; } resolveDone(why); };
452
453
  const send = (sock, msg) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(msg)); };
453
454
  const browserInfo = () => { try { const b = browserStatus(); return { installed: b.installed, version: b.version }; } catch { return { installed: false, version: null }; } };
454
455
 
456
+ // @意图#R750(2026-11-07): 网页点「升级」发来 self_update(不带参数)。自己查最新版、校验、换上、拉起新版,回一条结果后正常退出,
457
+ // 让新版接上。失败了回原因,旧版继续跑。同时只跑一个,重复发来的忽略。
458
+ let updating = false;
455
459
  function connect() {
456
460
  if (stopped) return;
457
461
  const sock = new WebSocket(wsUrlOf(cfg.server));
@@ -471,6 +475,7 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
471
475
  log(n ? `已连上服务器 ${cfg.server},${n} 个 bot 选了目录。${tail}`
472
476
  : `已连上服务器 ${cfg.server},还没有 bot 选目录:请在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」。${tail}`);
473
477
  } else if (msg.type === "revoked") { session.killAll(); log("网页上已断开这台电脑,令牌已作废。要再连,请在网页上重新拿配对码配对。"); }
478
+ else if (msg.type === "self_update") { void runSelfUpdate(sock); }
474
479
  else void session.onMessage(msg, (m) => send(sock, m));
475
480
  });
476
481
  sock.addEventListener("close", (ev) => {
@@ -494,6 +499,7 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
494
499
  session.killAll();
495
500
  void session.closeBrowsers(); // R729
496
501
  clearInterval(beat);
502
+ clearTimeout(retry);
497
503
  try { ws?.close(1000, "runner stopped"); } catch { /* ignore */ }
498
504
  resolveDone("stopped");
499
505
  };
@@ -622,6 +628,90 @@ export async function waitNewHolder(file, { version = RUNNER_VERSION, ms = 10000
622
628
  }
623
629
  }
624
630
 
631
+ /**
632
+ * R750:自己升级。版本号自己去 npm 查,不接收调用方给的版本或命令。
633
+ * 下载的包按 integrity 校验 sha512,对不上就不替换。
634
+ * fetchLatest / download / extract / spawnNext 都可以换成假的(测试用),默认走真的 npm。
635
+ * 返回 { ok:true, version } 或 { ok:false, error }。成功时新进程已经拉起,调用方负责退出自己。
636
+ */
637
+ // @意图#R750(2026-11-07): 网页点「升级」,运行器自己拉最新版换上,用户不用开终端。指令不带参数,目标版本自己查,
638
+ // 校验不过就不动旧版。没开开机自启的也拉一个后台新进程接上,不用先 Ctrl-C。
639
+ export const NPM_PACK_URL = (version) => `https://registry.npmjs.org/agent-hub-runner/-/agent-hub-runner-${version}.tgz`;
640
+ export async function fetchLatestPack(fetchImpl = globalThis.fetch, timeoutMs = 8000) {
641
+ const r = await fetchImpl("https://registry.npmjs.org/agent-hub-runner/latest", { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
642
+ if (!r.ok) return null;
643
+ const b = await r.json().catch(() => null);
644
+ const version = typeof b?.version === "string" ? b.version : "";
645
+ const integrity = typeof b?.dist?.integrity === "string" ? b.dist.integrity : "";
646
+ if (!/^\d+\.\d+\.\d+/.test(version) || !integrity.startsWith("sha512-")) return null;
647
+ return { version, integrity, tarball: typeof b?.dist?.tarball === "string" ? b.dist.tarball : NPM_PACK_URL(version) };
648
+ }
649
+ /** 下载 tgz。地址只接受 registry.npmjs.org,别的一律拒绝,不跟随调用方给的地址。 */
650
+ export async function downloadPack(latest, fetchImpl = globalThis.fetch, timeoutMs = 20000) {
651
+ const url = new URL(latest.tarball);
652
+ if (url.protocol !== "https:" || url.hostname !== "registry.npmjs.org") throw new Error("只从 registry.npmjs.org 下载");
653
+ const r = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
654
+ if (!r.ok) throw new Error(`下载失败 ${r.status}`);
655
+ return Buffer.from(await r.arrayBuffer());
656
+ }
657
+ /** 解压 tgz 到临时目录,返回 package/ 子目录(npm 包的根)。只取运行器自己那几份文件,不执行包里的任何东西。 */
658
+ export async function extractPack(buf) {
659
+ const { mkdtempSync } = await import("node:fs");
660
+ const dir = mkdtempSync(path.join(os.tmpdir(), "agent-runner-upd-"));
661
+ const tar = spawn("tar", ["-xzf", "-", "-C", dir], { stdio: ["pipe", "ignore", "ignore"] });
662
+ const done = new Promise((resolve, reject) => { tar.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`解压失败 ${code}`))); tar.on("error", reject); });
663
+ tar.stdin.end(buf);
664
+ await done;
665
+ const pkg = path.join(dir, "package");
666
+ const { readdirSync } = await import("node:fs");
667
+ for (const name of readdirSync(pkg)) if (!COPY_FILES.includes(name)) rmSync(path.join(pkg, name), { recursive: true, force: true });
668
+ return pkg;
669
+ }
670
+ /**
671
+ * 拉起新版后台进程。新版从刚装好的副本起,不从下载的临时目录起。
672
+ * 开机自启的交给 launchd / 计划任务重新加载(新版按 R732 顶掉旧的);没开的自己起一个后台进程,等它拿到锁。
673
+ */
674
+ export async function spawnUpdated({ version, file, env = process.env, platform = process.platform, launchctl, schtasks, spawnFn = spawn, wait = waitNewHolder }) {
675
+ const script = path.join(runnerHome(env), "bin", version, "runner.mjs");
676
+ if (platform === "win32" && env.AGENT_RUNNER_LAUNCHD === "1") {
677
+ await autostartOnWindows({ configFile: file, version, srcDir: path.dirname(script), execPath: process.execPath, env, schtasks });
678
+ } else if (platform === "darwin" && env.AGENT_RUNNER_LAUNCHD === "1") {
679
+ await autostartOn({ configFile: file, version, srcDir: path.dirname(script), execPath: process.execPath, env, launchctl, platform });
680
+ } else {
681
+ const child = spawnFn(process.execPath, [script, "start"], { detached: true, stdio: "ignore", env: { ...env, AGENT_RUNNER_LAUNCHD: "" } });
682
+ child.unref();
683
+ }
684
+ const got = await wait(file, { version });
685
+ if (!got) throw new Error("新版没有接上");
686
+ return got;
687
+ }
688
+ /**
689
+ * R750:处理一条 self_update。不看消息内容(版本号、命令都忽略),只跑 selfUpdate。
690
+ * 回一条 self_update_result。成功返回 true,调用方退出让新版接上;失败返回 false,旧版继续跑。
691
+ */
692
+ export async function handleSelfUpdate({ file, platform, selfUpdate: run = selfUpdate, log = () => {} }, send) {
693
+ const r = await run({ file, env: process.env, platform }).catch((e) => ({ ok: false, error: e?.message ?? String(e) }));
694
+ send({ type: "self_update_result", ok: r.ok === true, ...(r.ok ? { version: r.version } : { error: r.error ?? "升级失败" }) });
695
+ if (r.ok) { log(`已换到 ${r.version},这个进程退出,新版接上`); return true; }
696
+ log(`升级没成:${r.error}`);
697
+ return false;
698
+ }
699
+ export async function selfUpdate({ file = configPath(), env = process.env, platform = process.platform, fetchLatest = () => fetchLatestPack(), download = downloadPack, extract = extractPack, spawnNext = spawnUpdated, currentVersion = RUNNER_VERSION } = {}) {
700
+ const latest = await fetchLatest().catch(() => null);
701
+ if (!latest) return { ok: false, error: "查不到最新版,没有更换" };
702
+ if (!newerVersion(latest.version, currentVersion)) return { ok: false, error: `已经是最新版(${currentVersion})` };
703
+ const buf = await download(latest).catch(() => null);
704
+ if (!buf) return { ok: false, error: "下载失败,没有更换" };
705
+ if (!integrityOk(buf, latest.integrity)) return { ok: false, error: "下载的包校验对不上,没有更换" };
706
+ let srcDir;
707
+ try { srcDir = await extract(buf, latest.version); } catch (e) { return { ok: false, error: `解压失败,没有更换:${e?.message ?? e}` }; }
708
+ installCopy({ srcDir, version: latest.version, env, platform });
709
+ try {
710
+ await spawnNext({ version: latest.version, file, env });
711
+ } catch (e) { return { ok: false, error: `新版没接上,没有换掉正在跑的:${e?.message ?? e}` }; }
712
+ return { ok: true, version: latest.version };
713
+ }
714
+
625
715
  const here = () => path.dirname(fileURLToPath(import.meta.url));
626
716
  // @意图#R666(2026-10-29): 开启自启:前台有一个在跑就先停掉它(不然后台那个起来就因为锁退出了),再交给 autostart.mjs(复制自己、写 plist、加载)
627
717
  // @意图#R725(2026-11-03): Windows 走计划任务(autostartOnWindows),Mac 走 launchd。提示里的「这台 Mac」按平台写。