agent-hub-runner 0.1.1 → 0.1.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,4 @@
1
+ <!-- @意图#R690(2026-11-01): 加「改动记录」一节:存在哪、打码、64 KB、30 天、怎么删 -->
1
2
  <!-- @意图#R666(2026-10-29): 改成不用全局安装的 npx 一条命令(码@地址)、Mac 开机自启、stop / uninstall、常见报错 -->
2
3
  <!-- @意图#R664(2026-10-29): npm 包 agent-hub-runner 的说明(随包发布):给客户看的安装、配对、再连、查看、断开、卸载和安全提示 -->
3
4
  # agent-hub-runner
@@ -51,6 +52,8 @@ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复
51
52
 
52
53
  开机自启时,程序复制到 `~/.agent-runner/bin/<版本>/`,日志在 `~/.agent-runner/logs/runner.log`(超过 5MB 自动清空)。网页上提示「有新版本」时,按提示运行那一条升级命令。
53
54
 
55
+ 仓库开发者(R699):要让仓库里的源码 `runner/runner.mjs` 直接连测试服、开机自启,用 `agent-hub/scripts/runner-test.sh autostart on|off | start | stop | status | log`(launchd 服务 `com.agenthub.runner.src`,日志 `~/.agent-runner/logs/runner-src.log`,不复制程序,改了代码 `start` 一下就生效)。普通用户不用管这个。
56
+
54
57
  ## 配置文件
55
58
 
56
59
  - 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的目录。别发给别人。
@@ -71,6 +74,12 @@ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复
71
74
  - 在网页抽屉「连着的电脑」里点「断开」:令牌立刻作废,再连要重新配对;开机自启也会自己关掉。
72
75
  - 卸载:`npx -y agent-hub-runner@latest uninstall`(加 `--purge` 连配置一起删,不再问)。
73
76
 
77
+ ## 改动记录(0.1.2 起)
78
+
79
+ - bot 每次写文件、改文件,运行器在配置文件旁边的 `edits/` 文件夹(默认 `~/.agent-runner/edits/`,只有你能读)存一份改动记录,网页上点「已改文件 / 已写文件(自动允许)」那行灰字就能看到改了什么。内容只在你电脑上,服务器不存。
80
+ - 存之前会把像密钥的字符串换成 `***`(`sk-…`、`ghp_…`、连接串里的密码、私钥、`XXX_KEY=…` 这类赋值);每条最多 64 KB;只留 30 天,过期自动删。电脑不在线时网页上看不到。
81
+ - 不想留:直接删掉 `edits/` 文件夹,不影响别的。
82
+
74
83
  ## 安全提示
75
84
 
76
85
  - 别把放着密钥、令牌的文件夹授权出去:bot 在授权文件夹里读、写、改文件不用你批准。
package/autostart.mjs CHANGED
@@ -17,7 +17,8 @@ export const LABEL = "com.agenthub.runner";
17
17
  export const LOG_MAX_BYTES = 5 * 1024 * 1024;
18
18
  export const THROTTLE_S = 10;
19
19
  /** 复制到 ~/.agent-runner/bin/<版本>/ 的文件(package.json 用来读版本号) */
