@fanchao8609/agent_brain_sync 0.1.0
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 +112 -0
- package/bin/abs.js +112 -0
- package/bin/mcp.js +167 -0
- package/hooks/event.sh +58 -0
- package/package.json +33 -0
- package/skill/SKILL.md +200 -0
- package/src/brainio.js +66 -0
- package/src/hosts.js +65 -0
- package/src/index.js +40 -0
- package/src/install.js +416 -0
- package/src/lock.js +78 -0
- package/src/store.js +499 -0
- package/src/todo.js +308 -0
- package/src/wrapup.js +137 -0
package/src/install.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// src/install.js — 安装 / 卸载到各智能体(MCP + hook + skill)。
|
|
2
|
+
// 机制(抄 ai-memory,已确认):
|
|
3
|
+
// - Claude Code: ~/.claude/settings.json 的 hooks 对象(CamelCase 事件→command)。
|
|
4
|
+
// MCP: settings.json 顶层 mcpServers 或项目 .mcp.json。hook 要求 stdout 以 { 开头。
|
|
5
|
+
// - Codex: ~/.codex/hooks.json。
|
|
6
|
+
// - OpenCode / Pi: 官方只吃 TS plugin/extension(无 shell-hook 配置) → 初版给出手工指引。
|
|
7
|
+
// 纪律: 幂等(重复安装=更新)、原子写(tmp+rename)、卸载只删自己装的、写入前备份。
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join, dirname, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { HOSTS, hostByKey } from './hosts.js';
|
|
13
|
+
|
|
14
|
+
const ABS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const HOOK_TEMPLATE = join(ABS_DIR, 'hooks', 'event.sh');
|
|
16
|
+
const SKILL_SOURCE = join(ABS_DIR, 'skill', 'SKILL.md');
|
|
17
|
+
|
|
18
|
+
const MARK = '// abs-managed (agent-brain-sync)'; // TS plugin 标记
|
|
19
|
+
const JSON_MARK_KEY = 'abs-managed'; // JSON 内我们的命名空间
|
|
20
|
+
|
|
21
|
+
// ============================ 宿主 config 根 / skill 落点(统一走 env 或 homedir,测试可注入) ============================
|
|
22
|
+
/** 某宿主的配置根目录(settings.json 所在目录;env 覆盖优先,默认 ~/.<host> 或 ~/.config/<host>)。 */
|
|
23
|
+
export function hostConfigRoot(key) {
|
|
24
|
+
return hostByKey(key).configRoot();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 该宿主 skill 落点:<configRoot>/<skillSub>/abs-agent-brain-sync/。skillSub 与 hooks/MCP 同根,避免 env 隔离时分裂。
|
|
28
|
+
* 多数宿主 = <configRoot>/skills;pi 的用户级 skill 在 ~/.pi/agent/skills (configRoot=~/.pi)。 */
|
|
29
|
+
export function hostSkillDir(key) {
|
|
30
|
+
return join(hostConfigRoot(key), hostByKey(key).skillSub, 'abs-agent-brain-sync');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================ 工具函数 ============================
|
|
34
|
+
async function atomicWrite(p, text) {
|
|
35
|
+
await fs.mkdir(dirname(p), { recursive: true });
|
|
36
|
+
const tmp = `${p}.abs-tmp-${Date.now()}`;
|
|
37
|
+
await fs.writeFile(tmp, text, 'utf8');
|
|
38
|
+
await fs.rename(tmp, p);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function backup(p) {
|
|
42
|
+
try {
|
|
43
|
+
const bak = `${p}.abs-bak-${new Date().toISOString().slice(0, 10)}`;
|
|
44
|
+
await fs.copyFile(p, bak);
|
|
45
|
+
return bak;
|
|
46
|
+
} catch { return null; } // 文件不存在则无备份
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readJson(p) {
|
|
50
|
+
try { return JSON.parse(await fs.readFile(p, 'utf8')); } catch { return {}; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ABS_HOOK_MARK = '/.abs/hooks/';
|
|
54
|
+
/** 判断一个 hook 数组元素是不是 abs 装的(command 指向 ~/.abs/hooks/)。卸载/幂等去重用。 */
|
|
55
|
+
function entryHasAbs(entry) {
|
|
56
|
+
const hs = entry && entry.hooks ? (Array.isArray(entry.hooks) ? entry.hooks : [entry.hooks]) : [];
|
|
57
|
+
return hs.some((h) => typeof h?.command === 'string' && h.command.includes(ABS_HOOK_MARK));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================ Hook 脚本落盘 ============================
|
|
61
|
+
// 每个事件一份脚本(模板替换 EVENT/BIN),统一 stage 到 ~/.abs/hooks/<agent>/。
|
|
62
|
+
async function stageHookScripts(agentKey, events) {
|
|
63
|
+
const tpl = await fs.readFile(HOOK_TEMPLATE, 'utf8');
|
|
64
|
+
const dir = join(homedir(), '.abs', 'hooks', agentKey);
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const ev of events) {
|
|
67
|
+
const name = `abs-${ev}.sh`;
|
|
68
|
+
const p = join(dir, name);
|
|
69
|
+
const script = tpl
|
|
70
|
+
.replaceAll('__ABS_BIN__', join(ABS_DIR, 'bin', 'abs.js'))
|
|
71
|
+
.replaceAll('__NODE_BIN__', process.execPath)
|
|
72
|
+
.replaceAll('__EVENT__', ev);
|
|
73
|
+
await atomicWrite(p, script);
|
|
74
|
+
await fs.chmod(p, 0o755);
|
|
75
|
+
out[ev] = p;
|
|
76
|
+
}
|
|
77
|
+
return out; // { 'SessionStart': '/home/.abs/hooks/claude-code/abs-SessionStart.sh', ... }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================ Claude Code ============================
|
|
81
|
+
function claudeSettingsPath() {
|
|
82
|
+
return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function installClaudeCode({ withMcp, withSkill, log }) {
|
|
86
|
+
const steps = [];
|
|
87
|
+
// 1) hooks → settings.json (分区合并: 同事件可挂多个 hook 框架, 追加 abs 而非覆盖, 保留 moshi-hook 等)
|
|
88
|
+
const scriptMap = await stageHookScripts('claude-code', HOSTS[0].events);
|
|
89
|
+
const settingsP = claudeSettingsPath();
|
|
90
|
+
await backup(settingsP);
|
|
91
|
+
const settings = await readJson(settingsP);
|
|
92
|
+
settings.hooks = settings.hooks || {};
|
|
93
|
+
for (const [ev, script] of Object.entries(scriptMap)) {
|
|
94
|
+
const existing = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
|
|
95
|
+
const kept = existing.filter((e) => !entryHasAbs(e)); // 去掉旧 abs 条目, 幂等; 保留 moshi 等其它 hook
|
|
96
|
+
settings.hooks[ev] = [...kept, { hooks: [{ type: 'command', command: script }] }];
|
|
97
|
+
}
|
|
98
|
+
await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
|
|
99
|
+
steps.push(`✓ hooks → ${settingsP} (${Object.keys(scriptMap).length} 事件, 与既有 hook 共存)`);
|
|
100
|
+
|
|
101
|
+
// 2) MCP → settings.json mcpServers (stdio)
|
|
102
|
+
if (withMcp) {
|
|
103
|
+
settings.mcpServers = settings.mcpServers || {};
|
|
104
|
+
settings.mcpServers['abs'] = {
|
|
105
|
+
command: process.execPath,
|
|
106
|
+
args: [join(ABS_DIR, 'bin', 'mcp.js')],
|
|
107
|
+
};
|
|
108
|
+
await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
|
|
109
|
+
steps.push(`✓ MCP → settings.json mcpServers.abs (stdio)`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3) skill → <configRoot>/skills/abs-agent-brain-sync/SKILL.md (config 根 = CLAUDE_CONFIG_DIR)
|
|
113
|
+
if (withSkill) {
|
|
114
|
+
const target = join(hostSkillDir('claude-code'), 'SKILL.md');
|
|
115
|
+
await atomicWrite(target, await fs.readFile(SKILL_SOURCE, 'utf8'));
|
|
116
|
+
steps.push(`✓ skill → ${target}`);
|
|
117
|
+
}
|
|
118
|
+
return steps;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function uninstallClaudeCode() {
|
|
122
|
+
const steps = [];
|
|
123
|
+
const settingsP = claudeSettingsPath();
|
|
124
|
+
const settings = await readJson(settingsP);
|
|
125
|
+
let touched = false;
|
|
126
|
+
if (settings.hooks) {
|
|
127
|
+
const events = HOSTS[0].events;
|
|
128
|
+
for (const ev of events) {
|
|
129
|
+
if (!Array.isArray(settings.hooks[ev])) continue;
|
|
130
|
+
const kept = settings.hooks[ev].filter((e) => !entryHasAbs(e)); // 只删 abs, 保留 moshi 等共存 hook
|
|
131
|
+
if (kept.length !== settings.hooks[ev].length) {
|
|
132
|
+
if (kept.length) settings.hooks[ev] = kept;
|
|
133
|
+
else delete settings.hooks[ev]; // 无共存 hook 时整删该事件
|
|
134
|
+
touched = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (settings.mcpServers && settings.mcpServers.abs) {
|
|
139
|
+
delete settings.mcpServers.abs; touched = true;
|
|
140
|
+
}
|
|
141
|
+
if (touched) await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
|
|
142
|
+
steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
|
|
143
|
+
// staged hook 脚本目录 —— 只删本 agent 的,绝不整删 ~/.abs/(其它 agent 的 hook / mcp.log 共存)
|
|
144
|
+
await fs.rm(join(homedir(), '.abs', 'hooks', 'claude-code'), { recursive: true, force: true });
|
|
145
|
+
steps.push(`✓ ~/.abs/hooks/claude-code/ (本 agent hook 脚本) 已删除`);
|
|
146
|
+
// skill
|
|
147
|
+
const skillDir = hostSkillDir('claude-code');
|
|
148
|
+
await fs.rm(skillDir, { recursive: true, force: true });
|
|
149
|
+
steps.push(`✓ skill 已删除`);
|
|
150
|
+
return steps;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================ Codex ============================
|
|
154
|
+
async function installCodex({ withMcp, withSkill, log }) {
|
|
155
|
+
const steps = [];
|
|
156
|
+
const scriptMap = await stageHookScripts('codex', HOSTS[1].events);
|
|
157
|
+
const p = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'hooks.json');
|
|
158
|
+
await backup(p);
|
|
159
|
+
const cfg = await readJson(p);
|
|
160
|
+
cfg.hooks = cfg.hooks || [];
|
|
161
|
+
// 去掉旧的 abs 条目再写(幂等)
|
|
162
|
+
cfg.hooks = cfg.hooks.filter((h) => !String(h.command || '').includes('/.abs/hooks/'));
|
|
163
|
+
for (const [ev, script] of Object.entries(scriptMap)) {
|
|
164
|
+
cfg.hooks.push({ event: ev, command: script });
|
|
165
|
+
}
|
|
166
|
+
await atomicWrite(p, JSON.stringify(cfg, null, 2));
|
|
167
|
+
steps.push(`✓ hooks → ${p} (${Object.keys(scriptMap).length} 事件)`);
|
|
168
|
+
|
|
169
|
+
if (withMcp) {
|
|
170
|
+
const mcpP = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
|
|
171
|
+
let text = '';
|
|
172
|
+
try { text = await fs.readFile(mcpP, 'utf8'); } catch {}
|
|
173
|
+
if (!text.includes('[mcp_servers.abs]')) {
|
|
174
|
+
const block = `\n[mcp_servers.abs]\ncommand = "${process.execPath}"\nargs = ["${join(ABS_DIR, 'bin', 'mcp.js')}"]\n`;
|
|
175
|
+
await backup(mcpP);
|
|
176
|
+
await atomicWrite(mcpP, text.replace(/\s*$/, '') + '\n' + block);
|
|
177
|
+
steps.push(`✓ MCP → ${mcpP} [mcp_servers.abs]`);
|
|
178
|
+
} else {
|
|
179
|
+
steps.push(`• MCP → ${mcpP} 已存在, 跳过`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (withSkill) {
|
|
183
|
+
const target = join(hostSkillDir('codex'), 'SKILL.md');
|
|
184
|
+
await atomicWrite(target, await fs.readFile(SKILL_SOURCE, 'utf8'));
|
|
185
|
+
steps.push(`✓ skill → ${target}`);
|
|
186
|
+
}
|
|
187
|
+
return steps;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function uninstallCodex() {
|
|
191
|
+
const steps = [];
|
|
192
|
+
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
193
|
+
const p = join(home, 'hooks.json');
|
|
194
|
+
const cfg = await readJson(p);
|
|
195
|
+
if (Array.isArray(cfg.hooks)) {
|
|
196
|
+
const before = cfg.hooks.length;
|
|
197
|
+
cfg.hooks = cfg.hooks.filter((h) => !String(h.command || '').includes('/.abs/hooks/'));
|
|
198
|
+
if (cfg.hooks.length !== before) {
|
|
199
|
+
await atomicWrite(p, JSON.stringify(cfg, null, 2));
|
|
200
|
+
steps.push(`✓ hooks 已从 ${p} 移除`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const mcpP = join(home, 'config.toml');
|
|
204
|
+
try {
|
|
205
|
+
const text = await fs.readFile(mcpP, 'utf8');
|
|
206
|
+
if (text.includes('[mcp_servers.abs]')) {
|
|
207
|
+
const re = /\n?\[mcp_servers\.abs\][^\[]*/s;
|
|
208
|
+
await atomicWrite(mcpP, text.replace(re, '\n'));
|
|
209
|
+
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
await fs.rm(hostSkillDir('codex'), { recursive: true, force: true });
|
|
213
|
+
steps.push(`✓ skill 已删除`);
|
|
214
|
+
return steps;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ============================ Opencode / Pi (TS 插件) ============================
|
|
218
|
+
function opencodePluginSource() {
|
|
219
|
+
// Opencode 官方插件 API: export const MyPlugin: Plugin = async ({ project }) => ({ event: async ({ name }) => {...} })
|
|
220
|
+
return `/**
|
|
221
|
+
* abs (agent-brain-sync) — Opencode plugin。
|
|
222
|
+
* 纯触发: 生命周期事件 → 技术日志一行 (~/.abs/log/hooks.log, ABS_LOG_DIR 可覆盖)。fire-and-forget。
|
|
223
|
+
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
224
|
+
* Opencode 事件: session.start / session.end (对应 host hook 的 SessionStart/SessionEnd)。
|
|
225
|
+
*/
|
|
226
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
227
|
+
import { homedir } from "node:os"
|
|
228
|
+
import { join } from "node:path"
|
|
229
|
+
|
|
230
|
+
export const AbsPlugin = async () => ({
|
|
231
|
+
event: async ({ name }) => {
|
|
232
|
+
if (!["session.start", "session.end"].includes(name)) return
|
|
233
|
+
try {
|
|
234
|
+
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
235
|
+
const d = new Date()
|
|
236
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
237
|
+
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
238
|
+
const line = "[" + stamp + "] opencode:" + name + "\\n"
|
|
239
|
+
await mkdir(dir, { recursive: true })
|
|
240
|
+
await appendFile(join(dir, "hooks.log"), line)
|
|
241
|
+
} catch {} // fire-and-forget: 永不阻塞宿主
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
${MARK}
|
|
245
|
+
`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ============================ Pi (TS extension) ============================
|
|
249
|
+
// Pi 与 Opencode 插件语法不同构:Pi 需要 default 工厂函数接收 ExtensionAPI、用 pi.on(event) 注册。
|
|
250
|
+
// 不能复用 opencodePluginSource 的写法(那是 export const ... = ({ project }) => ...)。
|
|
251
|
+
// 两者同纪律: 生命周期事件只进技术日志 hooks.log, 不进图谱 log.md。
|
|
252
|
+
function piPluginSource() {
|
|
253
|
+
return `/**
|
|
254
|
+
* abs (agent-brain-sync) — Pi extension。
|
|
255
|
+
* 纯触发: 会话生命周期事件 → 技术日志一行 (~/.abs/log/hooks.log, ABS_LOG_DIR 可覆盖)。fire-and-forget。
|
|
256
|
+
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
257
|
+
* Pi 事件: session_start / session_shutdown (对应 host hook 的 SessionStart/SessionEnd)。
|
|
258
|
+
* session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
|
|
259
|
+
*/
|
|
260
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
261
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
262
|
+
import { homedir } from "node:os"
|
|
263
|
+
import { join } from "node:path"
|
|
264
|
+
import { spawn } from "node:child_process"
|
|
265
|
+
|
|
266
|
+
const ABS_BIN = "${join(ABS_DIR, 'bin', 'abs.js')}"
|
|
267
|
+
|
|
268
|
+
async function logHook(evt: string): Promise<void> {
|
|
269
|
+
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
270
|
+
const d = new Date()
|
|
271
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
272
|
+
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
273
|
+
const line = "[" + stamp + "] pi:" + evt + "\\n"
|
|
274
|
+
await mkdir(dir, { recursive: true })
|
|
275
|
+
await appendFile(join(dir, "hooks.log"), line)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
|
|
279
|
+
// cwd 用事件 ctx.cwd(当前项目),让 abs 从该目录向上定位 .brain/。
|
|
280
|
+
function snapshotWrapup(cwd: string): void {
|
|
281
|
+
try {
|
|
282
|
+
const child = spawn(process.execPath, [ABS_BIN, "wrapup"], {
|
|
283
|
+
cwd: cwd || process.cwd(), stdio: "ignore", detached: true,
|
|
284
|
+
})
|
|
285
|
+
child.unref()
|
|
286
|
+
} catch {} // fire-and-forget
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export default function absPiHook(pi: ExtensionAPI): void {
|
|
290
|
+
pi.on("session_start", () => logHook("session_start").catch(() => {}))
|
|
291
|
+
pi.on("session_shutdown", (_e, ctx) => {
|
|
292
|
+
snapshotWrapup((ctx && ctx.cwd) || process.cwd())
|
|
293
|
+
logHook("session_shutdown").catch(() => {})
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
${MARK}
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function installOpenCode({ withMcp, withSkill, log }) {
|
|
301
|
+
const steps = [];
|
|
302
|
+
const dir = join(hostConfigRoot('opencode'), 'plugins');
|
|
303
|
+
const p = join(dir, 'abs.ts');
|
|
304
|
+
await atomicWrite(p, opencodePluginSource());
|
|
305
|
+
steps.push(`✓ hook(ts plugin) → ${p}`);
|
|
306
|
+
if (withMcp) {
|
|
307
|
+
const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
|
|
308
|
+
const cfg = await readJson(mcpP);
|
|
309
|
+
cfg.mcp = cfg.mcp || {};
|
|
310
|
+
cfg.mcp['abs'] = {
|
|
311
|
+
type: 'local',
|
|
312
|
+
command: [process.execPath, join(ABS_DIR, 'bin', 'mcp.js')],
|
|
313
|
+
};
|
|
314
|
+
await backup(mcpP);
|
|
315
|
+
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
316
|
+
steps.push(`✓ MCP → ${mcpP} (mcp.abs local)`);
|
|
317
|
+
}
|
|
318
|
+
if (withSkill) {
|
|
319
|
+
const target = join(hostSkillDir('opencode'), 'SKILL.md');
|
|
320
|
+
await atomicWrite(target, await fs.readFile(SKILL_SOURCE, 'utf8'));
|
|
321
|
+
steps.push(`✓ skill → ${target}`);
|
|
322
|
+
}
|
|
323
|
+
return steps;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function uninstallOpenCode() {
|
|
327
|
+
const steps = [];
|
|
328
|
+
const plugin = join(hostConfigRoot('opencode'), 'plugins', 'abs.ts');
|
|
329
|
+
await fs.rm(plugin, { force: true });
|
|
330
|
+
steps.push(`✓ plugin 已删除`);
|
|
331
|
+
const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
|
|
332
|
+
const cfg = await readJson(mcpP);
|
|
333
|
+
if (cfg.mcp && cfg.mcp.abs) {
|
|
334
|
+
delete cfg.mcp.abs;
|
|
335
|
+
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
336
|
+
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
337
|
+
}
|
|
338
|
+
await fs.rm(hostSkillDir('opencode'), { recursive: true, force: true });
|
|
339
|
+
steps.push(`✓ skill 已删除`);
|
|
340
|
+
return steps;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function installPi({ withMcp, withSkill, log }) {
|
|
344
|
+
const steps = [];
|
|
345
|
+
const dir = join(hostConfigRoot('pi'), 'agent', 'extensions');
|
|
346
|
+
const p = join(dir, 'abs.ts');
|
|
347
|
+
await atomicWrite(p, piPluginSource()); // Pi 用专用模板, 语法与 OpenCode 不同构
|
|
348
|
+
steps.push(`✓ hook(ts extension) → ${p}`);
|
|
349
|
+
if (withMcp) {
|
|
350
|
+
steps.push(`• MCP → Pi 走 extension 内桥接(见 ${p}), 未单独注册`);
|
|
351
|
+
}
|
|
352
|
+
if (withSkill) {
|
|
353
|
+
const target = join(hostSkillDir('pi'), 'SKILL.md');
|
|
354
|
+
await atomicWrite(target, await fs.readFile(SKILL_SOURCE, 'utf8'));
|
|
355
|
+
steps.push(`✓ skill → ${target}`);
|
|
356
|
+
}
|
|
357
|
+
return steps;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function uninstallPi() {
|
|
361
|
+
const steps = [];
|
|
362
|
+
await fs.rm(join(hostConfigRoot('pi'), 'agent', 'extensions', 'abs.ts'), { force: true });
|
|
363
|
+
steps.push(`✓ extension 已删除`);
|
|
364
|
+
await fs.rm(hostSkillDir('pi'), { recursive: true, force: true });
|
|
365
|
+
steps.push(`✓ skill 已删除`);
|
|
366
|
+
return steps;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ============================ 向导入口 ============================
|
|
370
|
+
const INSTALLERS = {
|
|
371
|
+
'claude-code': { on: installClaudeCode, off: uninstallClaudeCode },
|
|
372
|
+
codex: { on: installCodex, off: uninstallCodex },
|
|
373
|
+
opencode: { on: installOpenCode, off: uninstallOpenCode },
|
|
374
|
+
pi: { on: installPi, off: uninstallPi },
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
export function installSummary() {
|
|
378
|
+
return HOSTS.map((h) => ` ${h.key.padEnd(12)} ${h.label}`).join('\n');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function runInstall({ agent, mcp = true, skill = true, yes = false } = {}) {
|
|
382
|
+
const targets = agent ? [agent] : await pickAgents();
|
|
383
|
+
for (const key of targets) {
|
|
384
|
+
const inst = INSTALLERS[key];
|
|
385
|
+
if (!inst) throw new Error(`未知 agent: ${key} (可用: ${Object.keys(INSTALLERS).join(', ')})`);
|
|
386
|
+
console.log(`\n▸ 安装到 ${key} …`);
|
|
387
|
+
for (const line of await inst.on({ withMcp: mcp, withSkill: skill })) {
|
|
388
|
+
console.log(' ' + line);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function runUninstall({ agent, yes = false } = {}) {
|
|
395
|
+
const targets = agent ? [agent] : Object.keys(INSTALLERS);
|
|
396
|
+
for (const key of targets) {
|
|
397
|
+
const inst = INSTALLERS[key];
|
|
398
|
+
if (!inst) throw new Error(`未知 agent: ${key}`);
|
|
399
|
+
console.log(`\n▸ 从 ${key} 卸载 …`);
|
|
400
|
+
for (const line of await inst.off()) console.log(' ' + line);
|
|
401
|
+
}
|
|
402
|
+
console.log('\n卸载完成。');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 交互式多选(无 TTY 时回退为全部)
|
|
406
|
+
async function pickAgents() {
|
|
407
|
+
if (!process.stdin.isTTY) return Object.keys(INSTALLERS);
|
|
408
|
+
const readline = await import('node:readline/promises');
|
|
409
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
410
|
+
console.log('选择要安装的智能体 (逗号分隔, 回车=全部):');
|
|
411
|
+
console.log(installSummary());
|
|
412
|
+
const ans = (await rl.question('> ')).trim();
|
|
413
|
+
rl.close();
|
|
414
|
+
if (!ans) return Object.keys(INSTALLERS);
|
|
415
|
+
return ans.split(',').map((s) => s.trim()).filter(Boolean);
|
|
416
|
+
}
|
package/src/lock.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/lock.js — .brain markdown 并发写保护。
|
|
2
|
+
// 所有读-改-全写回 .md 的操作都经此串行化,避免跨进程(CLI/MCP/hook)丢失更新。
|
|
3
|
+
// 原语:同目录 <file>.lock 用 open('wx') 原子抢占;拿不到则带过期让步重试。
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import { join, dirname, basename } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/** 哨兵:mutator 返回它表示「无需写盘」;editFile 原样把它回传给调用方。 */
|
|
8
|
+
export const SKIP = Symbol('editFile.skip');
|
|
9
|
+
|
|
10
|
+
export class LockTimeout extends Error {}
|
|
11
|
+
const LOCK_WAIT_BASE_MS = 15; // 指数退避起始重试间隔
|
|
12
|
+
const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
|
|
13
|
+
// 抢锁总预算:排队等锁的进程须依次排完。多进程高并发(CLI/MCP/hook 同刻抢一文件)下,
|
|
14
|
+
// 3s 会让后到进程在排队中途 LockTimeout 崩溃丢写入。设 30s 容纳大批排队者正常排完。
|
|
15
|
+
const LOCK_MAX_WAIT_MS = 30 * 1000;
|
|
16
|
+
// 超过此年龄视为残留锁(持锁进程崩溃没释放),允许摘除。须 > LOCK_MAX_WAIT_MS,
|
|
17
|
+
// 否则持锁进程在排队预算内会被误当残留摘除导致临界区重叠。
|
|
18
|
+
const STALE_MS = 60 * 1000;
|
|
19
|
+
|
|
20
|
+
async function acquireLock(lockPath) {
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
let backoff = LOCK_WAIT_BASE_MS;
|
|
23
|
+
for (;;) {
|
|
24
|
+
let handle = null;
|
|
25
|
+
try {
|
|
26
|
+
handle = await fs.open(lockPath, 'wx'); // 原子创建(O_EXCL);已存在则抛 EEXIST → 排队等
|
|
27
|
+
await handle.close(); // 锁的存在即持有信号;无需保持 fd
|
|
28
|
+
return;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (handle) await handle.close().catch(() => {});
|
|
31
|
+
if (e.code !== 'EEXIST') throw e;
|
|
32
|
+
// 残留锁检测:锁太老(持锁进程崩溃没释放)则摘除重试;正常排队者持锁时间远小于阈值
|
|
33
|
+
try {
|
|
34
|
+
const st = await fs.stat(lockPath);
|
|
35
|
+
if (Date.now() - st.mtimeMs > STALE_MS) {
|
|
36
|
+
await fs.rm(lockPath, { force: true });
|
|
37
|
+
backoff = LOCK_WAIT_BASE_MS; // 摘除残留后重置退避
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
} catch { /* stat 失败(锁刚被释放) → 下一轮重试 */ }
|
|
41
|
+
if (Date.now() - start > LOCK_MAX_WAIT_MS) {
|
|
42
|
+
throw new LockTimeout(`写锁排队超时(等 ${Date.now() - start}ms > 预算 ${LOCK_MAX_WAIT_MS}ms): ${lockPath} 仍被占用`);
|
|
43
|
+
}
|
|
44
|
+
// 指数退避等待:让排队者按先后逐步拿到锁(依次排队)。固定 300ms 粗间隔会让
|
|
45
|
+
// 高并发下后到进程累积等待过久、在预算内排不完而饿死。
|
|
46
|
+
await new Promise((r) => setTimeout(r, backoff + Math.floor(Math.random() * backoff)));
|
|
47
|
+
backoff = Math.min(backoff * 2, LOCK_WAIT_MAX_MS);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function releaseLock(lockPath) {
|
|
53
|
+
await fs.rm(lockPath, { force: true }).catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 串行读-改-写一个文件:锁内 readFile → mutator(currentText) → 写盘。
|
|
57
|
+
* 文件不存在时 currentText 传 null。mutator 返回三种形态:
|
|
58
|
+
* - { text: <新内容> } → 若非空且不同于当前则整体覆盖写盘(常用)
|
|
59
|
+
* - { text: <新内容>, ...meta } → 同上,把 meta 透传给调用方(结果回传用)
|
|
60
|
+
* - SKIP → 不写盘(幂等命中/未找到目标),返回 SKIP
|
|
61
|
+
* 返回:写盘后=mutator 返回值;SKIP 时原样返回 SKIP。抛错则锁内不落盘、锁释放、上抛。 */
|
|
62
|
+
export async function editFile(file, mutator, { maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
63
|
+
const lockPath = join(dirname(file), `.${basename(file)}.lock`);
|
|
64
|
+
await acquireLock(lockPath);
|
|
65
|
+
try {
|
|
66
|
+
let current = null;
|
|
67
|
+
try { current = await fs.readFile(file, 'utf8'); } catch { /* 尚无文件 */ }
|
|
68
|
+
const res = await mutator(current);
|
|
69
|
+
if (res === SKIP) return SKIP;
|
|
70
|
+
const write = typeof res === 'string' ? res : res && typeof res.text === 'string' ? res.text : null;
|
|
71
|
+
if (write && write !== current) {
|
|
72
|
+
await fs.writeFile(file, write, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
return res;
|
|
75
|
+
} finally {
|
|
76
|
+
await releaseLock(lockPath);
|
|
77
|
+
}
|
|
78
|
+
}
|