@fanchao8609/agent_brain_sync 1.31.2 → 1.31.3

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
@@ -172,7 +172,7 @@ npm i -g @fanchao8609/agent_brain_sync@latest
172
172
  abs install # 重新刷 hook/skill
173
173
  ```
174
174
 
175
- > ⚠️ 改完源码(尤其 `skill/*/SKILL.md`、`src/`)后**必须 `abs install --yes` 重扇出**,否则宿主还在跑旧副本 —— 版本号与代码会脱节。发布用 `npm version patch`(自动打 tag),别手改 `package.json` 的 version;发布后 `npm view` 有缓存延迟,必要时 `npm cache clean --force` 再验。
175
+ > ⚠️ 改完源码(尤其 `skill/*/SKILL.md`、`src/`)后**必须重扇出**,否则宿主还在跑旧副本 —— 版本号与代码会脱节。TTY 下跑 `abs install` 选宿主;脚本/CI 里 `abs install --agent pi --yes`(`--yes` 只装默认的 pi,不是全部)。发布用 `npm version patch`(自动打 tag),别手改 `package.json` 的 version;发布后 `npm view` 有缓存延迟,必要时 `npm cache clean --force` 再验。
176
176
 
177
177
  ---
178
178
 
package/bin/abs.js CHANGED
@@ -410,8 +410,8 @@ const subUsage = {
410
410
  '',
411
411
  '参数:',
412
412
  ' --agent <宿主> 只装一个宿主: claude-code | codex | opencode | pi | omp',
413
- ' (不传则在 TTY 下交互多选; 非 TTY / --yes 时为全部)',
414
- ' --yes 不交互,直接装全部宿主(自动化用)',
413
+ ' (不传则在 TTY 下交互多选; 非 TTY / --yes 时只装默认的 pi)',
414
+ ' --yes 不交互(只装默认的 pi;要装全部用 --all)',
415
415
  ' --no-mcp 不注册 MCP server',
416
416
  ' --no-skill 不装 skill',
417
417
  '',
@@ -422,7 +422,8 @@ const subUsage = {
422
422
  '',
423
423
  '例:',
424
424
  ' abs install 交互选择',
425
- ' abs install --yes 全部宿主',
425
+ ' abs install --all 全部宿主',
426
+ ' abs install --yes 不问,只装 pi',
426
427
  ' abs install --agent pi --yes 只装 pi',
427
428
  ].join('\n'),
428
429
  uninstall: [
package/hooks/event.sh CHANGED
@@ -13,7 +13,11 @@ ABS_BIN="__ABS_BIN__"
13
13
  NODE_BIN="__NODE_BIN__"
14
14
  EVENT="__EVENT__"
15
15
 
16
- PAYLOAD=$(cat 2>/dev/null | head -c 500)
16
+ # 全文收下(Stop 的 transcript_path / session_id 可能在 500 字节之外,截断会让
17
+ # teardown-check 解析不到字段)。日志行仍另截 120;指纹用这份全文(上限 64KB),
18
+ # 旧版只指纹前 500 字节:两个不同事件开头相同时会被当重复丢掉。
19
+ # 64KB 对 hook 元数据足够,又不至于把 MB 级 prompt 整个摚进 shell 变量。
20
+ PAYLOAD=$(cat 2>/dev/null | head -c 65536)
17
21
 
18
22
  # ── Stop 收尾注入 (CC/Co​dex 的"主动推") ─────────────────────────────────
19
23
  # 守卫同 pi 插件四条件: ①本会话真改过文件 ②本项目有 .brain ③log.md 今日无条目 ④每会话一次。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.31.2",
3
+ "version": "1.31.3",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/brain/gate.js CHANGED
@@ -4,12 +4,15 @@
4
4
  import { setFormatGate } from '../lock.js';
5
5
  import { enforceBrainFormat, ensureStateMark } from '../todo.js';
6
6
  import { BRAIN_SHAPE, legacyRenames } from './shape.js';
7
+ import { basename } from 'node:path';
7
8
 
8
9
  /** 写入侧格式闸门:lock.js 的 editFile 是所有写入的唯一收口,
9
10
  * 这里把「哪个文件名用哪套标准」告诉它 —— 三个目标文件之外的写入一律放行。
10
11
  * 非目标文件返回 null 表示不管。 */
11
12
  setFormatGate((file, text, current) => {
12
- const name = file.split('/').pop();
13
+ // basename 而不是 split('/'):Windows 路径是反斜杠,split 取不到文件名,
14
+ // 闸门会静默放行(等于没装)。install.js 有 win32 候选,Windows 是支持目标。
15
+ const name = basename(file);
13
16
  const spec = BRAIN_SHAPE[name];
14
17
  if (!spec) return null;
15
18
  const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() }, current);
@@ -332,9 +332,10 @@ export async function cmdIngest({ dir, file, force = false, mark = false, last =
332
332
  hit = best;
333
333
  } else {
334
334
  const want = basename(String(file).trim());
335
+ // 子串匹配必须够长(≥8):短词会把「日志」「test」这类撞上任意文件名,
336
+ // 静默选错会话。精确同名仍然放行。
335
337
  hit = names.find((n) => n === want)
336
- || names.find((n) => want.length >= 8 && n.includes(want))
337
- || names.find((n) => n.includes(want));
338
+ || (want.length >= 8 && names.find((n) => n.includes(want)));
338
339
  if (!hit) {
339
340
  return `✗ 会话目录里找不到 "${want}"。\n 可用: abs summon(列出待总结的会话)`;
340
341
  }
package/src/install.js CHANGED
@@ -634,7 +634,10 @@ async function uninstallClaudeCode() {
634
634
  if (settings && settings.mcpServers && settings.mcpServers.abs) {
635
635
  delete settings.mcpServers.abs; touched = true;
636
636
  }
637
- if (touched) await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
637
+ if (touched) {
638
+ await backup(settingsP);
639
+ await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
640
+ }
638
641
  if (settings) steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
639
642
  // staged hook 脚本目录 —— 只删本 agent 的,绝不整删 ~/.abs/(其它 agent 的 hook / mcp.log 共存)
640
643
  await fs.rm(join(homedir(), '.abs', 'hooks', 'claude-code'), { recursive: true, force: true });
@@ -751,6 +754,7 @@ async function uninstallCodex() {
751
754
  }
752
755
  }
753
756
  if (changed) {
757
+ await backup(p);
754
758
  await atomicWrite(p, JSON.stringify(cfg, null, 2));
755
759
  steps.push(`✓ hooks 已从 ${p} 移除`);
756
760
  }
@@ -761,6 +765,7 @@ async function uninstallCodex() {
761
765
  // 留下非法行 ["/…/mcp.js"] 让用户的 TOML 解析失败。
762
766
  const { text: after, removed } = removeTomlAbsSection(text);
763
767
  if (removed) {
768
+ await backup(mcpP);
764
769
  await atomicWrite(mcpP, after);
765
770
  steps.push(`✓ MCP 已从 ${mcpP} 移除`);
766
771
  }
@@ -889,6 +894,7 @@ async function uninstallOpenCode() {
889
894
  }
890
895
  if (cfg && cfg.mcp && cfg.mcp.abs) {
891
896
  delete cfg.mcp.abs;
897
+ await backup(mcpP);
892
898
  await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
893
899
  steps.push(`✓ MCP 已从 ${mcpP} 移除`);
894
900
  }
@@ -949,6 +955,7 @@ async function uninstallPi() {
949
955
  }
950
956
  if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
951
957
  delete cfg.mcpServers.abs;
958
+ await backup(mcpP);
952
959
  await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
953
960
  steps.push(`✓ MCP 已从 ${mcpP} 移除`);
954
961
  }
@@ -1001,6 +1008,7 @@ async function uninstallOmp() {
1001
1008
  }
1002
1009
  if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
1003
1010
  delete cfg.mcpServers.abs;
1011
+ await backup(mcpP);
1004
1012
  await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
1005
1013
  steps.push(`✓ MCP 已从 ${mcpP} 移除`);
1006
1014
  }
package/src/lock.js CHANGED
@@ -32,10 +32,22 @@ const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
32
32
  // 抢锁总预算:排队等锁的进程须依次排完。多进程高并发(CLI/MCP/hook 同刻抢一文件)下,
33
33
  // 3s 会让后到进程在排队中途 LockTimeout 崩溃丢写入。设 30s 容纳大批排队者正常排完。
34
34
  const LOCK_MAX_WAIT_MS = 30 * 1000;
35
- // 超过此年龄视为残留锁(持锁进程崩溃没释放),允许摘除。须 > LOCK_MAX_WAIT_MS,
36
- // 否则持锁进程在排队预算内会被误当残留摘除导致临界区重叠。
35
+ // 兜底年龄:锁文件里没有有效 PID 时(旧版空锁 / PID 读不出),超过此年龄才当残留摘除。
36
+ // 有 PID 后不再按年龄摘活人的锁 —— mutator 跑慢一点也不能让两个进程同时进临界区。
37
37
  const STALE_MS = 60 * 1000;
38
38
 
39
+ /** 进程还活着吗。true=活 / false=已死 / null=判不了(没写 PID、权限不够等)。 */
40
+ function pidAlive(pid) {
41
+ if (!Number.isInteger(pid) || pid <= 0) return null;
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ } catch (e) {
46
+ // ESRCH = 没这个进程;其它(权限等)当活着,宁可等也不误抢
47
+ return e.code === 'ESRCH' ? false : true;
48
+ }
49
+ }
50
+
39
51
  async function acquireLock(lockPath) {
40
52
  const start = Date.now();
41
53
  let backoff = LOCK_WAIT_BASE_MS;
@@ -43,20 +55,35 @@ async function acquireLock(lockPath) {
43
55
  let handle = null;
44
56
  try {
45
57
  handle = await fs.open(lockPath, 'wx'); // 原子创建(O_EXCL);已存在则抛 EEXIST → 排队等
58
+ // 写入 PID:后来者据此判断持锁人是否已死(崩溃残留可立刻摘,不等 60s)。
59
+ // 写 PID 失败不回滚 —— 空锁退回按年龄判残留,锁本身仍然有效。
60
+ await handle.writeFile(String(process.pid), 'utf8').catch(() => {});
46
61
  await handle.close(); // 锁的存在即持有信号;无需保持 fd
47
62
  return;
48
63
  } catch (e) {
49
64
  if (handle) await handle.close().catch(() => {});
50
65
  if (e.code !== 'EEXIST') throw e;
51
- // 残留锁检测:锁太老(持锁进程崩溃没释放)则摘除重试;正常排队者持锁时间远小于阈值
66
+ // 残留锁检测:优先看 PID(持锁进程已死 → 立刻摘,不等 60s);
67
+ // PID 读不出再退回按年龄(兼容旧版空锁文件)。
68
+ // ★ 持锁进程还活着就绝不摘 —— 哪怕超过 STALE_MS。
69
+ // 旧逻辑按 60s 硬摘,mutator 跑慢一点就让两个进程同时进临界区(2026-11-10 审查)。
52
70
  try {
53
- const st = await fs.stat(lockPath);
54
- if (Date.now() - st.mtimeMs > STALE_MS) {
71
+ const raw = (await fs.readFile(lockPath, 'utf8')).trim();
72
+ const alive = pidAlive(Number(raw));
73
+ if (alive === false) {
55
74
  await fs.rm(lockPath, { force: true });
56
- backoff = LOCK_WAIT_BASE_MS; // 摘除残留后重置退避
75
+ backoff = LOCK_WAIT_BASE_MS;
57
76
  continue;
58
77
  }
59
- } catch { /* stat 失败(锁刚被释放) → 下一轮重试 */ }
78
+ if (alive === null) {
79
+ const st = await fs.stat(lockPath);
80
+ if (Date.now() - st.mtimeMs > STALE_MS) {
81
+ await fs.rm(lockPath, { force: true });
82
+ backoff = LOCK_WAIT_BASE_MS;
83
+ continue;
84
+ }
85
+ }
86
+ } catch { /* 读锁失败(刚被释放) → 下一轮重试 */ }
60
87
  if (Date.now() - start > LOCK_MAX_WAIT_MS) {
61
88
  throw new LockTimeout(`写锁排队超时(等 ${Date.now() - start}ms > 预算 ${LOCK_MAX_WAIT_MS}ms): ${lockPath} 仍被占用`);
62
89
  }
@@ -98,7 +125,18 @@ export async function editFile(file, mutator) {
98
125
  if (gate) write = gate(file, write, current) ?? write;
99
126
  }
100
127
  if (write && write !== current) {
101
- await fs.writeFile(file, write, 'utf8');
128
+ // 原子落盘:先写同目录临时文件再 rename。
129
+ // fs.writeFile 是「截断再写」,进程死在中间 = 图谱剩半截/空文件。
130
+ // rename 在同一文件系统上是原子的 —— 读方要么见旧要么见新,不见半截。
131
+ // tmp 名带随机段:同毫秒两个进程写同一文件也不会撞名(summon.js 同坑)。
132
+ const tmp = join(dirname(file), `.${basename(file)}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
133
+ try {
134
+ await fs.writeFile(tmp, write, 'utf8');
135
+ await fs.rename(tmp, file);
136
+ } catch (e) {
137
+ await fs.rm(tmp, { force: true }).catch(() => {});
138
+ throw e;
139
+ }
102
140
  }
