@fanchao8609/agent_brain_sync 1.5.4 → 1.5.6

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/src/install.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import { promises as fs } from 'node:fs';
9
9
  import * as fsSync from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
- import { join, dirname, resolve } from 'node:path';
11
+ import { join, dirname, resolve, basename } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { HOSTS, hostByKey } from './hosts.js';
14
14
 
@@ -17,28 +17,57 @@ const HOOK_TEMPLATE = join(ABS_DIR, 'hooks', 'event.sh');
17
17
  const SKILL_SOURCE = join(ABS_DIR, 'skill', 'SKILL.md');
18
18
 
19
19
  /**
20
- * 写进宿主 MCP 配置的 mcp.js 路径。
20
+ * 本包的稳定入口路径解析(mcp.js / abs.js 通用)。
21
+ *
21
22
  * 坑: 直接用 ABS_DIR 是不稳定的 —— ABS_DIR = "install.js 自己住哪", 从仓库跑
22
- * `abs install` 就把仓库路径写进宿主配置。之后若卸载/移动全局包, 该路径直接失效;
23
- * 而 Co​dex 分支又"已存在即跳过", 一旦写错永不修正。
24
- * 现在优先解析本包的稳定安装位置(全局 node_modules),解析不到才回退 ABS_DIR。
23
+ * `abs install` 就把仓库路径烧进宿主配置/hook 脚本。之后若卸载/移动仓库或全局包,
24
+ * 该路径直接失效; 而某些分支"已存在即跳过", 一旦写错永不修正。
25
+ *
26
+ * 故统一优先解析本包的稳定安装位置(全局 node_modules),解析不到才回退 ABS_DIR。
27
+ * 单一收口: MCP 注册与 hook 脚本都走这里 —— 曾经 MCP 修了、hook 没修,
28
+ * 同一份安装里 hook 指向仓库而 MCP 指向全局,是两个不同的包。
29
+ *
30
+ * @param entryFile 包内相对 bin/ 的文件名,如 'mcp.js' / 'abs.js'
25
31
  */
