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.
- package/apps/web/pocket-adapter.mjs +32 -3
- package/apps/web/server.mjs +98 -1
- package/package.json +1 -1
- package/packages/cli/src/bom-pi.mjs +40 -6
- package/packages/core/src/daemon.mjs +130 -2
|
@@ -137,7 +137,7 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
|
|
|
137
137
|
createdAt: updatedAt,
|
|
138
138
|
updatedAt,
|
|
139
139
|
createdBy: OWNER.id,
|
|
140
|
-
busy:
|
|
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
|
|
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
|
package/apps/web/server.mjs
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
|
46
|
-
http://<ip-máy-chạy-daemon>:8791
|
|
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
|
|
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() : '';
|