bom-pi 0.0.3 → 0.0.5

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.
@@ -137,7 +137,7 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
137
137
  createdAt: updatedAt,
138
138
  updatedAt,
139
139
  createdBy: OWNER.id,
140
- busy: false, // PORT(bom-pi): session.list chưa báo busy — làm mới qua events.update
140
+ busy: c.busy === true, // LiveDoc.run — push 'done' + UI "working" dùng chung nguồn này
141
141
  waiting: appr.some((a) => Number(a.conversationId) === Number(c.id)),
142
142
  model: c.model?.modelId ?? 'glm-5.3',
143
143
  title,
@@ -158,7 +158,7 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
158
158
  conversation: { id: Number(convId), kind: 'session', title: conv?.title ?? `session ${convId}`, cwd: conv?.cwd ?? '', archived: false },
159
159
  entries,
160
160
  order: entries.map((e) => e.id),
161
- live: { busy: conv?.busy === true || conv?.running === true },
161
+ live: { busy: conv?.busy === true },
162
162
  agent: { model: { provider: conv?.model?.provider ?? 'zai', modelId: conv?.model?.modelId ?? 'glm-5.3' }, thinkingLevel: 'high', cwd: conv?.cwd ?? '', available: true, modelName: conv?.model?.modelId ?? 'GLM-5.3', contextWindow: 1000000, reasoning: true, images: false, levels: ['low', 'high', 'max'] },
163
163
  stats: { cost: 0 },
164
164
  clients: 1, viewers: ['Owner'],
@@ -169,6 +169,17 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
169
169
  };
170
170
  }
171
171
 
172
+ // title conversation = message user đầu (cùng logic sessionsPayload) — cho targetTitle webhook
173
+ async function convTitle(convId) {
174
+ try {
175
+ const raw = await rpc('transcript.raw', { conversationId: convId });
176
+ const first = (raw.entries ?? []).find((e) => e.kind === 'pi.user');
177
+ const m0 = first?.model?.[0];
178
+ const text = typeof m0?.content === 'string' ? m0.content : Array.isArray(m0?.content) ? m0.content.filter((p) => p?.type === 'text').map((p) => p.text).join(' ') : '';
179
+ return text ? text.split('\n')[0].slice(0, 60) : `session ${convId}`;
180
+ } catch { return `session ${convId}`; }
181
+ }
182
+
172
183
  const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }); res.end(JSON.stringify(obj)); };
173
184
  const readBody = (req) => new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => { try { done(JSON.parse(b || '{}')); } catch { done({}); } }); });
174
185
 
@@ -334,10 +345,28 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
334
345
  return true;
335
346
  }
336
347
 
348
+ // ---- webhooks (vòng 9 — port pi-pocket): list/add/rotate/remove; dispatch ở route public /hook/<id>/<secret> ----
349
+ if (first === 'hooks') {
350
+ if (second === undefined && method === 'GET') {
351
+ const r = await rpc('hooks.list');
352
+ const out = [];
353
+ for (const h of r.hooks ?? []) out.push({ ...h, targetTitle: await convTitle(h.target) });
354
+ return json(res, 200, out), true;
355
+ }
356
+ if (second === undefined && isPost) {
357
+ const body = await readBody(req);
358
+ return json(res, 200, await rpc('hooks.add', {
359
+ target: body.target, name: body.name, instructions: body.instructions,
360
+ ...(body.signed === true ? { signed: true } : {}),
361
+ })), true;
362
+ }
363
+ if (second !== undefined && third === 'rotate' && isPost) { await readBody(req); return json(res, 200, await rpc('hooks.rotate', { id: second })), true; }
364
+ if (second !== undefined && third === 'remove' && isPost) { await readBody(req); return json(res, 200, await rpc('hooks.remove', { id: second })), true; }
365
+ }
366
+
337
367
  // ---- stub rỗng: tính năng pocket chưa có ở bom-pi ----
338
368
  if (first === 'users' && method === 'GET') return json(res, 200, [OWNER]), true;
339
369
  if (first === 'subagents' && method === 'GET') return json(res, 200, []), true;
340
- if (first === 'hooks' && method === 'GET') return json(res, 200, []), true;
341
370
  if (first === 'folder-favorites' && method === 'GET') return json(res, 200, []), true;
342
371
  if (first === 'pi-sessions' && method === 'GET') return json(res, 200, []), true;