26
- function mcpEntryPath() {
32
+ function stableBinPath(entryFile) {
27
33
  // 本包名(package.json),用于反查全局安装位置
28
34
  let pkgName = '@fanchao8609/agent_brain_sync';
29
35
  try {
30
36
  pkgName = JSON.parse(fsSync.readFileSync(join(ABS_DIR, 'package.json'), 'utf8')).name || pkgName;
31
37
  } catch { /* 保持默认 */ }
38
+
39
+ // 各平台 npm 全局根下的本包位置。
40
+ // Unix (nvm/homebrew/apt): <node根>/lib/node_modules/<pkg>
41
+ // Windows: %APPDATA%\npm\node_modules\<pkg>
42
+ // 注: 曾经只有 Unix 候选 —— Windows 上两个都不中,会回退到 ABS_DIR(仓库路径),
43
+ // 即 H3 在 Windows 上仍会复现。此条为代码层推断,未在 Windows 实测。
44
+ const nodeRoot = dirname(dirname(process.execPath)); // <node>/bin/node → <node>
32
45
  const candidates = [
33
- // npm 全局根(最常见)
34
- join(dirname(process.execPath), '..', 'lib', 'node_modules', pkgName, 'bin', 'mcp.js'),
35
- join(process.env.npm_config_prefix || '', 'lib', 'node_modules', pkgName, 'bin', 'mcp.js'),
46
+ join(nodeRoot, 'lib', 'node_modules', pkgName, 'bin', entryFile), // Unix
36
47
  ];
48
+ // npm 自己报告的全局根(npm_config_prefix 由 npm 子进程设置;交互 shell 里通常为空)
49
+ if (process.env.npm_config_prefix) {
50
+ candidates.push(join(process.env.npm_config_prefix, 'lib', 'node_modules', pkgName, 'bin', entryFile));
51
+ candidates.push(join(process.env.npm_config_prefix, 'node_modules', pkgName, 'bin', entryFile));
52
+ }
53
+ if (process.platform === 'win32' && process.env.APPDATA) {
54
+ candidates.push(join(process.env.APPDATA, 'npm', 'node_modules', pkgName, 'bin', entryFile));
55
+ }
56
+
37
57
  for (const c of candidates) {
38
- try { if (fsSync.existsSync(c)) return c; } catch { /* 试下一个 */ }
58
+ try {
59
+ // 归一化为绝对路径,避免任何依赖 cwd 的相对候选被误用
60
+ const abs = resolve(c);
61
+ if (fsSync.existsSync(abs)) return abs;
62
+ } catch { /* 试下一个 */ }
39
63
  }
40
64
  // 回退:仓库/本地安装形态
41
- return join(ABS_DIR, 'bin', 'mcp.js');
65
+ return join(ABS_DIR, 'bin', entryFile);
66
+ }
67
+
68
+ /** 写进宿主 MCP 配置的 mcp.js 路径。 */
69
+ function mcpEntryPath() {
70
+ return stableBinPath('mcp.js');
42
71
  }
43
72
 
44
73
  const MARK = '// abs-managed (agent-brain-sync)'; // TS plugin 标记
@@ -60,27 +89,159 @@ export function hostSkillDir(key) {
60
89
  async function atomicWrite(p, text) {
61
90
  await fs.mkdir(dirname(p), { recursive: true });
62
91
  const tmp = `${p}.abs-tmp-${Date.now()}`;
63
- await fs.writeFile(tmp, text, 'utf8');
64
- await fs.rename(tmp, p);
92
+ try {
93
+ await fs.writeFile(tmp, text, 'utf8');
94
+ } catch (e) {
95
+ // 写 tmp 就失败(如目标是目录): 清掉半成品再抛,否则沙盒里留 .abs-tmp-* 残骸
96
+ await fs.rm(tmp, { force: true }).catch(() => {});
97
+ throw e;
98
+ }
99
+ try {
100
+ await fs.rename(tmp, p);
101
+ } catch (e) {
102
+ // rename 失败(如目标路径被目录占位): 同样清理,不留 tmp
103
+ await fs.rm(tmp, { force: true }).catch(() => {});
104
+ throw e;
105
+ }
65
106
  }
66
107
 
67
108
  async function backup(p) {
68
109
  try {
69
- const bak = `${p}.abs-bak-${new Date().toISOString().slice(0, 10)}`;
110
+ // 文件名含日期+时分秒: 曾经只有日期,同日多次安装/卸载会互相覆盖,
111
+ // 只剩最后一份 —— 用户装坏了想回滚时,最早的可用副本已经不在了。
112
+ const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
113
+ const bak = `${p}.abs-bak-${ts}`;
70
114
  await fs.copyFile(p, bak);
115
+ await pruneBackups(p);
71
116
  return bak;
72
117
  } catch { return null; } // 文件不存在则无备份
73
118
  }
74
119
 
75
- async function readJson(p) {
76
- try { return JSON.parse(await fs.readFile(p, 'utf8')); } catch { return {}; }
120
+ /** 只保留最近 KEEP 份备份(按文件名时间戳倒序),避免备份目录无限增长。 */
121
+ const BACKUP_KEEP = 5;
122
+ async function pruneBackups(p) {
123
+ try {
124
+ const dir = dirname(p);
125
+ const base = `${basename(p)}.abs-bak-`;
126
+ const olds = (await fs.readdir(dir))
127
+ .filter((f) => f.startsWith(base))
128
+ .sort() // 时间戳格式 YYYY-MM-DD-HH-mm-ss 字典序 == 时间序
129
+ .reverse();
130
+ for (const f of olds.slice(BACKUP_KEEP)) {
131
+ await fs.rm(join(dir, f), { force: true });
132
+ }
133
+ } catch { /* 清理失败不影响主流程 */ }
77
134
  }
78
135
 
79
- const ABS_HOOK_MARK = '/.abs/hooks/';
80
- /** 判断一个 hook 数组元素是不是 abs 装的(command 指向 ~/.abs/hooks/)。卸载/幂等去重用。 */
136
+ async function readJson(p, { strict = true } = {}) {
137
+ let text;
138
+ try {
139
+ text = await fs.readFile(p, 'utf8');
140
+ } catch (e) {
141
+ if (e.code === 'ENOENT') return {}; // 文件不存在 = 空配置(正常首装)
142
+ throw e; // 权限/IO 错不能静默吞
143
+ }
144
+ if (!text.trim()) return {};
145
+ try {
146
+ return JSON.parse(text);
147
+ } catch (e) {
148
+ if (!strict) return null; // 宽松模式: 调用方据此跳过写文件,但仍继续其它清理
149
+ // 关键: 「文件不存在」与「存在但解析失败」必须分开处理。
150
+ // 曾经两者都返回 {},于是只要用户的配置里有 JSONC 注释/尾逗号/多一个字符,
151
+ // 就会被当作空对象重建 → 用户的 hooks/permissions/model/mcpServers 静默全消失。
152
+ // 而 JSONC(带 // 注释)正是 CL​aude Code 官方文档鼓励的写法,命中率不低。
153
+ // 测例: 带注释的 settings.json 安装后 myKey/permissions 全丢,且无任何报错。
154
+ // 处置: 宁可整个安装失败,也不写坏用户文件(与 requireBrain 同原则)。
155
+ // 宽松模式(strict:false)供"卸载"使用: 跳过配置文件、但仍清理 abs 自己的脚本/skill,
156
+ // 避免因一个坏配置就留下残余。
157
+ throw new Error(
158
+ `配置文件无法解析为 JSON,已中止以免覆盖你的配置:\n` +
159
+ ` ${p}\n` +
160
+ ` ${e.message}\n` +
161
+ ` 若文件含 // 注释或尾逗号(JSONC),请先转成标准 JSON;` +
162
+ `或备份后移走该文件重跑。`,
163
+ );
164
+ }
165
+ }
166
+
167
+ /**
168
+ * 判断一个 hook 数组元素是不是 abs 装的(用于卸载只删自己、安装去重)。
169
+ *
170
+ * 修复: 曾经用 command.includes('/.abs/hooks/') —— 子串匹配。任何「命令文本里恰好
171
+ * 出现该片段」的用户 hook 都会被误判为 abs 装的,于是卸载时删掉、安装时静默丢弃。
172
+ * 实测真例(都被误删):
173
+ * grep -r try /home/u/.abs/hooks/ > /tmp/report
174
+ * tar czf /tmp/b.tgz /home/u/.abs/hooks/ && echo done
175
+ * 现在改为精确匹配本工具实际写入的路径前缀: <homedir>/.abs/hooks/<agentKey>/abs-
176
+ * (stageHookScripts 生成的脚本名统一是 abs-<Event>.sh)。
177
+ */
178
+ function absHookDir(agentKey) {
179
+ return join(homedir(), '.abs', 'hooks', agentKey);
180
+ }
181
+ /** 本工具会生成的 hook 脚本名(与 stageHookScripts 的 abs-<Event>.sh 一致)。 */
182
+ const ABS_SCRIPT_NAMES = new Set(
183
+ [...new Set(HOSTS.flatMap((h) => h.events))].map((ev) => `abs-${ev}.sh`),
184
+ );
185
+ function isAbsStagedCommand(cmd) {
186
+ if (typeof cmd !== 'string') return false;
187
+ // 逐个路径 token 比对: 必须是 <…>/.abs/hooks/<宿名>/<本工具生成的脚本名>。
188
+ // 不用"任意 abs-*"——那会把用户自己的 abs-mine.sh 也判成我们的(实测边界)。
189
+ const tokens = cmd.match(/\S+/g) || [];
190
+ return tokens.some((raw) => {
191
+ const t = raw.replace(/^['"]+|['"]+$/g, '');
192
+ const m = t.match(/(?:^|\/)\.abs\/hooks\/([^/]+)\/([^/]+)$/);
193
+ return !!m && ABS_SCRIPT_NAMES.has(m[2]);
194
+ });
195
+ }
81
196
  function entryHasAbs(entry) {
82
197
  const hs = entry && entry.hooks ? (Array.isArray(entry.hooks) ? entry.hooks : [entry.hooks]) : [];
83
- return hs.some((h) => typeof h?.command === 'string' && h.command.includes(ABS_HOOK_MARK));
198
+ const cmds = [];
199
+ for (const h of hs) if (h && typeof h.command === 'string') cmds.push(h.command);
200
+ // 兼容扁平形态 { command } (无 hooks 字段)
201
+ if (entry && typeof entry.command === 'string') cmds.push(entry.command);
202
+ return cmds.some(isAbsStagedCommand);
203
+ }
204
+
205
+ // ============================ TOML section 行级工具 ============================
206
+ // 为什么不用正则: 曾用 /\n?\[mcp_servers\.abs\][^\[]*/s 删 section,
207
+ // 而 `[^\[]*` 会在下一个 `[` 处停下 —— `args = ["/x/mcp.js"]` 的数组左括号就是 `[`。
208
+ // 结果卸载后把数组值原地截成活一个假 section 头,留下非法行 `["/…/mcp.js"]`,
209
+ // 用户 co​dex 启动时 TOML 解析直接失败(卸载却给用户留个坏配置)。
210
+ // 另一坑: 用 includes('[mcp_servers.abs]') 判"已存在"不区分注释 ——
211
+ // 用户配置里一句 `# 例: [mcp_servers.abs]` 就让安装器报"已存在且路径正确, 跳过",
212
+ // 实际从未注册(静默失效,且重装永不修复)。
213
+ // 故统一用行级扫描: section 头必须锚定行首、非注释;section 体到下一个 section 头为止。
214
+ const TOML_ABS_SECTION = '[mcp_servers.abs]';
215
+
216
+ /** 某行是否为 section 头(含数组表格 [[x]])。 */
217
+ function isTomlSectionHeader(line) {
218
+ return line.trimStart().startsWith('[');
219
+ }
220
+
221
+ /** 找出 [mcp_servers.abs] 真实 section 的 [start, end) 行下标;无则 null。
222
+ * 注释行(# 开头)不算 —— 这是修复 H1(注释导致假装成功)的关键。 */
223
+ function findTomlAbsSection(lines) {
224
+ const start = lines.findIndex((l) => {
225
+ const t = l.trimStart();
226
+ return !t.startsWith('#') && t.trim() === TOML_ABS_SECTION;
227
+ });
228
+ if (start === -1) return null;
229
+ let end = start + 1;
230
+ while (end < lines.length && !isTomlSectionHeader(lines[end])) end++;
231
+ return { start, end };
232
+ }
233
+
234
+ /** 删除 [mcp_servers.abs] section(含其 body);无则原样返回。
235
+ * @returns {{ text: string, removed: boolean }} */
236
+ function removeTomlAbsSection(text) {
237
+ const lines = String(text || '').split('\n');
238
+ const sec = findTomlAbsSection(lines);
239
+ if (!sec) return { text, removed: false };
240
+ // 一并去掉 section 前的多余空行,避免留下连续空行
241
+ let from = sec.start;
242
+ while (from > 0 && lines[from - 1].trim() === '') from--;
243
+ const out = [...lines.slice(0, from), ...lines.slice(sec.end)];
244
+ return { text: out.join('\n').replace(/\n{3,}/g, '\n\n'), removed: true };
84
245
  }
85
246
 
86
247
  // ============================ Hook 脚本落盘 ============================
@@ -93,7 +254,7 @@ async function stageHookScripts(agentKey, events) {
93
254
  const name = `abs-${ev}.sh`;
94
255
  const p = join(dir, name);
95
256
  const script = tpl
96
- .replaceAll('__ABS_BIN__', join(ABS_DIR, 'bin', 'abs.js'))
257
+ .replaceAll('__ABS_BIN__', stableBinPath('abs.js'))
97
258
  .replaceAll('__NODE_BIN__', process.execPath)
98
259
  .replaceAll('__EVENT__', ev);
99
260
  await atomicWrite(p, script);
@@ -147,9 +308,13 @@ async function installClaudeCode({ withMcp, withSkill, log }) {
147
308
  async function uninstallClaudeCode() {
148
309
  const steps = [];
149
310
  const settingsP = claudeSettingsPath();
150
- const settings = await readJson(settingsP);
311
+ const settings = await readJson(settingsP, { strict: false });
312
+ if (settings === null) {
313
+ // 配置解析失败: 跳过该文件(绝不覆盖),但仍继续清理 abs 自己的脚本/skill。
314
+ steps.push(`⚠ ${settingsP} 无法解析为 JSON,已跳过(未改动你的配置)`);
315
+ }
151
316
  let touched = false;
152
- if (settings.hooks) {
317
+ if (settings && settings.hooks) {
153
318
  const events = HOSTS[0].events;
154
319
  for (const ev of events) {
155
320
  if (!Array.isArray(settings.hooks[ev])) continue;
@@ -161,11 +326,11 @@ async function uninstallClaudeCode() {
161
326
  }
162
327
  }
163
328
  }
164
- if (settings.mcpServers && settings.mcpServers.abs) {
329
+ if (settings && settings.mcpServers && settings.mcpServers.abs) {
165
330
  delete settings.mcpServers.abs; touched = true;
166
331
  }
167
332
  if (touched) await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
168
- steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
333
+ if (settings) steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
169
334
  // staged hook 脚本目录 —— 只删本 agent 的,绝不整删 ~/.abs/(其它 agent 的 hook / mcp.log 共存)
170
335
  await fs.rm(join(homedir(), '.abs', 'hooks', 'claude-code'), { recursive: true, force: true });
171
336
  steps.push(`✓ ~/.abs/hooks/claude-code/ (本 agent hook 脚本) 已删除`);
@@ -208,7 +373,9 @@ async function installCodex({ withMcp, withSkill, log }) {
208
373
  const mcpP = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
209
374
  let text = '';
210
375
  try { text = await fs.readFile(mcpP, 'utf8'); } catch {}
211
- if (!text.includes('[mcp_servers.abs]')) {
376
+ const found = findTomlAbsSection(text.split('\n'));
377
+ if (!found) {
378
+ // 必须用 findTomlAbsSection 而非 includes —— 注释里的同名文本不算已注册(H1)。
212
379
  const block = `\n[mcp_servers.abs]\ncommand = "${process.execPath}"\nargs = ["${mcpEntryPath()}"]\n`;
213
380
  await backup(mcpP);
214
381
  await atomicWrite(mcpP, text.replace(/\s*$/, '') + '\n' + block);
@@ -216,13 +383,23 @@ async function installCodex({ withMcp, withSkill, log }) {
216
383
  } else {
217
384
  // 已存在也要校对路径: 旧版可能写入了仓库路径(不稳定) 或全局包已迁移。
218
385
  const want = mcpEntryPath();
219
- const re = /(\[mcp_servers\.abs\][^\[]*?args\s*=\s*\[)"[^"]*"(\])/s;
220
- if (re.test(text) && !text.includes(`"${want}"`)) {
221
- await backup(mcpP);
222
- await atomicWrite(mcpP, text.replace(re, `$1"${want}"$2`));
223
- steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
224
- } else {
386
+ const lines = text.split('\n');
387
+ const body = lines.slice(found.start, found.end).join('\n');
388
+ if (body.includes(`"${want}"`)) {
225
389
  steps.push(`• MCP → ${mcpP} 已存在且路径正确, 跳过`);
390
+ } else {
391
+ // 只改 args 行的字面量值,不碰其它行(正则跨行会误伤数组内容)
392
+ let touched = false;
393
+ for (let k = found.start; k < found.end; k++) {
394
+ if (/^\s*args\s*=/.test(lines[k])) { lines[k] = `args = ["${want}"]`; touched = true; break; }
395
+ }
396
+ if (touched) {
397
+ await backup(mcpP);
398
+ await atomicWrite(mcpP, lines.join('\n'));
399
+ steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
400
+ } else {
401
+ steps.push(`• MCP → ${mcpP} 已注册但无 args 行, 未动(请手动确认)`);
402
+ }
226
403
  }
227
404
  }
228
405
  }
@@ -238,13 +415,16 @@ async function uninstallCodex() {
238
415
  const steps = [];
239
416
  const home = process.env.CODEX_HOME || join(homedir(), '.codex');
240
417
  const p = join(home, 'hooks.json');
241
- const cfg = await readJson(p);
418
+ const cfg = await readJson(p, { strict: false });
419
+ if (cfg === null) {
420
+ steps.push(`⚠ ${p} 无法解析为 JSON,已跳过(未改动你的配置)`);
421
+ }
242
422
  let changed = false;
243
- if (Array.isArray(cfg.hooks)) { // 历史扁平数组形态
423
+ if (cfg && Array.isArray(cfg.hooks)) {
244
424
  const before = cfg.hooks.length;
245
- cfg.hooks = cfg.hooks.filter((h) => !String(h.command || '').includes('/.abs/hooks/'));
425
+ cfg.hooks = cfg.hooks.filter((h) => !isAbsStagedCommand(String(h.command || '')));
246
426
  changed = cfg.hooks.length !== before;
247
- } else if (cfg.hooks && typeof cfg.hooks === 'object') { // 对象形态 {EventName: [...]}
427
+ } else if (cfg && cfg.hooks && typeof cfg.hooks === 'object') {
248
428
  for (const ev of Object.keys(cfg.hooks)) {
249
429
  const arr = cfg.hooks[ev];
250
430
  if (!Array.isArray(arr)) continue;
@@ -263,9 +443,11 @@ async function uninstallCodex() {
263
443
  const mcpP = join(home, 'config.toml');
264
444
  try {
265
445
  const text = await fs.readFile(mcpP, 'utf8');
266
- if (text.includes('[mcp_servers.abs]')) {
267
- const re = /\n?\[mcp_servers\.abs\][^\[]*/s;
268
- await atomicWrite(mcpP, text.replace(re, '\n'));
446
+ // 用行级扫描而非正则: 正则会在 args = [...] 的 [ 处截断,
447
+ // 留下非法行 ["/…/mcp.js"] 让用户的 TOML 解析失败。
448
+ const { text: after, removed } = removeTomlAbsSection(text);
449
+ if (removed) {
450
+ await atomicWrite(mcpP, after);
269
451
  steps.push(`✓ MCP 已从 ${mcpP} 移除`);
270
452
  }
271
453
  } catch {}
@@ -274,133 +456,53 @@ async function uninstallCodex() {
274
456
  return steps;
275
457
  }
276
458
 
277
- // ============================ Opencode / Pi (TS 插件) ============================
278
- function opencodePluginSource() {
279
- // Op​encode 官方插件 API (据 @op​encode-ai/plugin 类型定义实核):
280
- // export const Plugin: Plugin = async ({ client, directory }) => ({ event: async ({ event }) => {...} })
281
- // 坑: event 回调入参是 { event }, 事件名在 event.type —— 曾错写成 ({ name }) 导致 name 恒 undefined,
282
- // 插件从未触发过(0 条日志), 静默失效。
283
- // 收尾注入: session.idle (= 每轮结束) 经 client.session.promptAsync 注入收尾指令,
284
- // 与 pi 的 agent_end 同策略(真改过文件 + .brain 存在 + log.md 今日无记录, 每会话一次)。
285
- return `/**
286
- * abs (agent-brain-sync) — Op​encode plugin。
287
- * 纯触发: 生命周期事件 → 技术日志一行 (~/.abs/log/hooks.log, ABS_LOG_DIR 可覆盖)。fire-and-forget。
288
- * 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
289
- * Op​encode 事件: session.created / session.idle / session.deleted (idle = 每轮结束, 对应 pi 的 agent_end)。
290
- * 收尾注入: session.idle 且本会话真改过文件 + .brain 今日无记录 → promptAsync 推 agent 走收尾循环。
459
+ /**
460
+ * opencode 路径歧义提示(返回一行警告文本,无歧义时返回 null)。
461
+ *
462
+ * 背景: opencode 的全局配置默认在 ~/.config/opencode/,但它同样尊重
463
+ * XDG_CONFIG_HOME(官方支持 XDG 规范)。若用户设了该变量,opencode 会去
464
+ * $XDG_CONFIG_HOME/opencode/ 读配置,而本工具默认仍写 ~/.config/opencode/ ——
465
+ * 于是插件装了却不生效(静默失效,最难查)。同理 OPENCODE_CONFIG 可覆盖配置文件路径。
466
+ *
467
+ * 为什么只警告不自动跟随: ①跟随会让"装到哪"依赖环境变量,变得不可预测;
468
+ * ②若 opencode 实际没读该变量,跟着写反而错。故只提示,把决定权留给用户 ——
469
+ * 用户可用 ABS_OPENCODE_HOME 显式指定目标。
291
470
  */
292
- import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
293
- import { homedir } from "node:os"
294
- import { join, dirname } from "node:path"
295
-
296
- const server = async ({ client, directory }) => {
297
- // 本会话是否真改过文件 (由 tool.execute.after 观测)
298
- let wroteFiles = false
299
- let nudged = false
300
- let idleSeen = false
301
-
302
- async function logHook(evt) {
303
- try {
304
- const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
305
- const d = new Date()
306
- const pad = (n) => String(n).padStart(2, "0")
307
- const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
308
- await mkdir(dir, { recursive: true })
309
- await appendFile(join(dir, "hooks.log"), "[" + stamp + "] opencode:" + evt + "\\n")
310
- } catch {} // fire-and-forget: 永不阻塞宿主
311
- }
312
-
313
- // 从 cwd 向上找最近含 .brain/ 的祖先目录
314
- async function findBrain(cwd) {
315
- let d = cwd || process.cwd()
316
- for (;;) {
317
- try { const st = await stat(join(d, ".brain")); if (st.isDirectory()) return join(d, ".brain") } catch {}
318
- const up = dirname(d)
319
- if (up === d) return null
320
- d = up
321
- }
471
+ function opencodePathWarning() {
472
+ // 本工具实际会写入的位置(ABS_OPENCODE_HOME 覆盖时即该值)
473
+ const ours = hostConfigRoot("opencode");
474
+ const hints = [];
475
+
476
+ // opencode 自身可能读取的位置。它支持 XDG 规范,故 XDG_CONFIG_HOME 优先于 ~/.config。
477
+ const xdg = process.env.XDG_CONFIG_HOME;
478
+ const opencodeWillRead = xdg ? join(xdg, "opencode") : null;
479
+ if (opencodeWillRead && resolve(opencodeWillRead) !== resolve(ours)) {
480
+ hints.push(`XDG_CONFIG_HOME=${xdg} → opencode 会读 ${opencodeWillRead},但本工具写的是 ${ours}`);
322
481
  }
323
-
324
- // 判定"今天是否收尾过": 必须匹配 log.md 的条目头 '## [YYYY-MM-DD HH:MM]'。
325
- // 坑: 曾用裸日期 substring(includes('2026-09-10')), 结果正文/任务行里任何一处
326
- // 提到今天就能把 nudge 永久压掉(误判为已收尾), 守卫形同虚设。
327
- async function loggedToday(brain) {
328
- try {
329
- const txt = await readFile(join(brain, "log.md"), "utf8")
330
- const d = new Date()
331
- const pad = (n) => String(n).padStart(2, "0")
332
- const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
333
- return new RegExp("^## \\\\[" + today + " \\\\d{2}:\\\\d{2}\\\\]", "m").test(txt)
334
- } catch { return false }
335
- }
336
-
337
- const TEARDOWN_MSG =
338
- "[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
339
- "1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
340
- "2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \\"断点\\";\\n" +
341
- "3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
342
- "4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
343
- "简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。"
344
-
345
- // bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
346
- const READONLY_CMD = /^\\s*(ls|cat|grep|rg|find|head|tail|wc|git\\s+(status|log|diff|show|branch)|pwd|which|echo|node\\s+-v|npm\\s+(ls|view)|curl)\\b/
347
-
348
- return {
349
- // 观测真实写操作: write/edit/patch 类工具成功即标记。
350
- // 坑: 曾漏掉 bash —— op​encode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
351
- // 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
352
- "tool.execute.after": async ({ tool, args }) => {
353
- const t = String(tool || "").toLowerCase()
354
- if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
355
- wroteFiles = true
356
- return
357
- }
358
- // bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
359
- if (t === "bash") {
360
- const cmd = String((args && (args.command || args.cmd)) || "")
361
- if (cmd && !READONLY_CMD.test(cmd)) wroteFiles = true
362
- }
363
- },
364
-
365
- event: async ({ event }) => {
366
- const type = event && event.type
367
- if (!type) return
368
- if (type === "session.created" || type === "session.deleted") {
369
- await logHook(type)
370
- return
371
- }
372
- if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做收尾判定
373
- const cwd = directory || process.cwd()
374
- const brain = await findBrain(cwd)
375
- // 可观测性: 首次 idle 无条件留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
376
- if (!idleSeen) {
377
- idleSeen = true
378
- await logHook(\`session.idle:seen cwd=\${cwd} brain=\${brain || 'none'}\`)
379
- }
380
- if (nudged || !wroteFiles) return
381
- const sessionID = event.properties && event.properties.sessionID
382
- if (!sessionID) return
383
- if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
384
- if (await loggedToday(brain)) return // 今天已收尾过
385
- nudged = true
386
- await logHook("session.idle:teardown-nudge")
387
- try {
388
- await client.session.promptAsync({
389
- path: { id: sessionID },
390
- query: { directory: cwd },
391
- body: { parts: [{ type: "text", text: TEARDOWN_MSG }] },
392
- })
393
- } catch {} // 注入失败不阻塞宿主
394
- },
482
+ if (process.env.OPENCODE_CONFIG) {
483
+ hints.push(`OPENCODE_CONFIG=${process.env.OPENCODE_CONFIG} 会覆盖全局配置路径(优先级高于上面两者)`);
395
484
  }
396
- }
397
485
 
398
- export default {
399
- id: "abs",
400
- server,
486
+ if (!hints.length) return null;
487
+ // 修复: 曾经 ABS_OPENCODE_HOME 一被设置就无条件 return null —— 于是即使用户
488
+ // 显式指定的目标与 opencode 实际会读的位置也不一致,警告仍完全静默。
489
+ // 现在改为「只比较路径是否一致」: 一致就静默(无歧义),不一致就提示,
490
+ // 无论目标来自默认值还是显式 env。
491
+ const via = process.env.ABS_OPENCODE_HOME ? "(目标由 ABS_OPENCODE_HOME 显式指定)" : "";
492
+ return [
493
+ "⚠ opencode 配置路径可能不一致(已装的插件可能不生效):" + via,
494
+ ...hints.map((h) => ` - ${h}`),
495
+ " 如需指定目标: ABS_OPENCODE_HOME=<配置根> abs install --agent opencode",
496
+ ].join("\n");
401
497
  }
402
- ${MARK}
403
- `;
498
+
499
+ // ============================ Opencode / Pi (TS 插件) ============================
500
+ function opencodePluginSource() {
501
+ // 模板已外置到 hooks/abs.opencode.ts(真文件,非字符串)。
502
+ // 为什么外置: ①改一处逻辑不用在模板里再改一遍(曾漏改导致行为不一致)
503
+ // ②生成产物能被测试直接 import 断言, 不再解析字符串产物(间接测试的税)
504
+ // 转义坑已消除: 以前模板嵌在模板字符串里,\n / \" / \` 双层转义极易写错(代码注释亦踩过)。
505
+ return renderPluginTemplate('abs.opencode.ts', { '@@MARK@@': MARK });
404
506
  }
405
507
 
406
508
  // ============================ Pi (TS extension) ============================
@@ -408,145 +510,29 @@ ${MARK}
408
510
  // 不能复用 opencodePluginSource 的写法(那是 export const ... = ({ project }) => ...)。
409
511
  // 两者同纪律: 生命周期事件只进技术日志 hooks.log, 不进图谱 log.md。
410
512
  function piPluginSource() {
411
- return `/**
412
- * abs (agent-brain-sync) — Pi extension。
413
- * 纯触发: 会话生命周期事件 → 技术日志一行 (~/.abs/log/hooks.log, ABS_LOG_DIR 可覆盖)。fire-and-forget。
414
- * 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
415
- * Pi 事件: session_start / session_shutdown (对应 host hook 的 SessionStart/SessionEnd)。
416
- * session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
417
- * agent_end 收尾注入: 本会话真改过文件 + .brain 今日无记录 → 注入一条收尾指令, 逼 agent 走收尾循环
418
- * (被动记日志不够 —— 没人提醒就不会有人收尾)。每会话最多一次, 且已收尾后不再打扰。
419
- */
420
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
421
- import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
422
- import { homedir } from "node:os"
423
- import { join, dirname } from "node:path"
424
- import { spawn } from "node:child_process"
425
-
426
- const ABS_BIN = "${join(ABS_DIR, 'bin', 'abs.js')}"
427
-
428
- async function logHook(evt: string): Promise<void> {
429
- const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
430
- const d = new Date()
431
- const pad = (n: number) => String(n).padStart(2, "0")
432
- const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
433
- const line = "[" + stamp + "] pi:" + evt + "\\n"
434
- await mkdir(dir, { recursive: true })
435
- await appendFile(join(dir, "hooks.log"), line)
436
- }
437
-
438
- // 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
439
- // cwd 用事件 ctx.cwd(当前项目),让 abs 从该目录向上定位 .brain/。
440
- function snapshotWrapup(cwd: string): void {
441
- try {
442
- const child = spawn(process.execPath, [ABS_BIN, "wrapup"], {
443
- cwd: cwd || process.cwd(), stdio: "ignore", detached: true,
444
- })
445
- child.unref()
446
- } catch {} // fire-and-forget
447
- }
448
-
449
- // ---- 收尾注入判定 ----
450
- // 真改过文件的工具(read/grep/ls 不算, 那些不产生可沉淀的产出)
451
- const WRITE_TOOLS = new Set(["write", "edit", "multi_edit", "apply_patch", "bash"])
452
- // bash 里只跑查询类命令不算改文件
453
- const READONLY_CMD = /^\\s*(ls|cat|grep|rg|find|head|tail|wc|git\\s+(status|log|diff|show|branch)|pwd|which|echo|node\\s+-v|npm\\s+(ls|view)|curl)\\b/
454
-
455
- function hasWriteWork(toolResults: any[]): boolean {
456
- for (const r of toolResults || []) {
457
- const name = String(r?.toolName || "")
458
- if (!WRITE_TOOLS.has(name)) continue
459
- if (r?.isError) continue
460
- if (name === "bash") {
461
- const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
462
- if (READONLY_CMD.test(cmd)) continue
463
- }
464
- return true
465
- }
466
- return false
467
- }
468
-
469
- /** 从 cwd 向上找最近含 .brain/ 的祖先目录。 */
470
- async function findBrain(cwd: string): Promise<string | null> {
471
- let d = cwd || process.cwd()
472
- for (;;) {
473
- try {
474
- const st = await stat(join(d, ".brain"))
475
- if (st.isDirectory()) return join(d, ".brain")
476
- } catch {}
477
- const up = dirname(d)
478
- if (up === d) return null
479
- d = up
480
- }
513
+ // 模板已外置到 hooks/abs.pi.ts。理由同 opencodePluginSource。
514
+ return renderPluginTemplate('abs.pi.ts', {
515
+ '@@ABS_BIN@@': stableBinPath('abs.js'),
516
+ '@@MARK@@': MARK,
517
+ });
481
518
  }
482
519
 
483
520
  /**
484
- * .brain/log.md 今天有记录吗? 有=已收尾, 不再打扰。
485
- * 必须匹配条目头 '## [YYYY-MM-DD HH:MM]' —— 裸日期 substring 会被正文里任意一处
486
- * 今天的日期误命中, 导致"已收尾"误判、nudge 永不触发。
521
+ * 读 hooks/ 下的插件模板(真文件,非字符串)并替换 @@占位符@@。
522
+ * 为什么用占位符而非模板字符串: 模板本身是 TS 代码,含 \n / " / \` 等;
523
+ * 以前嵌在模板字符串里需双层转义,极易写错且只能靠“生成的字符串”间接测。
524
+ * 现在模板是真文件 → 可用真 TS 编辑器/测试直接 import 断言。
525
+ * 找不到模板文件时抛错(宁可失败也不静默生成空插件)。
487
526
  */
488
- async function loggedToday(brain: string): Promise<boolean> {
527
+ function renderPluginTemplate(name, vars) {
528
+ let t;
489
529
  try {
490
- const txt = await readFile(join(brain, "log.md"), "utf8")
491
- const d = new Date()
492
- const pad = (n: number) => String(n).padStart(2, "0")
493
- const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
494
- // 注意: 这里是真实 TS, 不是生成代码的模板字符串。用拼接形式避免反斜杠逃逸歧义。
495
- return new RegExp("^## \\\\[" + today + " \\\\d{2}:\\\\d{2}\\\\]", "m").test(txt)
496
- } catch { return false }
497
- }
498
-
499
- export default function absPiHook(pi: ExtensionAPI): void {
500
- pi.on("session_start", () => logHook("session_start").catch(() => {}))
501
-
502
- // 收尾注入: 每个会话最多一次, 避免反复打扰。
503
- let teardownNudged = false
504
- let agentEndSeen = false
505
- pi.on("agent_end", async (event: any, ctx: any) => {
506
- if (teardownNudged) return
507
- const cwd = (ctx && ctx.cwd) || process.cwd()
508
- const brain = await findBrain(cwd)
509
- // 可观测性: 本会话首次 agent_end 无条件留一行痕。
510
- // 没有它就无法区分三种状态: ①事件没触发 ②触发了但被守卫拦下 ③真注入了。
511
- if (!agentEndSeen) {
512
- agentEndSeen = true
513
- // 带上 cwd 与命中的 brain —— 否则事后无法解释“为何这条 nudge 会出现”
514
- await logHook(\`agent_end:seen cwd=\${cwd} brain=\${brain || 'none'}\`).catch(() => {})
515
- }
516
- if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
517
- if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
518
- if (await loggedToday(brain)) return // 今天已收尾过
519
- teardownNudged = true
520
- await logHook("agent_end:teardown-nudge").catch(() => {})
521
- try {
522
- pi.sendUserMessage(
523
- "[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
524
- "1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
525
- "2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \\"断点\\";\\n" +
526
- "3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
527
- "4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
528
- "简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。",
529
- { deliverAs: "followUp" },
530
- )
531
- } catch {}
532
- })
533
-
534
- pi.on("session_shutdown", (_e: any, ctx: any) => {
535
- snapshotWrapup((ctx && ctx.cwd) || process.cwd())
536
- logHook("session_shutdown").catch(() => {})
537
- })
538
- }
539
-
540
- /** agent_end 的 event.messages 里翻出 toolResult 消息。 */
541
- function collectToolResults(messages: any[]): any[] {
542
- const out: any[] = []
543
- for (const m of messages || []) {
544
- if (m && m.role === "toolResult") out.push(m)
530
+ t = fsSync.readFileSync(join(ABS_DIR, 'hooks', name), 'utf8');
531
+ } catch (e) {
532
+ throw new Error(`插件模板缺失: hooks/${name}(安装包不完整?): ${e.message}`);
545
533
  }
546
- return out
547
- }
548
- ${MARK}
549
- `;
534
+ for (const [k, v] of Object.entries(vars || {})) t = t.split(k).join(v);
535
+ return t;
550
536
  }
551
537
 
552
538
  async function installOpenCode({ withMcp, withSkill, log }) {
@@ -555,6 +541,8 @@ async function installOpenCode({ withMcp, withSkill, log }) {
555
541
  const p = join(dir, 'abs.ts');
556
542
  await atomicWrite(p, opencodePluginSource());
557
543
  steps.push(`✓ hook(ts plugin) → ${p}`);
544
+ const warn = opencodePathWarning();
545
+ if (warn) steps.push(warn);
558
546
  if (withMcp) {
559
547
  const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
560
548
  const cfg = await readJson(mcpP);
@@ -577,12 +565,18 @@ async function installOpenCode({ withMcp, withSkill, log }) {
577
565
 
578
566
  async function uninstallOpenCode() {
579
567
  const steps = [];
568
+ // 路径歧义提示(卸载侧同样有价值:可能提示用户去清另一个位置的旧副本)
569
+ const warn = opencodePathWarning();
570
+ if (warn) steps.push(warn);
580
571
  const plugin = join(hostConfigRoot('opencode'), 'plugins', 'abs.ts');
581
572
  await fs.rm(plugin, { force: true });
582
573
  steps.push(`✓ plugin 已删除`);
583
574
  const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
584
- const cfg = await readJson(mcpP);
585
- if (cfg.mcp && cfg.mcp.abs) {
575
+ const cfg = await readJson(mcpP, { strict: false });
576
+ if (cfg === null) {
577
+ steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
578
+ }
579
+ if (cfg && cfg.mcp && cfg.mcp.abs) {
586
580
  delete cfg.mcp.abs;
587
581
  await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
588
582
  steps.push(`✓ MCP 已从 ${mcpP} 移除`);
@@ -625,8 +619,11 @@ async function uninstallPi() {
625
619
  await fs.rm(hostSkillDir('pi'), { recursive: true, force: true });
626
620
  steps.push(`✓ skill 已删除`);
627
621
  const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
628
- const cfg = await readJson(mcpP);
629
- if (cfg.mcpServers && cfg.mcpServers.abs) {
622
+ const cfg = await readJson(mcpP, { strict: false });
623
+ if (cfg === null) {
624
+ steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
625
+ }
626
+ if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
630
627
  delete cfg.mcpServers.abs;
631
628
  await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
632
629
  steps.push(`✓ MCP 已从 ${mcpP} 移除`);
@@ -647,15 +644,30 @@ export function installSummary() {
647
644
  }
648
645
 
649
646
  export async function runInstall({ agent, mcp = true, skill = true, yes = false } = {}) {
650
- const targets = agent ? [agent] : await pickAgents();
647
+ const targets = agent ? [agent] : await pickAgents(yes);
648
+ const failed = [];
651
649
  for (const key of targets) {
652
650
  const inst = INSTALLERS[key];
653
651
  if (!inst) throw new Error(`未知 agent: ${key} (可用: ${Object.keys(INSTALLERS).join(', ')})`);
654
652
  console.log(`\n▸ 安装到 ${key} …`);
655
- for (const line of await inst.on({ withMcp: mcp, withSkill: skill })) {
656
- console.log(' ' + line);
653
+ try {
654
+ for (const line of await inst.on({ withMcp: mcp, withSkill: skill })) {
655
+ console.log(' ' + line);
656
+ }
657
+ } catch (e) {
658
+ // 逐宿主容错: 一个宿主失败不应让其余宿主整体被跳过。
659
+ // 坑: 曾经任一处报错就直接上抛,于是前序宿主已装、后续宿主全未装 —— 半成品状态。
660
+ // 注意: 单宿主安装(agent 指定)时仍上抛,保持 CLI 非零退出语义。
661
+ failed.push({ key, msg: String(e && e.message ? e.message : e) });
662
+ console.error(` ✗ ${key} 安装失败: ${failed[failed.length - 1].msg}`);
663
+ if (agent) throw e;
657
664
  }
658
665
  }
666
+ if (failed.length) {
667
+ console.log(`\n⚠ ${failed.length} 个宿主安装失败: ${failed.map((f) => f.key).join(', ')}`);
668
+ console.log(' 其余宿主已完成。修复上述问题后重跑 `abs install`(幂等,不会重复写入)。');
669
+ throw new Error(`${failed.length} 个宿主安装失败(见上)`);
670
+ }
659
671
  console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
660
672
  }
661
673
 
@@ -671,8 +683,11 @@ export async function runUninstall({ agent, yes = false } = {}) {
671
683
  }
672
684
 
673
685
  // 交互式多选(无 TTY 时回退为全部)
674
- async function pickAgents() {
675
- if (!process.stdin.isTTY) return Object.keys(INSTALLERS);
686
+ async function pickAgents(yes = false) {
687
+ // --yes / 非 TTY: 直接全选,绝不弹交互。
688
+ // 坑: 曾经只看 isTTY,--yes 被收下却从不使用 —— 在 TTY 里跑
689
+ // abs install --yes(自动化/脚本)仍会弹提示并挂起等输入。
690
+ if (yes || !process.stdin.isTTY) return Object.keys(INSTALLERS);
676
691
  const readline = await import('node:readline/promises');
677
692
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
678
693
  console.log('选择要安装的智能体 (逗号分隔, 回车=全部):');