agent-hub-runner 0.2.1 → 0.3.1
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 +9 -0
- package/autostart.mjs +1 -1
- package/ops.mjs +5 -1
- package/package.json +2 -1
- package/project-context.mjs +402 -0
- package/runner.mjs +44 -8
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
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 天、怎么删 -->
|
|
5
|
+
<!-- @意图#R732(2026-11-04): 常见问题加「升级了网页还说版本太旧」(0.2.2 起新版自动顶掉旧进程) -->
|
|
3
6
|
<!-- @意图#R731(2026-11-04): 常见问题加开机自启「Bootstrap failed: 5」(0.2.1 起自动处理) -->
|
|
4
7
|
<!-- @意图#R666(2026-10-29): 改成不用全局安装的 npx 一条命令(码@地址)、Mac 开机自启、stop / uninstall、常见报错 -->
|
|
5
8
|
<!-- @意图#R664(2026-10-29): npm 包 agent-hub-runner 的说明(随包发布):给客户看的安装、配对、再连、查看、断开、卸载和安全提示 -->
|
|
@@ -64,6 +67,11 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
|
|
|
64
67
|
- 想放别处:设环境变量 `AGENT_RUNNER_CONFIG=/某个路径/config.json`。
|
|
65
68
|
- 文件坏了:删掉它,回网页拿新配对码重新配对。
|
|
66
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
|
+
|
|
67
75
|
## 常见问题
|
|
68
76
|
|
|
69
77
|
- `command not found: npx`:还没装 Node。
|
|
@@ -73,6 +81,7 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
|
|
|
73
81
|
- 「配对码过期了 / 已经用过了 / 已经作废了」:网页上点「换一个码」再运行新命令。
|
|
74
82
|
- 「这个账号已经连着一台电脑了」:一个账号同时只能连一台,先在网页抽屉「连着的电脑」里点「断开」。
|
|
75
83
|
- 开机自启报「Bootstrap failed: 5: Input/output error」:这个后台任务以前被 macOS 记成了「已禁用」。0.2.1 起 `autostart on` 会自己解除并重试,直接再运行一次 `npx -y agent-hub-runner@latest autostart on` 就行;还不行就按它给的那条 `launchctl enable …` 命令运行后再试,或重启电脑再试。
|
|
84
|
+
- 升级后网页还说「运行器版本太旧」:旧版本的运行器还在后台跑着(升级前开的终端,或者旧的开机自启),新版一看有人连着就退出了。0.2.2 起新版会自己把旧版本停掉、接上服务器;从 0.2.1 或更早升上来的,运行一次 `npx -y agent-hub-runner@latest autostart on` 就行,它会先停掉正在跑的那个,等新版本真连上了才说「已设开机自启」。
|
|
76
85
|
|
|
77
86
|
## 断开、卸载
|
|
78
87
|
|
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";
|
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
|
-
// @意图#
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
@@ -510,12 +510,22 @@ export const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } ca
|
|
|
510
510
|
* 拿锁:别的活着的进程拿着就返回 { ok: false, pid, launchd };否则写上自己(600)返回 { ok: true, release }。
|
|
511
511
|
* 进程死了留下的旧锁直接覆盖。
|
|
512
512
|
*/
|
|
513
|
+
/** R732:a 比 b 新(只比 x.y.z;带预发布后缀的算旧一点;b 不是版本号 = 很旧) */
|
|
514
|
+
export function newerVersion(a, b) {
|
|
515
|
+
const re = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/;
|
|
516
|
+
const pa = re.exec(String(a ?? "")); const pb = re.exec(String(b ?? ""));
|
|
517
|
+
if (!pa) return false;
|
|
518
|
+
if (!pb) return true;
|
|
519
|
+
for (let i = 1; i <= 3; i++) { const d = Number(pa[i]) - Number(pb[i]); if (d) return d > 0; }
|
|
520
|
+
return !String(a).includes("-") && String(b).includes("-");
|
|
521
|
+
}
|
|
522
|
+
// @意图#R732(2026-11-04): 锁里记版本号;拿不到锁时告诉调用方对方是不是旧版本(older:锁里没版本号或比我旧),旧的由 runStart 顶掉
|
|
513
523
|
// @意图#R666(2026-10-29): 防互踢(king 定必须做):开机自启的在后台跑着,再在终端里 start 就提示「已经在后台连着」退出,不再两个互相顶
|
|
514
|
-
export function takeLock(file = configPath(), { pid = process.pid, launchd = process.env.AGENT_RUNNER_LAUNCHD === "1" } = {}) {
|
|
524
|
+
export function takeLock(file = configPath(), { pid = process.pid, launchd = process.env.AGENT_RUNNER_LAUNCHD === "1", version = RUNNER_VERSION } = {}) {
|
|
515
525
|
const cur = readLock(file);
|
|
516
|
-
if (cur && cur.pid !== pid && pidAlive(cur.pid)) return { ok: false, pid: cur.pid, launchd: !!cur.launchd };
|
|
526
|
+
if (cur && cur.pid !== pid && pidAlive(cur.pid)) return { ok: false, pid: cur.pid, launchd: !!cur.launchd, version: cur.version ?? null, older: newerVersion(version, cur.version) };
|
|
517
527
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
518
|
-
writeFileSync(lockPath(file), `${JSON.stringify({ pid, launchd, started_at: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
528
|
+
writeFileSync(lockPath(file), `${JSON.stringify({ pid, launchd, version, started_at: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
519
529
|
return { ok: true, release: () => { if (readLock(file)?.pid === pid) rmSync(lockPath(file), { force: true }); } };
|
|
520
530
|
}
|
|
521
531
|
|
|
@@ -532,6 +542,7 @@ async function autostartOffQuietly(file, deps) {
|
|
|
532
542
|
} catch { return false; }
|
|
533
543
|
}
|
|
534
544
|
|
|
545
|
+
// @意图#R732(2026-11-04): 拿锁时发现拿着锁的是旧版本(或没记版本的老运行器)就先让它退出再接管,客户升级后不用自己停旧进程;同版本或更新的照旧提示退出
|
|
535
546
|
// @意图#R666(2026-10-29): 先拿锁(后台已在跑就提示退出 0);开机自启时启动前日志超 5MB 清空、配置坏了/没配对也退出 0(不让 launchd 反复拉起);
|
|
536
547
|
// 结束一律退出码 0(Ctrl-C、被网页断开、令牌失效、被顶掉),被断开 / 令牌失效时顺手关掉自启
|
|
537
548
|
// @意图#R663(2026-10-29): 前台跑 start,Ctrl-C 断开(pair --start 和 start 共用)
|
|
@@ -544,7 +555,14 @@ async function runStart(prog = progName(), deps = {}) {
|
|
|
544
555
|
if (background) { console.error(explainError(e)); process.exit(0); }
|
|
545
556
|
throw e;
|
|
546
557
|
}
|
|
547
|
-
|
|
558
|
+
let lock = takeLock(file);
|
|
559
|
+
if (!lock.ok && lock.older) {
|
|
560
|
+
// R732:后台是旧版本(升级前起的那个还在内存里):让它退出,我来接
|
|
561
|
+
const old = lock;
|
|
562
|
+
await stopPid(old.pid);
|
|
563
|
+
lock = takeLock(file);
|
|
564
|
+
if (lock.ok) say(`已顶掉旧版本 ${old.version ?? "(0.2.1 以前)"}(进程 ${old.pid}),现在是 ${RUNNER_VERSION}`);
|
|
565
|
+
}
|
|
548
566
|
if (!lock.ok) {
|
|
549
567
|
console.log(`已经在后台连着,不用再开(进程 ${lock.pid}${lock.launchd ? ",开机自启起的" : ""})。要停掉它:${prog} stop`);
|
|
550
568
|
process.exit(0);
|
|
@@ -586,22 +604,40 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
586
604
|
async function stopHolder(file) {
|
|
587
605
|
const l = readLock(file);
|
|
588
606
|
if (!l || l.pid === process.pid || !pidAlive(l.pid)) return null;
|
|
589
|
-
|
|
590
|
-
for (let i = 0; i < 50 && pidAlive(l.pid); i++) await sleep(100);
|
|
607
|
+
await stopPid(l.pid);
|
|
591
608
|
return l;
|
|
592
609
|
}
|
|
610
|
+
/** R732:发 SIGTERM 让它正常退出(退出码 0,launchd 不会再拉起),等最多 5 秒 */
|
|
611
|
+
async function stopPid(pid) {
|
|
612
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* 已经没了 */ }
|
|
613
|
+
for (let i = 0; i < 50 && pidAlive(pid); i++) await sleep(100);
|
|
614
|
+
}
|
|
615
|
+
/** R732:等新起的后台运行器拿到锁(锁里是 version 且进程活着),最多 ms 毫秒;拿到返回锁信息,超时 null */
|
|
616
|
+
export async function waitNewHolder(file, { version = RUNNER_VERSION, ms = 10000, step = 200 } = {}) {
|
|
617
|
+
for (let t = 0; ; t += step) {
|
|
618
|
+
const l = readLock(file);
|
|
619
|
+
if (l && l.pid !== process.pid && l.version === version && pidAlive(l.pid)) return l;
|
|
620
|
+
if (t >= ms) return null;
|
|
621
|
+
await sleep(step);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
593
624
|
|
|
594
625
|
const here = () => path.dirname(fileURLToPath(import.meta.url));
|
|
595
626
|
// @意图#R666(2026-10-29): 开启自启:前台有一个在跑就先停掉它(不然后台那个起来就因为锁退出了),再交给 autostart.mjs(复制自己、写 plist、加载)
|
|
596
627
|
// @意图#R725(2026-11-03): Windows 走计划任务(autostartOnWindows),Mac 走 launchd。提示里的「这台 Mac」按平台写。
|
|
628
|
+
// @意图#R732(2026-11-04): 不管拿着锁的是终端里的还是开机自启起的(包括别的标签起的旧版本),一律先停掉;装好后等新版本真的拿到锁才报「已设开机自启」,没接上就说清楚、给日志位置
|
|
597
629
|
async function turnAutostartOn(file, prog, deps = {}) {
|
|
598
|
-
|
|
599
|
-
if (held && !held.launchd) await stopHolder(file);
|
|
630
|
+
await stopHolder(file);
|
|
600
631
|
const platform = deps.platform ?? process.platform;
|
|
601
632
|
const r = platform === "win32"
|
|
602
633
|
? await autostartOnWindows({ configFile: file, version: RUNNER_VERSION, srcDir: here(), execPath: deps.execPath, schtasks: deps.schtasks })
|
|
603
634
|
: await autostartOn({ configFile: file, version: RUNNER_VERSION, srcDir: here(), launchctl: deps.launchctl, platform });
|
|
604
635
|
const where = platform === "win32" ? "这台电脑" : "这台 Mac";
|
|
636
|
+
const got = await (deps.waitNewHolder ?? waitNewHolder)(file, { version: RUNNER_VERSION });
|
|
637
|
+
if (!got) {
|
|
638
|
+
console.log(`开机自启已经装好(${RUNNER_VERSION}),但等了 10 秒后台还没连上。可以看日志找原因:${r.log}\n过一会儿可以再运行一次:${prog} autostart on`);
|
|
639
|
+
return r;
|
|
640
|
+
}
|
|
605
641
|
console.log(`已设开机自启:以后登录${where}会自动连上,不用开终端。现在已经在后台连着,可以关掉这个终端窗口了。\n日志:${r.log}\n关掉开机自启:${prog} autostart off`);
|
|
606
642
|
return r;
|
|
607
643
|
}
|