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: 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'],
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bom-pi",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
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,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
- 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Ủ đó.`;
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 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'.`;
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 rpc(method, params) {
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 };