@bolloon/bolloon-agent 0.1.40 → 0.1.42

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.
Files changed (38) hide show
  1. package/.comm/README.md +21 -0
  2. package/.comm/default/2026-06-17T08-23-00-017Z-8a735de8.md +7 -0
  3. package/CLAUDE.md +3 -0
  4. package/dist/agents/intent-classifier.js +99 -0
  5. package/dist/agents/pi-sdk.js +1364 -58
  6. package/dist/agents/pre-tool-validator.js +2 -1
  7. package/dist/agents/shell-guard.js +9 -3
  8. package/dist/agents/shell-tool.js +28 -13
  9. package/dist/agents/task-state.js +224 -0
  10. package/dist/agents/workflow-pivot-loop.js +30 -0
  11. package/dist/bollharness-integration/gate-state-machine.js +13 -0
  12. package/dist/bollharness-integration/integration.js +71 -0
  13. package/dist/bollharness-integration/skill-adapter.js +52 -127
  14. package/dist/bootstrap/context-hierarchy.js +6 -4
  15. package/dist/cli/interface.js +28 -0
  16. package/dist/documents/reader.js +14 -0
  17. package/dist/git-transport/chat-render.js +155 -0
  18. package/dist/git-transport/chat-repo.js +370 -0
  19. package/dist/git-transport/chat-types.js +23 -0
  20. package/dist/git-transport/chat-watch.js +102 -0
  21. package/dist/index.js +471 -25
  22. package/dist/llm/pi-ai.js +103 -27
  23. package/dist/network/local-inbox-bus.js +73 -0
  24. package/dist/network/p2p-direct.js +3 -4
  25. package/dist/pi-ecosystem-judgment/adaptive-scan.js +6 -2
  26. package/dist/pi-ecosystem-judgment/causal-judge.js +3 -3
  27. package/dist/pi-ecosystem-judgment/index.js +1 -1
  28. package/dist/security/tool-gate.js +11 -0
  29. package/dist/web/client.js +2876 -3770
  30. package/dist/web/components/p2p/index.js +226 -264
  31. package/dist/web/server.js +83 -15
  32. package/dist/web/ui/message-renderer.js +326 -442
  33. package/dist/web/ui/step-timeline.js +255 -351
  34. package/package.json +2 -2
  35. package/scripts/lefthook-helper.sh +69 -0
  36. package/dist/web/components/p2p/P2PModal.js +0 -188
  37. package/dist/web/components/p2p/p2p-modal.js +0 -657
  38. package/dist/web/components/p2p/p2p-tools.js +0 -248