20
- export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "autostart.mjs", "package.json"];
20
+ // @意图#R690(2026-11-01): 多复制 changes.mjs(runner.mjs 要 import 它,少了开机自启的副本起不来)
21
+ export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "autostart.mjs", "package.json"];
21
22
  /** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
22
23
  export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
23
24
  export const ONLY_MAC = "开机自启目前只支持 Mac";
package/changes.mjs ADDED
@@ -0,0 +1,195 @@
1
+ // @意图#R690(2026-11-01): 私聊里「已改文件 / 已写文件(自动允许)」点开看改动(king 同意 R690):改动内容只存在你电脑上(运行器配置旁边的 edits/<auto_id>.json,权限 600),
2
+ // 服务器库里只记 id。改文件存按行对比(带改前、改后行号和前后 3 行,网页左右对照用),写文件存写进去的内容;存之前先打码(和服务器 redactSecrets 同一套规则,
3
+ // 共用测试样例 test/fixtures/redact-cases.json);每条最多 64 KB(超出截断并标记);只留 30 天(启动时和每次存时顺手清,最多一小时一次)。存失败只记日志,不影响写改本身。
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+
7
+ export const CHANGE_ID_RE = /^auto-[0-9a-f]{32}$/;
8
+ export const MAX_RECORD_BYTES = 64 * 1024;
9
+ export const KEEP_MS = 30 * 24 * 60 * 60_000;
10
+ export const CLEAN_EVERY_MS = 60 * 60_000;
11
+ export const CONTEXT_LINES = 3;
12
+ /** 中间改动部分(去掉首尾相同的行后)行数乘积超过这个就不细算,整块当作删了再加 */
13
+ export const MAX_DIFF_CELLS = 4_000_000;
14
+
15
+ /** 改动记录放在配置文件旁边的 edits/ 里(跟着 AGENT_RUNNER_CONFIG 走,dev 和正式配置各管各的文件,名字是 auto_id 不会撞)。 */
16
+ // @意图#R690(2026-11-01): king 定甲:放配置旁边
17
+ export const changesDirFor = (configFile) => path.join(path.dirname(configFile), "edits");
18
+
19
+ // 变量名以 KEY / TOKEN / SECRET / PASSWORD / PASSWD 结尾(API_KEY、db.password、apiKey、token 都算;monkey、tokens 不算)
20
+ const NAME = String.raw`(?:(?:[A-Za-z0-9]+[_.-])*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|key|token|secret|password|passwd|Key|Token|Secret|Password)|[a-z][A-Za-z0-9]*(?:Key|Token|Secret|Password))`;
21
+ const QUOTED_ASSIGN = new RegExp(String.raw`(^|[^\w.-])(["']?)(${NAME})\2([ \t]*[=:][ \t]*)(["'])([^"'\r\n]+)\5`, "gm");
22
+ const LINE_ASSIGN = new RegExp(String.raw`^([ \t]*(?:export[ \t]+)?)(${NAME})([ \t]*[=:][ \t]*)([^\s"'#][^\s#]*)`, "gm");
23
+
24
+ /**
25
+ * 打码(和服务器 server/src/model-keys.ts 的 redactSecrets 规则一字不差,样例见 test/fixtures/redact-cases.json):
26
+ * sk- / ghp_ 开头的串、连接串里的密码、私钥块(BEGIN/END 两行留着,中间每行换成 ***,行数不变)、
27
+ * 变量名像密钥的赋值(带引号的值任何位置都打;不带引号的只认行首的 .env / yaml 写法)。
28
+ */
29
+ // @意图#R690(2026-11-01): king 定乙:服务器三条规则 + 私钥块 + .env 式 XXX_KEY / TOKEN / SECRET / PASSWORD = 值;打码不改变行数(行号对得上)
30
+ export function redactSecrets(text) {
31
+ let s = String(text);
32
+ s = s.replace(/(-----BEGIN [A-Z0-9 ]*-----)([\s\S]*?)(-----END [A-Z0-9 ]*-----)/g, (_m, a, body, b) => a + body.replace(/[^\r\n]+/g, "***") + b);
33
+ s = s.replace(/(-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----)((?:(?!-----END )[\s\S])*)$/, (_m, a, body) => a + body.replace(/[^\r\n]+/g, "***")); // 截断了没有 END 的私钥块
34
+ s = s.replace(/(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]+/g, "sk-***").replace(/(?<![A-Za-z0-9])ghp_[A-Za-z0-9_]+/g, "ghp_***")
35
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):[^\s@/]+@/gi, "$1:***@");
36
+ s = s.replace(QUOTED_ASSIGN, (m, pre, q1, name, eq, q2, val) => (val === "***" ? m : `${pre}${q1}${name}${q1}${eq}${q2}***${q2}`));
37
+ s = s.replace(LINE_ASSIGN, "$1$2$3***");
38
+ return s;
39
+ }
40
+
41
+ /** 按行拆(去 BOM、换行统一成 LF;末尾换行不算多一行)。 */
42
+ export function splitLines(text) {
43
+ let t = String(text);
44
+ if (t.startsWith("\uFEFF")) t = t.slice(1);
45
+ t = t.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
46
+ if (t === "") return [];
47
+ const a = t.split("\n");
48
+ if (a[a.length - 1] === "") a.pop();
49
+ return a;
50
+ }
51
+
52
+ /** 逐行对比:返回每一行 {t:" "|"-"|"+", o:改前行号|null, n:改后行号|null, s:内容};连着的一段改动里先删后加。 */
53
+ // @意图#R690(2026-11-01): 带两边行号,网页既能画左右对照也能画红绿一栏
54
+ export function diffLines(a, b) {
55
+ let p = 0;
56
+ while (p < a.length && p < b.length && a[p] === b[p]) p++;
57
+ let q = 0;
58
+ while (q < a.length - p && q < b.length - p && a[a.length - 1 - q] === b[b.length - 1 - q]) q++;
59
+ const A = a.slice(p, a.length - q), B = b.slice(p, b.length - q);
60
+ const mid = []; // " " / "-" / "+" 序列
61
+ if (A.length && B.length && A.length * B.length <= MAX_DIFF_CELLS) {
62
+ const n = A.length, m = B.length, w = m + 1;
63
+ const dp = new Uint32Array((n + 1) * w);
64
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
65
+ dp[i * w + j] = A[i] === B[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
66
+ }
67
+ let i = 0, j = 0;
68
+ while (i < n || j < m) {
69
+ if (i < n && j < m && A[i] === B[j]) { mid.push(" "); i++; j++; }
70
+ else if (j >= m || (i < n && dp[(i + 1) * w + j] >= dp[i * w + j + 1])) { mid.push("-"); i++; }
71
+ else { mid.push("+"); j++; }
72
+ }
73
+ } else {
74
+ for (let i = 0; i < A.length; i++) mid.push("-");
75
+ for (let j = 0; j < B.length; j++) mid.push("+");
76
+ }
77
+ // 连着的改动里先删后加
78
+ const tags = [];
79
+ for (let k = 0; k < mid.length;) {
80
+ if (mid[k] === " ") { tags.push(" "); k++; continue; }
81
+ let d = 0, ad = 0;
82
+ while (k < mid.length && mid[k] !== " ") { if (mid[k] === "-") d++; else ad++; k++; }
83
+ for (let x = 0; x < d; x++) tags.push("-");
84
+ for (let x = 0; x < ad; x++) tags.push("+");
85
+ }
86
+ const out = [];
87
+ let o = 0, nn = 0;
88
+ for (let k = 0; k < p; k++) { out.push({ t: " ", o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
89
+ for (const t of tags) {
90
+ if (t === " ") { out.push({ t, o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
91
+ else if (t === "-") { out.push({ t, o: o + 1, n: null, s: a[o] }); o++; }
92
+ else { out.push({ t, o: null, n: nn + 1, s: b[nn] }); nn++; }
93
+ }
94
+ for (let k = 0; k < q; k++) { out.push({ t: " ", o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
95
+ return out;
96
+ }
97
+
98
+ /** 把逐行对比切成几处改动,每处前后带 ctx 行没变的;两处之间没变的不超过 2×ctx 行就并成一处。 */
99
+ export function hunksOf(rows, ctx = CONTEXT_LINES) {
100
+ const idx = [];
101
+ rows.forEach((r, i) => { if (r.t !== " ") idx.push(i); });
102
+ const hunks = [];
103
+ let k = 0;
104
+ while (k < idx.length) {
105
+ let start = idx[k], end = idx[k];
106
+ while (k + 1 < idx.length && idx[k + 1] - end - 1 <= 2 * ctx) { k++; end = idx[k]; }
107
+ k++;
108
+ const from = Math.max(0, start - ctx), to = Math.min(rows.length, end + ctx + 1);
109
+ const lines = rows.slice(from, to);
110
+ let oStart = 1, nStart = 1;
111
+ for (let i = 0; i < from; i++) { if (rows[i].t !== "+") oStart++; if (rows[i].t !== "-") nStart++; }
112
+ hunks.push({ old_start: oStart, new_start: nStart, lines });
113
+ }
114
+ return hunks;
115
+ }
116
+
117
+ const jsonBytes = (v) => Buffer.byteLength(JSON.stringify(v), "utf8") + 1; // 存进去占的字节(加逗号)
118
+
119
+ /**
120
+ * 生成一条改动记录(存之前先打码、截到 64 KB)。
121
+ * edit:{ v, id, tool, path, at, hunks, added, removed, truncated };write:{ v, id, tool, path, at, lines, total_lines, chars, truncated }
122
+ */
123
+ // @意图#R690(2026-11-01): 改文件存红绿对比(king 定),写文件只存写进去的内容、不存旧文件(king 定乙);每条最多 64 KB(king 定甲)
124
+ export function buildChangeRecord({ id, tool, path: p, before, after, content, at = new Date().toISOString() }) {
125
+ const budget = MAX_RECORD_BYTES - 1024;
126
+ const head = { v: 1, id, tool, path: String(p ?? ""), at };
127
+ if (tool === "write") {
128
+ const text = String(content ?? "");
129
+ const all = splitLines(redactSecrets(text));
130
+ const lines = [];
131
+ let used = 0, truncated = false;
132
+ for (const l of all) {
133
+ const b = jsonBytes(l);
134
+ if (used + b > budget) { truncated = true; break; }
135
+ lines.push(l); used += b;
136
+ }
137
+ return { ...head, lines, total_lines: all.length, chars: text.length, truncated };
138
+ }
139
+ const rows = diffLines(splitLines(redactSecrets(before ?? "")), splitLines(redactSecrets(after ?? "")));
140
+ let added = 0, removed = 0;
141
+ for (const r of rows) { if (r.t === "+") added++; else if (r.t === "-") removed++; }
142
+ const hunks = [];
143
+ let used = 0, truncated = false;
144
+ for (const h of hunksOf(rows)) {
145
+ used += jsonBytes({ old_start: h.old_start, new_start: h.new_start, lines: [] });
146
+ const keep = [];
147
+ for (const l of h.lines) {
148
+ const b = jsonBytes(l);
149
+ if (used + b > budget) { truncated = true; break; }
150
+ keep.push(l); used += b;
151
+ }
152
+ if (keep.length) hunks.push({ ...h, lines: keep });
153
+ if (truncated) break;
154
+ }
155
+ return { ...head, hunks, added, removed, truncated };
156
+ }
157
+
158
+ /** 改动记录的存取和清理(目录用到时才建,700;文件 600)。 */
159
+ // @意图#R690(2026-11-01): 只留 30 天(king 定甲):启动时清一次,每次存时顺手清(最多一小时一次),按文件修改时间算
160
+ export function createChangeStore(dir, { now = Date.now, log = () => {} } = {}) {
161
+ let lastClean = 0;
162
+ const fileOf = (id) => path.join(dir, `${id}.json`);
163
+ async function clean(force = false) {
164
+ if (!force && now() - lastClean < CLEAN_EVERY_MS) return 0;
165
+ lastClean = now();
166
+ let names;
167
+ try { names = await fs.readdir(dir); } catch { return 0; }
168
+ let removed = 0;
169
+ for (const f of names) {
170
+ if (!/^auto-[0-9a-f]{32}\.json$/.test(f)) continue;
171
+ try {
172
+ const st = await fs.stat(path.join(dir, f));
173
+ if (now() - st.mtimeMs > KEEP_MS) { await fs.unlink(path.join(dir, f)); removed++; }
174
+ } catch { /* 别的进程删了 */ }
175
+ }
176
+ return removed;
177
+ }
178
+ return {
179
+ dir,
180
+ clean,
181
+ async save(id, record) {
182
+ if (!CHANGE_ID_RE.test(String(id))) throw new Error("改动记录 id 不对");
183
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
184
+ const f = fileOf(id);
185
+ await fs.writeFile(f, JSON.stringify(record), { mode: 0o600 });
186
+ await fs.chmod(f, 0o600);
187
+ await clean().catch((e) => log(`清理旧改动记录失败:${e?.message ?? e}`));
188
+ },
189
+ /** 找不到(没存、已清理、id 不对)返回 null */
190
+ async get(id) {
191
+ if (!CHANGE_ID_RE.test(String(id))) return null;
192
+ try { return JSON.parse(await fs.readFile(fileOf(id), "utf8")); } catch { return null; }
193
+ },
194
+ };
195
+ }
package/ops.mjs CHANGED
@@ -77,24 +77,28 @@ export async function opRead(args, root) {
77
77
  }
78
78
 
79
79
  /** write:建好上级目录再整份写入。 */
80
+ // @意图#R690(2026-11-01): 写成功后交给 onChange 存一份改动记录(写进去的内容),存记录的事不影响写本身
80
81
  // @意图#R636(2026-10-27): 写文件(已经过批准,只执行这一次)
81
- export async function opWrite(args, root) {
82
+ export async function opWrite(args, root, { onChange } = {}) {
82
83
  if (typeof args.content !== "string") throw new Error("content 必须是文字");
83
84
  const real = await resolveInside(args.path, root);
84
85
  await fs.mkdir(path.dirname(real), { recursive: true });
85
86
  await fs.writeFile(real, args.content, "utf8");
87
+ await onChange?.({ tool: "write", path: args.path, content: args.content });
86
88
  return { bytes: Buffer.byteLength(args.content, "utf8") };
87
89
  }
88
90
 
89
91
  /** edit:和 pi 原版同样的精确替换。 */
92
+ // @意图#R690(2026-11-01): 写回成功后把改前(读到的原文)、改后(写回的内容)交给 onChange 算对比、存记录:同一个函数里拿到的两份,不会和别的改动串
90
93
  // @意图#R636(2026-10-27): 改文件(已经过批准,只执行这一次)
91
- export async function opEdit(args, root) {
94
+ export async function opEdit(args, root, { onChange } = {}) {
92
95
  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
96
  const real = await resolveInside(args.path, root);
94
97
  let raw;
95
98
  try { raw = await fs.readFile(real, "utf8"); } catch (e) { throw new Error(`Could not edit file: ${args.path}. Error code: ${e?.code ?? "unknown"}.`); }
96
99
  const next = editText(raw, args.edits, args.path);
97
100
  await fs.writeFile(real, next, "utf8");
101
+ await onChange?.({ tool: "edit", path: args.path, before: raw, after: next });
98
102
  return { replaced: args.edits.length };
99
103
  }
100
104
 
@@ -141,12 +145,13 @@ export function opBash(args, root, { onData, signal } = {}) {
141
145
  }
142
146
 
143
147
  /** 按 op 分派;未知 op 拒绝。 */
148
+ // @意图#R690(2026-11-01): write、edit 也收 extra(onChange 存改动记录)
144
149
  // @意图#R636(2026-10-27): 协议里只有这四种工具调用
145
150
  export function runOp(op, args, root, extra) {
146
151
  const a = args && typeof args === "object" ? args : {};
147
152
  if (op === "read") return opRead(a, root);
148
- if (op === "write") return opWrite(a, root);
149
- if (op === "edit") return opEdit(a, root);
153
+ if (op === "write") return opWrite(a, root, extra);
154
+ if (op === "edit") return opEdit(a, root, extra);
150
155
  if (op === "bash") return opBash(a, root, extra);
151
156
  return Promise.reject(new Error(`未知操作 ${op}`));
152
157
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "agent-hub-runner",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准)",
5
5
  "type": "module",
6
6
  "bin": { "agent-hub-runner": "bin/agent-hub-runner.mjs" },
7
- "files": ["runner.mjs", "ops.mjs", "edit.mjs", "autostart.mjs", "bin/agent-hub-runner.mjs", "README.md"],
7
+ "files": ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "autostart.mjs", "bin/agent-hub-runner.mjs", "README.md"],
8
8
  "engines": { "node": ">=22" },
9
9
  "scripts": { "test": "node --test" }
10
10
  }
package/runner.mjs CHANGED
@@ -26,6 +26,7 @@ import readline from "node:readline/promises";
26
26
  import { execFile } from "node:child_process";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { runOp } from "./ops.mjs";
29
+ import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
29
30
  import { autostartOff, autostartOn, autostartStatus, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
30
31
 
31
32
  export const PROTOCOL_VERSION = 1;
@@ -254,10 +255,26 @@ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
254
255
  // @意图#R640(2026-10-27): 目录按 bot 分:call 按 bot_id 查对照表,没有就拒绝 NO_DIR;pick_dir 带 bot_id,选完检查后记进对照表、写回配置(600),
255
256
  // 回 dir_result {id, ok, bot_id, dir};老配置的单一 dir 忽略
256
257
  // @意图#R639(2026-10-27已废弃): 目录不再是连接时固定的常量;没目录时 call 一律拒绝;pick_dir 选完检查、写回配置(600)、回 dir_result(R640 改成按 bot 分)
257
- export function createSession(cfg, { log = say, pickDir = pickDirMac, platform = process.platform, file = configPath() } = {}) {
258
+ // @意图#R690(2026-11-01): write / edit 带 change_id(服务器记灰字时生成的 auto_id)就在写改成功后存一份改动记录(存失败只记日志);
259
+ // 新消息 change_get {id, change_id} → change_result {id, ok:true, found, record},不看这个 bot 有没有目录(换了目录也能看旧记录)
260
+ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform = process.platform, file = configPath(), changes } = {}) {
258
261
  let dirs = botDirsOf(cfg); // bot_id → 授权目录
259
262
  const jobs = new Map(); // 调用 id → AbortController
260
263
  const picks = new Map(); // 弹窗 id → AbortController
264
+ let store = changes; // 改动记录(R690),用到时才建
265
+ const changeStore = () => (store ??= createChangeStore(changesDirFor(file), { log }));
266
+ const recorder = (args) => {
267
+ const id = typeof args?.change_id === "string" ? args.change_id : "";
268
+ if (!CHANGE_ID_RE.test(id)) return undefined;
269
+ return async (info) => {
270
+ try { await changeStore().save(id, buildChangeRecord({ id, ...info })); } catch (e) { log(`改动记录没存上(不影响这次写改):${e?.message ?? e}`); }
271
+ };
272
+ };
273
+
274
+ async function handleChangeGet(send, msg) {
275
+ const record = await changeStore().get(msg.change_id).catch(() => null);
276
+ send({ type: "change_result", id: msg.id, ok: true, found: !!record, ...(record ? { record } : {}) });
277
+ }
261
278
 
262
279
  async function handleCall(send, msg) {
263
280
  const { id, op, args } = msg;
@@ -268,7 +285,8 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
268
285
  jobs.set(id, ac);
269
286
  log(`[${bot}] ${op === "bash" ? `bash:${args?.command}` : `${op}:${args?.path}`}`);
270
287
  try {
271
- const res = await runOp(op, args, dir, { signal: ac.signal, onData: (d) => send({ type: "output", id, data: Buffer.from(d).toString("base64") }) });
288
+ const onChange = op === "write" || op === "edit" ? recorder(args) : undefined;
289
+ const res = await runOp(op, args, dir, { signal: ac.signal, onChange, onData: (d) => send({ type: "output", id, data: Buffer.from(d).toString("base64") }) });
272
290
  if (ac.signal.aborted) send({ type: "result", id, ok: false, error: "已中止" });
273
291
  else send({ type: "result", id, ok: true, ...res });
274
292
  } catch (e) {
@@ -307,9 +325,12 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
307
325
 
308
326
  return {
309
327
  get botDirs() { return { ...dirs }; },
328
+ /** R690:清掉 30 天前的改动记录(startRunner 启动时调一次) */
329
+ cleanChanges() { return changeStore().clean(true).catch(() => 0); },
310
330
  onMessage(msg, send) {
311
331
  if (msg.type === "call") return handleCall(send, msg);
312
332
  if (msg.type === "pick_dir") return handlePick(send, msg);
333
+ if (msg.type === "change_get") return handleChangeGet(send, msg);
313
334
  if (msg.type === "abort") { jobs.get(msg.id)?.abort(); picks.get(msg.id)?.abort(); }
314
335
  return undefined;
315
336
  },
@@ -336,6 +357,7 @@ export function startRunner(cfg, { log = say, retryMs = 2000, pickDir = pickDirM
336
357
  checkNode();
337
358
  let fails = 0;
338
359
  const session = createSession(cfg, { log, pickDir, platform, file });
360
+ void session.cleanChanges(); // R690:启动时清一次 30 天前的改动记录
339
361
  let ws = null;
340
362
  let stopped = false;
341
363
  let beat = null;