343
372
  if (first === 'fs') return json(res, 200, { entries: [] }), true; // PORT(bom-pi): file tree
@@ -111,7 +111,7 @@ async function verifyDeviceToken(token) {
111
111
  }
112
112
  const authorized = async (req, url) => {
113
113
  if (url.pathname === '/api/pair/start') return true; // public — tự vệ bằng code + rate-limit daemon
114
- if (!url.pathname.startsWith('/api/')) return true; // static UI/PWA — không dữ liệu
114
+ if (!url.pathname.startsWith('/api/') && url.pathname !== '/rpc') return true; // static UI/PWA — không dữ liệu
115
115
  const header = req.headers.authorization ?? '';
116
116
  const bearer = header.startsWith('Bearer ') ? header.slice(7) : null;
117
117
  const qToken = url.searchParams.get('token');
@@ -150,6 +150,7 @@ async function ensureEventsSocket(cid) {
150
150
  if (msg.method === 'events.update') {
151
151
  for (const fn of eventListeners) fn(msg.params);
152
152
  pushApprovalWatch(); // push "needs approval" — kể cả khi tab đóng
153
+ pushDoneWatch(); // push "bom finished" khi conv busy→idle
153
154
  }
154
155
  } catch { /* dòng chưa trọn */ }
155
156
  }
