bom-pi 0.0.3 → 0.0.4
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:
|
|
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'],
|
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>
|
|
@@ -689,6 +747,17 @@ const http = createHttpServer(async (req, res) => {
|
|
|
689
747
|
}
|
|
690
748
|
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
749
|
|
|
750
|
+
// ---- /rpc: JSON-RPC 2.0 qua HTTP cho CLI từ thiết bị khác (BOM_PI_URL + device token) ----
|
|
751
|
+
// Cùng cổng auth /api/* (master BOM_PI_TOKEN hoặc device token bom_…); web-devices pair
|
|
752
|
+
// trước rồi đặt env. Trả JSON-RPC error shape để CLI hiển thị như lỗi local.
|
|
753
|
+
if (req.method === 'POST' && url.pathname === '/rpc') {
|
|
754
|
+
const raw = await new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => done(b)); });
|
|
755
|
+
let p; try { p = JSON.parse(raw || '{}'); } catch { return json2(res, 400, { error: { code: -32700, message: 'invalid JSON' } }); }
|
|
756
|
+
if (typeof p.method !== 'string') return json2(res, 400, { error: { code: -32600, message: 'method (string) required' } });
|
|
757
|
+
try { return json2(res, 200, await rpc(p.method, p.params)); }
|
|
758
|
+
catch (e) { return json2(res, 502, { error: { code: -32000, message: String(e?.message ?? e).replace(/^\d+: /, '') } }); }
|
|
759
|
+
}
|
|
760
|
+
|
|
692
761
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
693
762
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...CORS });
|
|
694
763
|
return res.end(HTML);
|
|
@@ -805,4 +874,8 @@ http.listen(PORT, LISTEN, () => {
|
|
|
805
874
|
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
875
|
// Socket events sống từ boot: push approval phải nổ khi không ai mở web
|
|
807
876
|
ensureEventsSocket().catch(() => scheduleEventsReconnect());
|
|
877
|
+
// eventsSocket chỉ watch conv active — conv khác (factory) kết thúc turn không sinh
|
|
878
|
+
// event cho web ⇒ poll 5s mọi conv cho push done/approval (chỉ khi có subscription,
|
|
879
|
+
// hai watch đều debounce + early-return nên gần như miễn phí khi không ai đăng ký).
|
|
880
|
+
setInterval(() => { pushApprovalWatch(); pushDoneWatch(); }, 5000).unref?.();
|
|
808
881
|
});
|
package/package.json
CHANGED
|
@@ -27,6 +27,11 @@ 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
|
+
|
|
30
35
|
// Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
|
|
31
36
|
// thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
|
|
32
37
|
function unreachable(e) {
|
|
@@ -35,18 +40,23 @@ function unreachable(e) {
|
|
|
35
40
|
return `${head}
|
|
36
41
|
|
|
37
42
|
bom-core chạy trên máy chủ Linux (unix socket + systemd) — CLI chưa chạy được trên Windows.
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
Hai đường từ máy khác:
|
|
44
|
+
· WEB (đầy đủ nhất): mở http://<ip-máy-chạy-daemon>:8791 (hoặc tunnel https://…) rồi pair
|
|
45
|
+
bằng code in ra từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ đó.
|
|
46
|
+
· CLI REMOTE: pair qua browser trước, copy device token bom_…, rồi đặt:
|
|
47
|
+
set BOM_PI_URL=http://<ip>:8791 & set BOM_PI_TOKEN=bom_…
|
|
48
|
+
(PowerShell: $env:BOM_PI_URL=…; $env:BOM_PI_TOKEN=…) rồi chạy bom-pi status`;
|
|
40
49
|
}
|
|
41
50
|
return `${head}
|
|
42
51
|
|
|
43
52
|
daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
|
|
44
53
|
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
|
|
54
|
+
ở THIẾT BỊ KHÁC?: CLI có thể nói chuyện daemon từ xa — pair qua browser trước
|
|
55
|
+
(http://<ip-máy-chạy-daemon>:8791), copy device token, rồi:
|
|
56
|
+
BOM_PI_URL=http://<ip>:8791 BOM_PI_TOKEN=bom_… bom-pi status`;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
function
|
|
59
|
+
function rpcSocket(method, params) {
|
|
50
60
|
return new Promise((done, fail) => {
|
|
51
61
|
const conn = connect(socketPath);
|
|
52
62
|
let buf = '';
|
|
@@ -82,6 +92,19 @@ function rpc(method, params) {
|
|
|
82
92
|
|
|
83
93
|
const pretty = (o) => console.log(JSON.stringify(o, null, 2));
|
|
84
94
|
|
|
95
|
+
// Dispatcher: remote (/rpc qua HTTP) khi BOM_PI_URL đặt — thiết bị khác không có unix socket
|
|
96
|
+
async function rpcRemote(method, params) {
|
|
97
|
+
const r = await fetch(`${REMOTE_URL.replace(/\/+$/, '')}/rpc`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json', ...(REMOTE_TOKEN ? { Authorization: `Bearer ${REMOTE_TOKEN}` } : {}) },
|
|
100
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
|
|
101
|
+
});
|
|
102
|
+
const j = await r.json().catch(() => ({}));
|
|
103
|
+
if (!r.ok) throw new Error(`bom-web ${REMOTE_URL} ${r.status}: ${j?.error?.message ?? r.statusText}`);
|
|
104
|
+
return j;
|
|
105
|
+
}
|
|
106
|
+
const rpc = REMOTE_URL ? rpcRemote : rpcSocket;
|
|
107
|
+
|
|
85
108
|
// Kiểm protocol sau hello — mọi lệnh đều chạy qua đây
|
|
86
109
|
function checkProtocol(hello) {
|
|
87
110
|
if (typeof hello?.protocol === 'number' && hello.protocol > CLIENT_PROTOCOL) {
|
|
@@ -93,6 +116,10 @@ function checkProtocol(hello) {
|
|
|
93
116
|
|
|
94
117
|
// ---- watch: mở 1 connection, subscribe, in transcript live đến khi Ctrl+C ----
|
|
95
118
|
function watch() {
|
|
119
|
+
if (REMOTE_URL) {
|
|
120
|
+
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)');
|
|
121
|
+
process.exit(64);
|
|
122
|
+
}
|
|
96
123
|
const conn = connect(socketPath);
|
|
97
124
|
let gotReply = false;
|
|
98
125
|
conn.on('connect', () => {
|
|
@@ -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;
|
|
@@ -1051,7 +1055,7 @@ let taskRegistry = null; // gán trong boot sau createRegistry (registry là loc
|
|
|
1051
1055
|
// ---- Mở Harness (pin exact @earendil-works 1.4.0 — xem DECISIONS.md) ----
|
|
1052
1056
|
const { Harness } = await import('@earendil-works/pi-durable');
|
|
1053
1057
|
const { openNodeSqliteStorage } = await import('@earendil-works/pi-durable/storage/sqlite/node');
|
|
1054
|
-
const { createRegistry, AgentDoc, defineExtension, wrapTool } = await import('@earendil-works/pi-durable');
|
|
1058
|
+
const { createRegistry, AgentDoc, LiveDoc, defineExtension, wrapTool } = await import('@earendil-works/pi-durable');
|
|
1055
1059
|
const { CodingTools } = await import('@earendil-works/pi-durable/tools');
|
|
1056
1060
|
const { NodeExecutionEnv } = await import('@earendil-works/pi-durable/env/node');
|
|
1057
1061
|
const { ModelRuntime } = await import('@earendil-works/pi-coding-agent');
|
|
@@ -1471,6 +1475,7 @@ const methods = {
|
|
|
1471
1475
|
model: agentState?.model ?? null,
|
|
1472
1476
|
cwd: agentState?.cwd ?? null,
|
|
1473
1477
|
instructions: agentState?.instructions ?? null, // M5a: smoke kiểm instructions của factory conv
|
|
1478
|
+
busy: await busyOf(id), // web/UI + push 'done' theo dõi busy→idle
|
|
1474
1479
|
});
|
|
1475
1480
|
}
|
|
1476
1481
|
return { active: activeConvId, count: out.length, conversations: out };
|