@@ -0,0 +1,370 @@
1
+ // src/git-transport/chat-repo.ts
2
+ // 负责 .comm/ 目录初始化、消息写盘、commit/pull/push、状态持久化.
3
+ // 全部用 child_process.spawn('git', ...) — 不引 simple-git / nodegit.
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as crypto from 'crypto';
7
+ import { spawn } from 'child_process';
8
+ import { chatPaths, CHAT_PROTOCOL_VERSION, CHAT_BODY_MAX_BYTES, COMMIT_MESSAGE_MAX, CHAT_P2P_NOTIFY_THRESHOLD, } from './chat-types.js';
9
+ import { parseMessageFile, dedupeKey, listMessageFiles } from './chat-render.js';
10
+ // ---------- helpers ----------
11
+ function runGit(args, opts) {
12
+ return new Promise((resolve) => {
13
+ const child = spawn('git', args, { cwd: opts.cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
14
+ let stdout = '';
15
+ let stderr = '';
16
+ const timer = setTimeout(() => {
17
+ try {
18
+ child.kill('SIGKILL');
19
+ }
20
+ catch { }
21
+ resolve({ stdout, stderr: stderr + '\n[killed: timeout]', code: 124 });
22
+ }, opts.timeoutMs ?? 30_000);
23
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
24
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
25
+ child.on('close', (code) => {
26
+ clearTimeout(timer);
27
+ resolve({ stdout, stderr, code: code ?? 1 });
28
+ });
29
+ child.on('error', (err) => {
30
+ clearTimeout(timer);
31
+ resolve({ stdout, stderr: stderr + '\n' + err.message, code: 127 });
32
+ });
33
+ });
34
+ }
35
+ async function mkdirp(p) {
36
+ await fs.promises.mkdir(p, { recursive: true });
37
+ }
38
+ // 原子获取 lock — 用 mkdir 互斥, 失败重试最多 30s
39
+ async function acquireLock(lockPath, timeoutMs = 30_000) {
40
+ const start = Date.now();
41
+ while (true) {
42
+ try {
43
+ await fs.promises.mkdir(lockPath);
44
+ break;
45
+ }
46
+ catch (e) {
47
+ if (e.code !== 'EEXIST')
48
+ throw e;
49
+ // 锁超过 60s 视为僵尸, 强行清掉
50
+ try {
51
+ const st = await fs.promises.stat(lockPath);
52
+ if (Date.now() - st.mtimeMs > 60_000) {
53
+ await fs.promises.rmdir(lockPath).catch(() => { });
54
+ }
55
+ }
56
+ catch { }
57
+ if (Date.now() - start > timeoutMs) {
58
+ throw new Error(`acquire git lock timeout: ${lockPath}`);
59
+ }
60
+ await new Promise((r) => setTimeout(r, 200));
61
+ }
62
+ }
63
+ return async () => {
64
+ try {
65
+ await fs.promises.rmdir(lockPath);
66
+ }
67
+ catch { }
68
+ };
69
+ }
70
+ function readJsonSafe(p, fallback) {
71
+ try {
72
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
73
+ }
74
+ catch {
75
+ return fallback;
76
+ }
77
+ }
78
+ async function writeJsonSafe(p, v) {
79
+ await fs.promises.mkdir(path.dirname(p), { recursive: true });
80
+ await fs.promises.writeFile(p, JSON.stringify(v, null, 2) + '\n', 'utf8');
81
+ }
82
+ // ---------- identity ----------
83
+ import { loadOrCreateKeyPair } from '../network/p2p-secret.js';
84
+ export async function resolveIdentity(roleOverride) {
85
+ const role = roleOverride || process.env.BOLLOON_ROLE || process.env.IROH_ROLE || 'default';
86
+ // loadOrCreateKeyPair 读 ~/.bolloon/p2p-direct-secret-{role}.json
87
+ const kp = await loadOrCreateKeyPair(role);
88
+ // 关键: 让 git author 反映 Ed25519 公钥, 这样 git log 一眼能认出"谁写的"
89
+ return {
90
+ role,
91
+ publicKey: kp.publicKey,
92
+ name: role,
93
+ email: `${kp.publicKey.slice(0, 12)}@bolloon.local`,
94
+ };
95
+ }
96
+ export async function chatInit(repoDir, opts = {}) {
97
+ const id = await resolveIdentity(opts.roleOverride);
98
+ const paths = chatPaths(repoDir);
99
+ await mkdirp(path.join(paths.root, id.role));
100
+ await mkdirp(paths.stateDir);
101
+ await mkdirp(paths.inboxDir);
102
+ // README.md
103
+ const readme = [
104
+ '# .comm/ — 跨机聊天收件箱',
105
+ '',
106
+ 'Bolloon chat transport: commits-as-messages.',
107
+ '每条消息 = 一个 markdown 文件 + 一次 git commit + push.',
108
+ '',
109
+ '## 目录约定',
110
+ '',
111
+ '- `<role>/` — 每个 role 一个子目录, 里面是该角色发的所有消息',
112
+ '- `_state/` — 本地运行态 (cursor, seen, lock), 不 commit',
113
+ '- `_inbox/` — 看门狗把对方消息反写到本地, 不 commit',
114
+ '',
115
+ '## 子命令',
116
+ '',
117
+ '```',
118
+ 'bolloon --chat-init',
119
+ 'bolloon --chat-send "..."',
120
+ 'bolloon --chat-pull',
121
+ 'bolloon --chat-list',
122
+ 'bolloon --chat-watch',
123
+ 'bolloon --chat-status',
124
+ '```',
125
+ '',
126
+ ].join('\n');
127
+ await fs.promises.writeFile(paths.readmeFile, readme, 'utf8');
128
+ // REMOTE 文件
129
+ if (opts.remote) {
130
+ await fs.promises.writeFile(paths.remoteFile, opts.remote.trim() + '\n', 'utf8');
131
+ }
132
+ // 探测 remote / branch
133
+ const remote = opts.remote || (await runGit(['remote', 'get-url', 'origin'], { cwd: repoDir })).stdout.trim() || undefined;
134
+ const branch = opts.branch || (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir })).stdout.trim() || 'master';
135
+ // 配 git user.name/user.email
136
+ await runGit(['config', 'user.name', id.name], { cwd: repoDir });
137
+ await runGit(['config', 'user.email', id.email], { cwd: repoDir });
138
+ // 首次 rebase
139
+ const pull = await runGit(['pull', '--rebase', '--autostash'], { cwd: repoDir });
140
+ // 把 README + REMOTE 第一次 commit 上去 (若有变更)
141
+ await runGit(['add', '.comm/README.md'], { cwd: repoDir });
142
+ if (fs.existsSync(paths.remoteFile)) {
143
+ await runGit(['add', '.comm/REMOTE'], { cwd: repoDir });
144
+ }
145
+ const diff = await runGit(['diff', '--cached', '--name-only'], { cwd: repoDir });
146
+ const messages = [];
147
+ if (diff.stdout.trim()) {
148
+ const msg = `[chat-init] role=${id.role} pk=${id.publicKey.slice(0, 12)}`;
149
+ const commit = await runGit(['commit', '-m', msg], { cwd: repoDir });
150
+ if (commit.code === 0) {
151
+ messages.push(`created initial commit: ${msg}`);
152
+ if (remote) {
153
+ const push = await runGit(['push', remote, branch], { cwd: repoDir });
154
+ if (push.code === 0)
155
+ messages.push(`pushed to ${remote}/${branch}`);
156
+ else
157
+ messages.push(`push failed (will retry on next chat-send): ${push.stderr.trim().split('\n').slice(-1)[0]}`);
158
+ }
159
+ else {
160
+ messages.push('no remote configured, will only commit locally');
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ messages.push('.comm/ already initialized, no changes');
166
+ }
167
+ return {
168
+ ok: true,
169
+ role: id.role,
170
+ publicKey: id.publicKey,
171
+ remote,
172
+ branch,
173
+ messages: [
174
+ `role: ${id.role}`,
175
+ `publicKey: ${id.publicKey.slice(0, 16)}...`,
176
+ ...(remote ? [`remote: ${remote}`] : []),
177
+ ...(branch ? [`branch: ${branch}`] : []),
178
+ ...messages,
179
+ ...(pull.code !== 0 ? [`pull warning: ${pull.stderr.trim().split('\n').slice(-1)[0] || 'unknown'}`] : []),
180
+ ],
181
+ };
182
+ }
183
+ function sanitizeRoleName(role) {
184
+ // 文件名只允许 [A-Za-z0-9._-]
185
+ return role.replace(/[^A-Za-z0-9._-]/g, '_');
186
+ }
187
+ function buildMessageFile(frontmatter, body) {
188
+ const lines = ['---'];
189
+ lines.push(`v: ${frontmatter.v}`);
190
+ lines.push(`from: ${frontmatter.from}`);
191
+ lines.push(`fromPk: ${frontmatter.fromPk}`);
192
+ if (frontmatter.to)
193
+ lines.push(`to: ${frontmatter.to}`);
194
+ lines.push(`ts: ${frontmatter.ts}`);
195
+ if (frontmatter.p2pRef)
196
+ lines.push(`p2pRef: ${frontmatter.p2pRef}`);
197
+ if (frontmatter.git?.sha) {
198
+ lines.push('git:');
199
+ if (frontmatter.git.branch)
200
+ lines.push(` branch: ${frontmatter.git.branch}`);
201
+ if (frontmatter.git.sha)
202
+ lines.push(` sha: ${frontmatter.git.sha}`);
203
+ }
204
+ lines.push('---');
205
+ lines.push('');
206
+ return lines.join('\n') + body.trimEnd() + '\n';
207
+ }
208
+ export async function chatSend(opts) {
209
+ const { repoDir, body } = opts;
210
+ if (!body || !body.trim())
211
+ return { ok: false, reason: 'empty body', p2pNotifyEligible: false, filePath: '' };
212
+ if (Buffer.byteLength(body, 'utf8') > CHAT_BODY_MAX_BYTES) {
213
+ return { ok: false, reason: `body too large (${Buffer.byteLength(body, 'utf8')} > ${CHAT_BODY_MAX_BYTES})`, p2pNotifyEligible: false, filePath: '' };
214
+ }
215
+ const id = await resolveIdentity(opts.roleOverride);
216
+ const paths = chatPaths(repoDir);
217
+ await mkdirp(path.join(paths.root, id.role));
218
+ await mkdirp(paths.stateDir);
219
+ const ts = new Date().toISOString();
220
+ const shortHash = crypto.createHash('sha256').update(`${id.publicKey}|${ts}|${body}`).digest('hex').slice(0, 8);
221
+ const safeTs = ts.replace(/[:.]/g, '-');
222
+ const filename = `${safeTs}-${shortHash}.md`;
223
+ const relPath = path.join('.comm', sanitizeRoleName(id.role), filename);
224
+ const fm = {
225
+ v: CHAT_PROTOCOL_VERSION,
226
+ from: id.role,
227
+ fromPk: id.publicKey,
228
+ to: opts.to,
229
+ ts,
230
+ };
231
+ const content = buildMessageFile(fm, body);
232
+ const release = await acquireLock(paths.lockFile);
233
+ try {
234
+ // 1) 写盘
235
+ await fs.promises.writeFile(path.join(repoDir, relPath), content, 'utf8');
236
+ // 2) git add (限定 .comm/<self>/, 避免把别人未 push 的修改带进来)
237
+ await runGit(['add', '--', relPath], { cwd: repoDir });
238
+ // 3) commit
239
+ const commitMsg = `chat(${id.role}): ${body.split('\n')[0].slice(0, 60)}`.slice(0, COMMIT_MESSAGE_MAX);
240
+ const commit = await runGit(['commit', '-m', commitMsg], { cwd: repoDir });
241
+ if (commit.code !== 0) {
242
+ return { ok: false, reason: `git commit failed: ${commit.stderr.trim()}`, p2pNotifyEligible: false, filePath: relPath };
243
+ }
244
+ const shaLine = (await runGit(['rev-parse', 'HEAD'], { cwd: repoDir })).stdout.trim();
245
+ // 4) rebase + push (失败不回滚 commit, 让本地 commit 留下, 下次 send 重试 push)
246
+ await runGit(['pull', '--rebase', '--autostash'], { cwd: repoDir });
247
+ let pushed = false;
248
+ const push = await runGit(['push'], { cwd: repoDir });
249
+ if (push.code === 0)
250
+ pushed = true;
251
+ // 5) 自己的 cursor 推进
252
+ await writeJsonSafe(paths.cursorFile, { lastHead: shaLine, lastSelfTs: ts, role: id.role });
253
+ return {
254
+ ok: true,
255
+ sha: shaLine,
256
+ pushed,
257
+ p2pNotifyEligible: Buffer.byteLength(body, 'utf8') <= CHAT_P2P_NOTIFY_THRESHOLD,
258
+ filePath: relPath,
259
+ };
260
+ }
261
+ finally {
262
+ await release();
263
+ }
264
+ }
265
+ export async function chatPull(opts) {
266
+ const { repoDir } = opts;
267
+ const paths = chatPaths(repoDir);
268
+ await mkdirp(paths.stateDir);
269
+ const fetch = await runGit(['fetch', '--all', '--prune'], { cwd: repoDir });
270
+ if (fetch.code !== 0) {
271
+ return { ok: false, newCommits: 0, newMessages: [], reason: `git fetch failed: ${fetch.stderr.trim().split('\n').slice(-1)[0]}` };
272
+ }
273
+ const rebase = await runGit(['pull', '--rebase', '--autostash'], { cwd: repoDir });
274
+ // 收集新增消息: 对比 rev-list HEAD~ <self-cursor-sha>..HEAD 的 .comm/* 变更
275
+ const cursor = readJsonSafe(paths.cursorFile, {});
276
+ let range = 'HEAD';
277
+ if (cursor.lastHead) {
278
+ range = `${cursor.lastHead}..HEAD`;
279
+ }
280
+ const rev = await runGit(['rev-list', range, '--', '.comm'], { cwd: repoDir });
281
+ if (rev.code !== 0) {
282
+ return { ok: false, newCommits: 0, newMessages: [], reason: `rev-list failed: ${rev.stderr.trim()}` };
283
+ }
284
+ const shas = rev.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
285
+ const newMessages = [];
286
+ const seen = readJsonSafe(paths.seenFile, {});
287
+ const id = await resolveIdentity(opts.roleOverride);
288
+ for (const sha of shas) {
289
+ const show = await runGit(['show', '--name-only', '--pretty=format:', sha, '--', '.comm'], { cwd: repoDir });
290
+ const fileLines = show.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
291
+ for (const f of fileLines) {
292
+ if (!f.endsWith('.md'))
293
+ continue;
294
+ if (f.includes(`${path.sep}_state${path.sep}`) || f.includes(`${path.sep}_inbox${path.sep}`))
295
+ continue;
296
+ const abs = path.isAbsolute(f) ? f : path.join(repoDir, f);
297
+ const msg = parseMessageFile(abs, sha);
298
+ if (!msg)
299
+ continue;
300
+ // 不是自己发的: 收
301
+ if (msg.frontmatter.fromPk !== id.publicKey) {
302
+ const key = dedupeKey(msg);
303
+ if (!seen[key]) {
304
+ newMessages.push(msg);
305
+ seen[key] = Date.now();
306
+ }
307
+ // 关键: 每个对方消息都顺手 addOrUpdatePeer (幂等), 让 web 模式 5s 后自动 joinPeer 直连
308
+ // 即使消息之前见过, 也值得刷一次"lastConnectedAt"语义
309
+ try {
310
+ const { addOrUpdatePeer } = await import('../network/known-peers.js');
311
+ const name = msg.frontmatter.from || `peer-${msg.frontmatter.fromPk.slice(0, 8)}`;
312
+ addOrUpdatePeer(name, msg.frontmatter.fromPk, `auto from chat ${sha.slice(0, 8)}`);
313
+ }
314
+ catch {
315
+ // known-peers import / 写盘失败 — 不影响消息收集
316
+ }
317
+ }
318
+ }
319
+ }
320
+ await writeJsonSafe(paths.seenFile, seen);
321
+ // 推进 cursor
322
+ const head = (await runGit(['rev-parse', 'HEAD'], { cwd: repoDir })).stdout.trim();
323
+ await writeJsonSafe(paths.cursorFile, { ...cursor, lastHead: head });
324
+ return { ok: true, newCommits: shas.length, newMessages };
325
+ }
326
+ export async function chatStatus(opts) {
327
+ const { repoDir } = opts;
328
+ const id = await resolveIdentity(opts.roleOverride);
329
+ const remote = (await runGit(['remote', 'get-url', 'origin'], { cwd: repoDir })).stdout.trim() || undefined;
330
+ const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir })).stdout.trim() || undefined;
331
+ const head = (await runGit(['rev-parse', 'HEAD'], { cwd: repoDir })).stdout.trim() || undefined;
332
+ let ahead;
333
+ let behind;
334
+ if (remote && branch) {
335
+ const rev = await runGit(['rev-list', '--left-right', '--count', `${branch}...${remote}/${branch}`], { cwd: repoDir });
336
+ if (rev.code === 0) {
337
+ const [a, b] = rev.stdout.trim().split(/\s+/).map((n) => parseInt(n, 10));
338
+ ahead = a;
339
+ behind = b;
340
+ }
341
+ }
342
+ const files = listMessageFilesLocal(repoDir);
343
+ const byRole = {};
344
+ for (const f of files) {
345
+ const m = /[\\/].comm[\\/]([^\\/]+)[\\/]/.exec(f);
346
+ const role = m ? m[1] : '?';
347
+ byRole[role] = (byRole[role] || 0) + 1;
348
+ }
349
+ let mode = 'synced';
350
+ if (!remote)
351
+ mode = 'no-remote';
352
+ else if (ahead && ahead > 0)
353
+ mode = 'local-only';
354
+ return {
355
+ role: id.role,
356
+ publicKey: id.publicKey,
357
+ repoDir,
358
+ remote,
359
+ branch,
360
+ head: head ? head.slice(0, 12) : undefined,
361
+ ahead,
362
+ behind,
363
+ mode,
364
+ fileCount: files.length,
365
+ byRole,
366
+ };
367
+ }
368
+ function listMessageFilesLocal(repoDir) {
369
+ return listMessageFiles(repoDir);
370
+ }
@@ -0,0 +1,23 @@
1
+ // src/git-transport/chat-types.ts
2
+ // 共享类型与常量 — 不依赖任何运行时副作用,可在 import 链路最底层使用.
3
+ import * as path from 'path';
4
+ export const CHAT_PROTOCOL_VERSION = 1;
5
+ export const CHAT_BODY_MAX_BYTES = 256 * 1024; // 256 KiB 单条上限
6
+ export const COMMIT_MESSAGE_MAX = 8192; // git commit message 自身 8 KiB 上限
7
+ export const CHAT_P2P_NOTIFY_THRESHOLD = 4 * 1024; // > 4 KiB 只走 git, 不发 P2P notify
8
+ export const CHAT_DEFAULT_PULL_INTERVAL_MS = 15_000;
9
+ export const CHAT_PULL_BACKOFF_MAX_MS = 5 * 60_000;
10
+ export function chatPaths(repoDir) {
11
+ const root = path.join(repoDir, '.comm');
12
+ return {
13
+ root,
14
+ stateDir: path.join(root, '_state'),
15
+ inboxDir: path.join(root, '_inbox'),
16
+ cursorFile: path.join(root, '_state', 'cursor'),
17
+ p2pStatusFile: path.join(root, '_state', 'p2p-status.json'),
18
+ seenFile: path.join(root, '_state', 'seen.json'),
19
+ lockFile: path.join(root, '_state', 'git.lock'),
20
+ remoteFile: path.join(root, 'REMOTE'),
21
+ readmeFile: path.join(root, 'README.md'),
22
+ };
23
+ }
@@ -0,0 +1,102 @@
1
+ // src/git-transport/chat-watch.ts
2
+ // 后台长循环: 定时 fetch + rebase + 解析新消息 + 打印到 stdout.
3
+ // 走 SIGHUP / SIGINT 优雅退出.
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import { chatPull, chatStatus, resolveIdentity } from './chat-repo.js';
7
+ import { renderOneLine, dedupeKey } from './chat-render.js';
8
+ import { chatPaths, CHAT_DEFAULT_PULL_INTERVAL_MS, CHAT_PULL_BACKOFF_MAX_MS } from './chat-types.js';
9
+ export async function chatWatch(opts) {
10
+ const repoDir = opts.repoDir;
11
+ const interval = opts.intervalMs ?? CHAT_DEFAULT_PULL_INTERVAL_MS;
12
+ const paths = chatPaths(repoDir);
13
+ await fs.promises.mkdir(paths.stateDir, { recursive: true });
14
+ // 启动时主动拉一次
15
+ let backoff = interval;
16
+ const seen = readJsonSafe(paths.seenFile, {});
17
+ const id = await resolveIdentity();
18
+ let stopped = false;
19
+ const onAbort = () => { stopped = true; };
20
+ if (opts.signal)
21
+ opts.signal.addEventListener('abort', onAbort);
22
+ const cleanup = () => {
23
+ if (opts.signal)
24
+ opts.signal.removeEventListener('abort', onAbort);
25
+ };
26
+ process.on('SIGHUP', () => { stopped = true; });
27
+ process.on('SIGINT', () => { stopped = true; });
28
+ process.on('SIGTERM', () => { stopped = true; });
29
+ // 启动 banner
30
+ banner(id, repoDir, interval);
31
+ while (!stopped) {
32
+ try {
33
+ const r = await chatPull({ repoDir });
34
+ if (!r.ok) {
35
+ const m = `[chat-watch] pull failed: ${r.reason ?? 'unknown'}`;
36
+ opts.onError ? opts.onError(m) : console.error(m);
37
+ backoff = Math.min(backoff * 1.5, CHAT_PULL_BACKOFF_MAX_MS);
38
+ }
39
+ else {
40
+ backoff = interval;
41
+ if (r.newMessages.length > 0) {
42
+ const lines = [];
43
+ for (const msg of r.newMessages) {
44
+ const key = dedupeKey(msg);
45
+ if (seen[key])
46
+ continue;
47
+ seen[key] = Date.now();
48
+ lines.push(renderOneLine(msg));
49
+ }
50
+ if (lines.length > 0) {
51
+ opts.onNew ? opts.onNew(lines) : process.stdout.write(lines.join('\n') + '\n');
52
+ }
53
+ }
54
+ else {
55
+ // 5 分钟自打 ping, 防止 watchdog 30min 静默误杀
56
+ if (shouldSelfPing()) {
57
+ const st = await chatStatus({ repoDir });
58
+ process.stdout.write(`[chat-watch] idle, role=${st.role} head=${st.head ?? '?'}\n`);
59
+ }
60
+ }
61
+ }
62
+ await writeJsonSafe(paths.seenFile, seen);
63
+ }
64
+ catch (e) {
65
+ const m = `[chat-watch] loop error: ${e?.message ?? e}`;
66
+ opts.onError ? opts.onError(m) : console.error(m);
67
+ }
68
+ // 退避 sleep, 但要响应停止信号
69
+ for (let i = 0; i < backoff / 1000 && !stopped; i++) {
70
+ await sleep(1000);
71
+ }
72
+ }
73
+ cleanup();
74
+ process.stdout.write('[chat-watch] stopped\n');
75
+ }
76
+ // helpers
77
+ function readJsonSafe(p, fallback) {
78
+ try {
79
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
80
+ }
81
+ catch {
82
+ return fallback;
83
+ }
84
+ }
85
+ async function writeJsonSafe(p, v) {
86
+ await fs.promises.mkdir(path.dirname(p), { recursive: true });
87
+ await fs.promises.writeFile(p, JSON.stringify(v, null, 2) + '\n', 'utf8');
88
+ }
89
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
90
+ let lastPing = 0;
91
+ function shouldSelfPing() {
92
+ const now = Date.now();
93
+ if (now - lastPing > 5 * 60_000) {
94
+ lastPing = now;
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+ function banner(id, repoDir, interval) {
100
+ process.stdout.write(`[chat-watch] role=${id.role} pk=${id.publicKey.slice(0, 12)} repo=${repoDir} interval=${interval}ms\n`);
101
+ process.stdout.write(`[chat-watch] press Ctrl-C to stop\n`);
102
+ }