@@ -222,6 +223,63 @@ function pushApprovalWatch() {
222
223
 
223
224
  const pocketRoute = makePocketAdapter({ rpc, checkToken: async (t) => (TOKEN ? t === TOKEN : false) || (await verifyDeviceToken(t)), push: pushStore ? { store: pushStore, userId: PUSH_USER } : null });
224
225
 
226
+ // ---- push 'done' (khuôn alerts.ts: runChanged + DONE_DELAY 3s): conv busy→idle và Ở YÊN
227
+ // 3s (follow-up queued thường chạy ngay) ⇒ "bom finished · <title>". Abort không bắn —
228
+ // người dùng tự dừng, không phải tin; error đổi tiêu đề. ----
229
+ const DONE_DELAY_MS = 3000;
230
+ let busyByConv = null; // null = chưa nạp mốc — không bắn trạng thái cũ lúc web mới lên
231
+ const doneTimers = new Map(); // convId → timeout đợi-yên
232
+ let doneWatchTimer = null;
233
+ function pushDoneWatch() {
234
+ if (!pushStore || doneWatchTimer) return;
235
+ doneWatchTimer = setTimeout(() => {
236
+ doneWatchTimer = null;
237
+ void (async () => {
238
+ try {
239
+ if (pushStore.subscriptions(PUSH_USER).length === 0) { busyByConv = null; return; }
240
+ const convs = (await rpc('session.list')).conversations ?? [];
241
+ const now = new Map(convs.map((c) => [Number(c.id), c.busy === true]));
242
+ if (busyByConv === null) { busyByConv = now; return; }
243
+ for (const [id, busy] of now) {
244
+ const was = busyByConv.get(id);
245
+ if (was === undefined || was === busy) continue;
246
+ clearTimeout(doneTimers.get(id));
247
+ doneTimers.delete(id);
248
+ if (!busy) {
249
+ const t = setTimeout(() => { doneTimers.delete(id); void pushDone(id); }, DONE_DELAY_MS);
250
+ t.unref?.();
251
+ doneTimers.set(id, t);
252
+ }
253
+ }
254
+ busyByConv = now;
255
+ } catch { /* daemon bận — events.update kế tiếp */ }
256
+ })();
257
+ }, 500);
258
+ }
259
+ async function pushDone(convId) {
260
+ try {
261
+ if (!pushStore.prefs(PUSH_USER).done) return;
262
+ const conv = ((await rpc('session.list')).conversations ?? []).find((c) => Number(c.id) === Number(convId));
263
+ if (!conv || conv.busy === true) return; // đã chạy tiếp — không phải "xong"
264
+ const entries = (await rpc('transcript.raw', { conversationId: convId })).entries ?? [];
265
+ let title = `session ${convId}`;
266
+ const m0 = entries.find((e) => e.kind === 'pi.user')?.model?.[0];
267
+ const text0 = typeof m0?.content === 'string' ? m0.content : Array.isArray(m0?.content) ? m0.content.filter((p) => p?.type === 'text').map((p) => p.text).join(' ') : '';
268
+ if (text0) title = text0.split('\n')[0].slice(0, 60);
269
+ const ma = [...entries].reverse().find((e) => e.kind === 'pi.assistant')?.model?.[0];
270
+ if (ma?.stopReason === 'aborted') return;
271
+ const failed = ma?.stopReason === 'error' || Boolean(ma?.errorMessage);
272
+ const bodyText = ma ? (Array.isArray(ma.content) ? ma.content.filter((p) => p?.type === 'text').map((p) => p.text).join('\n') : String(ma.text ?? '')) : '';
273
+ const sent = await pushStore.notify(PUSH_USER, {
274
+ title: failed ? `bom stopped with an error · ${title}` : `bom finished · ${title}`,
275
+ body: String(failed ? ma.errorMessage ?? 'The model request failed.' : bodyText || 'Done.').slice(0, 400),
276
+ url: `/s/${Number(convId)}`,
277
+ tag: `done-${convId}`,
278
+ });
279
+ console.error(`push done ${convId}${failed ? ' (error)' : ''} → ${sent} thiết bị`);
280
+ } catch (e) { console.error(`push done ${convId} lỗi: ${e.message}`); }
281
+ }
282
+
225
283
  // ---- HTML (mobile-first, không framework) ----
226
284
  const HTML = `<!doctype html>
227
285
  <html lang="en"><head>
@@ -668,6 +726,30 @@ const http = createHttpServer(async (req, res) => {
668
726
  try { return json2(res, 200, await rpc('pair.poll', { enrollId: p.enrollId })); }
669
727
  catch (e) { return json2(res, 404, { error: e.message.replace(/^\d+: /, '') }); }
670
728
  }
729
+ // ---- /hook/<id>[/<secret>]: webhook public (vòng 9 — port pi-pocket hooks, bản đơn).
730
+ // Dịch vụ ngoài (GitHub/CI/monitor/cron) POST ⇒ message vào conversation. KHÔNG token:
731
+ // secret nằm trong path (unsigned) hoặc chữ ký X-Hub-Signature-256 (signed) — daemon verify. ----
732
+ if (req.method === 'POST' && url.pathname.startsWith('/hook/')) {
733
+ const parts = url.pathname.slice('/hook/'.length).split('/').filter(Boolean);
734
+ if (parts.length < 1 || parts.length > 2 || parts.some((p) => p === '')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"Not found"}'); }
735
+ if (Number(req.headers['content-length'] ?? 0) > 5 * 1024 * 1024) { res.writeHead(413, { 'content-type': 'application/json' }); return res.end('{"error":"Request too large"}'); }
736
+ const body = await new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => done(b)); });
737
+ try {
738
+ const r = await rpc('hooks.dispatch', {
739
+ id: parts[0],
740
+ ...(parts[1] !== undefined ? { secret: parts[1] } : {}),
741
+ body,
742
+ signature: req.headers['x-hub-signature-256'],
743
+ delivery: req.headers['x-github-delivery'] ?? req.headers['idempotency-key'],
744
+ });
745
+ return json2(res, 200, r);
746
+ } catch (e) {
747
+ const msg = String(e?.message ?? e).replace(/^-?\d+: /, '');
748
+ if (/not found/.test(msg)) return json2(res, 404, { error: 'Not found' }); // không tiết lộ hook tồn tại与否
749
+ if (/rate-limited/.test(msg)) { res.writeHead(429, { 'content-type': 'application/json', 'retry-after': '60', ...CORS }); return res.end('{"error":"Too many posts this hour"}'); }
750
+ return json2(res, 502, { error: msg });
751
+ }
752
+ }
671
753
  // ---- UI pocket (public/): shell + module + vendor tĩnh, KHÔNG cần token ----
672
754
  // serveStatic phục vụ cả sw.js/manifest/icon của pocket (public/) — không còn hằng
673
755
  // SW cũ: sw.js pocket có push handler (notification từ push service).
@@ -689,6 +771,17 @@ const http = createHttpServer(async (req, res) => {
689
771
  }
690
772
  if (!(await authorized(req, url))) { res.writeHead(401, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ error: 'unauthorized — open /pair and enter the code from `bom-pi pair`' })); }
691
773
 
774
+ // ---- /rpc: JSON-RPC 2.0 qua HTTP cho CLI từ thiết bị khác (BOM_PI_URL + device token) ----
775
+ // Cùng cổng auth /api/* (master BOM_PI_TOKEN hoặc device token bom_…); web-devices pair
776
+ // trước rồi đặt env. Trả JSON-RPC error shape để CLI hiển thị như lỗi local.
777
+ if (req.method === 'POST' && url.pathname === '/rpc') {
778
+ const raw = await new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => done(b)); });
779
+ let p; try { p = JSON.parse(raw || '{}'); } catch { return json2(res, 400, { error: { code: -32700, message: 'invalid JSON' } }); }
780
+ if (typeof p.method !== 'string') return json2(res, 400, { error: { code: -32600, message: 'method (string) required' } });
781
+ try { return json2(res, 200, await rpc(p.method, p.params)); }
782
+ catch (e) { return json2(res, 502, { error: { code: -32000, message: String(e?.message ?? e).replace(/^\d+: /, '') } }); }
783
+ }
784
+
692
785
  if (req.method === 'GET' && url.pathname === '/') {
693
786
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...CORS });
694
787
  return res.end(HTML);
@@ -805,4 +898,8 @@ http.listen(PORT, LISTEN, () => {
805
898
  console.error(`bom-web listening http://${LISTEN}:${PORT} — bom-core at ${socketPath}${TOKEN ? ' — token required' : ' — NO TOKEN (loopback only)'}${pushStore ? ` — push ${pushStore.publicKey.slice(0, 12)}…` : ' — push OFF'}`);
806
899
  // Socket events sống từ boot: push approval phải nổ khi không ai mở web
807
900
  ensureEventsSocket().catch(() => scheduleEventsReconnect());
901
+ // eventsSocket chỉ watch conv active — conv khác (factory) kết thúc turn không sinh
902
+ // event cho web ⇒ poll 5s mọi conv cho push done/approval (chỉ khi có subscription,
903
+ // hai watch đều debounce + early-return nên gần như miễn phí khi không ai đăng ký).
904
+ setInterval(() => { pushApprovalWatch(); pushDoneWatch(); }, 5000).unref?.();
808
905
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bom-pi",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "description": "bom × pi — hệ sinh thái durable cho pi trên nền pi-durable: core daemon + web UI pocket + CLI",
6
6
  "engines": {
@@ -27,6 +27,18 @@ const cid = arg('cid', null); // conversation id cho submit/transcript/reset/wat
27
27
  const cidParams = () => (cid ? { conversationId: Number(cid) } : {});
28
28
  const cmd = process.argv[2] ?? 'status';
29
29
 
30
+ // Remote (thiết bị khác — Windows/phone đã pair): BOM_PI_URL=http://host:8791 (+ BOM_PI_TOKEN
31
+ // device token bom_… copy sau khi pair qua browser). CLI nói chuyện web /rpc thay unix socket.
32
+ const REMOTE_URL = arg('url', process.env.BOM_PI_URL || null);
33
+ const REMOTE_TOKEN = arg('token', process.env.BOM_PI_TOKEN || null);
34
+
35
+ // -v/--version trước mọi thứ — user kiểm bản nào đang chạy (bài học: máy Windows kẹt bản cũ)
36
+ const VERSION = (await import('../../../package.json', { with: { type: 'json' } })).default.version;
37
+ if (cmd === '-v' || cmd === '--version' || cmd === 'version') {
38
+ console.log(`bom-pi ${VERSION}${REMOTE_URL ? ` (remote ${REMOTE_URL})` : ''}`);
39
+ process.exit(0);
40
+ }
41
+
30
42
  // Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
31
43
  // thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
32
44
  function unreachable(e) {
@@ -35,18 +47,23 @@ function unreachable(e) {
35
47
  return `${head}
36
48
 
37
49
  bom-core chạy trên máy chủ Linux (unix socket + systemd) — CLI chưa chạy được trên Windows.
38
- Từ thiết bị khác, dùng WEB thay CLI: mở http://<ip-máy-chạy-daemon>:8791 (hoặc URL tunnel
39
- https://… nếu có) rồi pair bằng code in ra từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ đó.`;
50
+ Hai đường từ máy khác:
51
+ · WEB (đầy đủ nhất): mở http://<ip-máy-chạy-daemon>:8791 (hoặc tunnel https://…) rồi pair
52
+ bằng code in ra từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ đó.
53
+ · CLI REMOTE: pair qua browser trước, copy device token bom_…, rồi đặt:
54
+ set BOM_PI_URL=http://<ip>:8791 & set BOM_PI_TOKEN=bom_…
55
+ (PowerShell: $env:BOM_PI_URL=…; $env:BOM_PI_TOKEN=…) rồi chạy bom-pi status`;
40
56
  }
41
57
  return `${head}
42
58
 
43
59
  daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
44
60
  chưa cài daemon?: bom-pi setup
45
- ở THIẾT BỊ KHÁC?: CLI nói chuyện daemon trên MÁY NÀY — từ phone/laptop hãy mở
46
- http://<ip-máy-chạy-daemon>:8791 và pair bằng code từ 'bom-pi pair'.`;
61
+ ở THIẾT BỊ KHÁC?: CLI có thể nói chuyện daemon từ xa — pair qua browser trước
62
+ (http://<ip-máy-chạy-daemon>:8791), copy device token, rồi:
63
+ BOM_PI_URL=http://<ip>:8791 BOM_PI_TOKEN=bom_… bom-pi status`;
47
64
  }
48
65
 
49
- function rpc(method, params) {
66
+ function rpcSocket(method, params) {
50
67
  return new Promise((done, fail) => {
51
68
  const conn = connect(socketPath);
52
69
  let buf = '';
@@ -82,6 +99,19 @@ function rpc(method, params) {
82
99
 
83
100
  const pretty = (o) => console.log(JSON.stringify(o, null, 2));
84
101
 
102
+ // Dispatcher: remote (/rpc qua HTTP) khi BOM_PI_URL đặt — thiết bị khác không có unix socket
103
+ async function rpcRemote(method, params) {
104
+ const r = await fetch(`${REMOTE_URL.replace(/\/+$/, '')}/rpc`, {
105
+ method: 'POST',
106
+ headers: { 'content-type': 'application/json', ...(REMOTE_TOKEN ? { Authorization: `Bearer ${REMOTE_TOKEN}` } : {}) },
107
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
108
+ });
109
+ const j = await r.json().catch(() => ({}));
110
+ if (!r.ok) throw new Error(`bom-web ${REMOTE_URL} ${r.status}: ${j?.error?.message ?? r.statusText}`);
111
+ return j;
112
+ }
113
+ const rpc = REMOTE_URL ? rpcRemote : rpcSocket;
114
+
85
115
  // Kiểm protocol sau hello — mọi lệnh đều chạy qua đây
86
116
  function checkProtocol(hello) {
87
117
  if (typeof hello?.protocol === 'number' && hello.protocol > CLIENT_PROTOCOL) {
@@ -93,6 +123,10 @@ function checkProtocol(hello) {
93
123
 
94
124
  // ---- watch: mở 1 connection, subscribe, in transcript live đến khi Ctrl+C ----
95
125
  function watch() {
126
+ if (REMOTE_URL) {
127
+ console.error('watch cần kết nối socket local — từ thiết bị khác hãy mở web (transcript live có sẵn trong UI)');
128
+ process.exit(64);
129
+ }
96
130
  const conn = connect(socketPath);
97
131
  let gotReply = false;
98
132
  conn.on('connect', () => {
@@ -461,7 +495,7 @@ WantedBy=default.target
461
495
  } else if (cmd === 'shutdown') {
462
496
  pretty(await rpc('shutdown'));
463
497
  } else {
464
- console.error('usage: bom-pi <hello|status|models|agents|sessions|new|use|submit|reset|transcript|approvals|approve|deny|schedules|schedule|unschedule|factory|limits|abort|watch|shutdown> [--socket PATH] [--cid N]');
498
+ console.error('usage: bom-pi <hello|status|models|agents|sessions|new|use|submit|reset|transcript|approvals|approve|deny|schedules|schedule|unschedule|factory|limits|abort|watch|shutdown|version> [--socket PATH] [--cid N] [--url http://host:8791] [--token bom_…]\n từ thiết bị khác: BOM_PI_URL + BOM_PI_TOKEN (xem bom-pi version)');
465
499
  process.exit(64);
466
500
  }
467
501
  if (cmd !== 'watch') process.exit(0);
@@ -31,7 +31,7 @@ import { homedir, userInfo } from 'node:os';
31
31
  import process from 'node:process';
32
32
  import { execFile as execFileCb } from 'node:child_process';
33
33
  import { promisify } from 'node:util';
34
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes } from 'node:crypto';
34
+ import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
35
35
  import { createLimitGate } from './limit-gate.mjs';
36
36
 
37
37
  const VERSION = '0.0.4';
@@ -624,6 +624,10 @@ function clearWatchState(ownerKey) {
624
624
  async function agentStateOf(id) {
625
625
  try { return await harness.snapshot(AgentDoc, id, BACKGROUND_CONTEXT); } catch { return undefined; }
626
626
  }
627
+ // Busy = LiveDoc.run đang chạy (khuôn pocket app.ts #busy) — turn đang stream / tool đang chạy
628
+ async function busyOf(id) {
629
+ try { return (await harness.snapshot(LiveDoc, id, BACKGROUND_CONTEXT))?.run !== undefined; } catch { return false; }
630
+ }
627
631
  // factory của conversation gọi (gốc hoặc con); null = orphan (K7 — vẫn chạy, không thuộc xưởng)
628
632
  function deriveFactory(cid) {
629
633
  return factories.find((f) => f.conversationId === cid || f.childConversationIds.includes(cid)) ?? null;
@@ -986,6 +990,37 @@ async function saveDevices() {
986
990
  await writeFile(tmp, JSON.stringify(webDevices, null, 2) + '\n');
987
991
  await rename(tmp, devicesPath);
988
992
  }
993
+
994
+ // ---- Webhooks (vòng 9 — port pi-pocket hooks.ts, bản đơn có chủ ý): địa chỉ mà dịch vụ
995
+ // ngoài (GitHub/CI/monitor/cron) POST đến ⇒ message vào conversation. Secret trong path
996
+ // (unsigned) hoặc chữ ký X-Hub-Signature-256 (signed, khuôn GitHub). PORT(bom-pi): lọc
997
+ // "người ngoài repo" của pocket (cần gh query writers) — vòng này post hợp lệ đều qua. ----
998
+ const hooksPath = join(dataDir, 'hooks.json');
999
+ let hooks = []; // [{id, secret, name, instructions, target, signed?, by, createdAt, count, lastAt?}]
1000
+ const hookSeen = new Map(); // idempotency: key → ts (delivery id / Idempotency-Key, 1h)
1001
+ const hookHits = new Map(); // rate-limit: hook id → [ts] (30 post/h, cửa sổ trượt)
1002
+ const HOOK_LIMITS = { max: 20, nameLen: 60, instructionsLen: 2000, perHour: 30, payloadChars: 8000 };
1003
+ const HOOK_DEFAULT_INSTRUCTIONS = 'Something happened that you were set up to hear about. Say in a line what it is, and whether anything needs doing.';
1004
+ async function loadHooks() {
1005
+ try { hooks = JSON.parse(await readFile(hooksPath, 'utf8')); } catch { hooks = []; }
1006
+ }
1007
+ async function saveHooks() {
1008
+ const tmp = hooksPath + '.tmp';
1009
+ await writeFile(tmp, JSON.stringify(hooks, null, 2) + '\n');
1010
+ await rename(tmp, hooksPath);
1011
+ }
1012
+ const hookLabel = (name) => String(name ?? '').replace(/[[\]]/g, '').replace(/\s+/g, ' ').trim();
1013
+ const sameSecret = (a, b) => {
1014
+ const l = createHash('sha256').update(String(a)).digest();
1015
+ const r = createHash('sha256').update(String(b)).digest();
1016
+ return timingSafeEqual(l, r) && String(a).length === String(b).length;
1017
+ };
1018
+ function hookView(h) {
1019
+ const { secret, ...rest } = h;
1020
+ // unsigned: secret nằm trong path (địa chỉ tự chịu); signed: path không secret —
1021
+ // secret hiển thị riêng để paste vào GitHub Webhook secret
1022
+ return h.signed === true ? { ...rest, path: `/hook/${h.id}`, secret } : { ...rest, path: `/hook/${h.id}/${secret}` };
1023
+ }
989
1024
  const sha256hex = (s) => createHash('sha256').update(s).digest('hex');
990
1025
  // Daemon keypair Ed25519 — sinh tại lần boot đầu (khuôn ssh host key / spin daemon):
991
1026
  // MÁY NÀO cài bom-pi cũng tự có cặp key riêng. fp = 8 hex đầu sha256(publicKey DER) —
@@ -1051,7 +1086,7 @@ let taskRegistry = null; // gán trong boot sau createRegistry (registry là loc
1051
1086
  // ---- Mở Harness (pin exact @earendil-works 1.4.0 — xem DECISIONS.md) ----
1052
1087
  const { Harness } = await import('@earendil-works/pi-durable');
1053
1088
  const { openNodeSqliteStorage } = await import('@earendil-works/pi-durable/storage/sqlite/node');
1054
- const { createRegistry, AgentDoc, defineExtension, wrapTool } = await import('@earendil-works/pi-durable');
1089
+ const { createRegistry, AgentDoc, LiveDoc, defineExtension, wrapTool } = await import('@earendil-works/pi-durable');
1055
1090
  const { CodingTools } = await import('@earendil-works/pi-durable/tools');
1056
1091
  const { NodeExecutionEnv } = await import('@earendil-works/pi-durable/env/node');
1057
1092
  const { ModelRuntime } = await import('@earendil-works/pi-coding-agent');
@@ -1321,6 +1356,7 @@ try {
1321
1356
  await loadFactories();
1322
1357
  await loadMetrics(); // M6c: ledger số đo trust ladder
1323
1358
  await loadDevices(); // M8: thiết bị đã pair (web-devices.json)
1359
+ await loadHooks(); // vòng 9: webhook ngoài → conversation (hooks.json)
1324
1360
  await loadDaemonKeys(); // offer pair: keypair danh tính — fp + pubkey cho device đối chiếu
1325
1361
  if (factories.length) log('factories nạp lại:', factories.length, '—', factories.map((f) => f.id).join(', '));
1326
1362
  await loadConfigWatches(); // M5b: đăng lại watch config (scan-timer là M5d)
@@ -1471,6 +1507,7 @@ const methods = {
1471
1507
  model: agentState?.model ?? null,
1472
1508
  cwd: agentState?.cwd ?? null,
1473
1509
  instructions: agentState?.instructions ?? null, // M5a: smoke kiểm instructions của factory conv
1510
+ busy: await busyOf(id), // web/UI + push 'done' theo dõi busy→idle
1474
1511
  });
1475
1512
  }
1476
1513
  return { active: activeConvId, count: out.length, conversations: out };
@@ -1565,6 +1602,97 @@ const methods = {
1565
1602
  await conv.configure(change, BACKGROUND_CONTEXT);
1566
1603
  return { ok: true, conversationId: conv.id };
1567
1604
  },
1605
+ // ---- Webhooks (vòng 9): quản lý qua CLI/web, dispatch từ route public /hook/<id>/<secret> ----
1606
+ async hooksList() {
1607
+ return { hooks: hooks.map(hookView) };
1608
+ },
1609
+ async hooksAdd(params) {
1610
+ const name = hookLabel(params?.name);
1611
+ const instructions = typeof params?.instructions === 'string' ? params.instructions.trim() : '';
1612
+ if (!name || name.length > HOOK_LIMITS.nameLen) throw new Error(`name: 1–${HOOK_LIMITS.nameLen} ký tự, một dòng`);
1613
+ if (instructions.length > HOOK_LIMITS.instructionsLen) throw new Error(`instructions ≤ ${HOOK_LIMITS.instructionsLen} ký tự`);
1614
+ const target = Number(params?.target);
1615
+ if (!Number.isSafeInteger(target)) throw new Error('target: conversation id (xem session.list)');
1616
+ await convFor({ conversationId: target }); // phải là conversation thật
1617
+ if (hooks.length >= HOOK_LIMITS.max) throw new Error(`tối đa ${HOOK_LIMITS.max} webhook — bỏ cái cũ trước (hooks.remove)`);
1618
+ // PORT(bom-pi): 'anyone' của pocket (lọc người ngoài repo qua gh) chưa có — bỏ qua
1619
+ const hook = {
1620
+ id: randomBytes(6).toString('base64url'),
1621
+ secret: randomBytes(24).toString('base64url'),
1622
+ name, instructions, target,
1623
+ ...(params?.signed === true ? { signed: true } : {}),
1624
+ by: 'bom-owner', createdAt: Date.now(), count: 0,
1625
+ };
1626
+ hooks.push(hook);
1627
+ await saveHooks();
1628
+ log('webhook tạo:', hook.id, '→ conv', target, hook.signed ? '(GitHub signed)' : '');
1629
+ return hookView(hook);
1630
+ },
1631
+ async hooksRemove({ id }) {
1632
+ const before = hooks.length;
1633
+ hooks = hooks.filter((h) => h.id !== String(id ?? ''));
1634
+ if (hooks.length === before) throw new Error('không có webhook đó — hooks.list');
1635
+ hookHits.delete(String(id));
1636
+ await saveHooks();
1637
+ return { ok: true };
1638
+ },
1639
+ async hooksRotate({ id }) {
1640
+ const h = hooks.find((x) => x.id === String(id ?? ''));
1641
+ if (!h) throw new Error('không có webhook đó — hooks.list');
1642
+ h.secret = randomBytes(24).toString('base64url');
1643
+ await saveHooks();
1644
+ log('webhook xoay secret:', h.id);
1645
+ return hookView(h);
1646
+ },
1647
+ async hooksDispatch({ id, secret, body, signature, delivery }) {
1648
+ const h = hooks.find((x) => x.id === String(id ?? ''));
1649
+ // Không tiết lộ hook có tồn tại hay không — 404 chung chung như pocket
1650
+ if (!h) throw new Error('not found');
1651
+ const text = String(body ?? '');
1652
+ if (secret !== undefined) {
1653
+ if (h.signed === true || !sameSecret(secret, h.secret)) throw new Error('not found');
1654
+ } else {
1655
+ // Khuôn GitHub: X-Hub-Signature-256 = sha256=HMAC_SHA256(secret, body)
1656
+ const sig = String(signature ?? '');
1657
+ const expect = `sha256=${createHmac('sha256', h.secret).update(text).digest('hex')}`;
1658
+ if (h.signed !== true || !sameSecret(sig, expect)) throw new Error('not found');
1659
+ }
1660
+ // Idempotency (GitHub delivery id / Idempotency-Key): post lại không thành message thứ hai
1661
+ const key = delivery !== undefined && delivery !== '' ? String(delivery) : null;
1662
+ if (key) {
1663
+ const seen = hookSeen.get(`${h.id}:${key}`);
1664
+ if (seen && Date.now() - seen < 3_600_000) return { ok: true, duplicate: true, conversationId: h.target };
1665
+ hookSeen.set(`${h.id}:${key}`, Date.now());
1666
+ if (hookSeen.size > 500) { // prune — giữ map nhỏ
1667
+ const cut = Date.now() - 3_600_000;
1668
+ for (const [k, ts] of hookSeen) if (ts < cut) hookSeen.delete(k);
1669
+ }
1670
+ }
1671
+ // Rate-limit 30 post/h mỗi hook (cửa sổ trượt)
1672
+ const now = Date.now();
1673
+ const hits = (hookHits.get(h.id) ?? []).filter((t) => now - t < 3_600_000);
1674
+ if (hits.length >= HOOK_LIMITS.perHour) throw new Error('rate-limited: too many posts this hour');
1675
+ hits.push(now);
1676
+ hookHits.set(h.id, hits);
1677
+ // Message khuôn pocket: nhãn + instructions + payload fence (đọc như DATA, không phải lệnh)
1678
+ const payload = text.length > HOOK_LIMITS.payloadChars ? `${text.slice(0, HOOK_LIMITS.payloadChars)}\n… (clipped)` : text;
1679
+ const message = `[via webhook ${h.name}]\n${h.instructions || HOOK_DEFAULT_INSTRUCTIONS}\n\n<webhook-payload>\n${payload}\n</webhook-payload>`;
1680
+ try {
1681
+ await methods.sessionSubmit({
1682
+ conversationId: h.target,
1683
+ text: message,
1684
+ ...(key ? { requestId: `hook-${h.id}-${key}` } : {}), // durable idempotent khi có delivery id
1685
+ });
1686
+ } catch (e) {
1687
+ hookSeen.delete(`${h.id}:${key}`); // không vào transcript ⇒ lần sau thử lại được
1688
+ throw new Error(`không submit được: ${e.message}`);
1689
+ }
1690
+ h.count += 1;
1691
+ h.lastAt = now;
1692
+ await saveHooks();
1693
+ return { ok: true, conversationId: h.target };
1694
+ },
1695
+
1568
1696
  async sessionSubmit(params) {
1569
1697
  const conv = await convFor(params);
1570
1698
  const text = typeof params.text === 'string' ? params.text.trim() : '';