103
141
  return res;
104
142
  } finally {
@@ -9,15 +9,22 @@ import { ensurePersonPage } from '../note.js';
9
9
 
10
10
  // 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
11
11
  // log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
12
+ /** 任务 id 的合法形状:一个词,不含空白 / 路径分隔符 / 方括号。
13
+ * 为什么这么严(2026-11-10 审查实测):
14
+ * ① 空白会被 extractId 截断(静默丢信息);换行还会往 todo.md 里注一行。
15
+ * ② `/` `\` 会被 setMemo 拼进文件名写出 .brain/ 外(路径穿越,`../..` 实测逃逸)。
16
+ * ③ `[` `]` 撞行首状态标记 `[进行中]` 的语法。
17
+ * 旧注释写了「不能含方括号」,代码只查了空格 tab —— 这次收口。 */
18
+ function isValidId(s) {
19
+ return !/[\s\\/\0[\]]/.test(s);
20
+ }
21
+
12
22
  export async function cmdTask({ dir, action, id, section, note, as, session, n }) {
13
- // ★ id 校验(2026-10-16 模糊测试找出来的真 bug):id 不能含空白/方括号。
14
- // 不校就是**静默丢信息**:id="带 空格" 写进文件时被 extractId 截成 "带",
15
- // 后面的 "空格" 与描述一起消失,不报错(实测:`abs todo start "带 空格"` 落成 `- [ ] 带`)。
16
- // 宁可明确拒掉,也不替人猜哪个词算 id。
23
+ // ★ id 校验:宁可明确拒掉,也不替人猜哪个词算 id、不把怪 id 写出图谱外。
17
24
  const idStr = id === undefined || id === null ? '' : String(id);
18
- if (idStr && (idStr.includes(' ') || idStr.includes('\t'))) {
25
+ if (idStr && !isValidId(idStr)) {
19
26
  throw new Error(
20
- `✗ 任务 id 不能含空格(收到 "${idStr}")—— id 是一个词,空白会被当分隔符。\n` +
27
+ `✗ 任务 id 不能含空格、/、\\、[、](收到 "${idStr}")—— id 是一个词,也会被拼进文件名。\n` +
21
28
  ` 想要带空格的名字:用连字符或下划线;完整名称写进 --note。`,
22
29
  );
23
30
  }
@@ -113,7 +120,7 @@ export async function cmdTask({ dir, action, id, section, note, as, session, n }
113
120
  // --note 传新 id(沿用既有 CLI 习惯,免得再加一个 flag)。
114
121
  if (!note) throw new Error('✗ 用法: abs todo rename <旧id> --note "<新id>"');
115
122
  const newId = String(note).replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim();
116
- if (!newId || /\s/.test(newId)) throw new Error(`✗ 新 id 不能为空或含空白: "${newId}"`);
123
+ if (!newId || !isValidId(newId)) throw new Error(`✗ 新 id 不能为空,且不能含空白、/、\\、[、]: "${newId}"`);
117
124
  const f = brainPath(root, 'todo.md');
118
125
  let done = false;
119
126
  await editFile(f, (cur) => {