bom-pi 0.0.2 → 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.
@@ -97,7 +97,7 @@ function mapApproval(a) {
97
97
 
98
98
  const OWNER = { id: 'bom-owner', name: 'Owner', role: 'owner' };
99
99
 
100
- export function makePocketAdapter({ rpc, checkToken }) {
100
+ export function makePocketAdapter({ rpc, checkToken, push }) {
101
101
  // me-shape: mọi thứ UI cần biết về server (chụp từ pocket 0.27.0 thật)
102
102
  async function mePayload() {
103
103
  const hello = await rpc('hello');
@@ -137,7 +137,7 @@ export function makePocketAdapter({ rpc, checkToken }) {
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 }) {
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'],
@@ -186,7 +186,7 @@ export function makePocketAdapter({ rpc, checkToken }) {
186
186
  if (isPost && req.headers['x-pocket'] !== '1') return false; // POST pocket luôn có X-Pocket (api.ts l365)
187
187
  if (!['me', 'login', 'logout', 'sessions', 'c', 'providers', 'themes', 'theme', 'events', 'poll',
188
188
  'users', 'hooks', 'subagents', 'pip', 'extensions', 'extension-settings', 'fs', 'visibility', 'peeks',
189
- 'folder-favorites', 'spend', 'pi-sessions', 'x', 'repo-watches', 'approvals'].includes(first)) return false;
189
+ 'folder-favorites', 'spend', 'pi-sessions', 'x', 'repo-watches', 'approvals', 'push'].includes(first)) return false;
190
190
 
191
191
  try {
192
192
  // ---- sign-in/lifecycle ----
@@ -275,6 +275,36 @@ export function makePocketAdapter({ rpc, checkToken }) {
275
275
  return json(res, 200, true), true;
276
276
  }
277
277
 
278
+ // ---- push: phone notifications (Web Push RFC 8291/8292/8030 — push.mjs port pi-pocket) ----
279
+ if (first === 'push') {
280
+ if (!push) return json(res, 503, { error: 'Push notifications are not available on this server.' }), true;
281
+ const { store, userId } = push;
282
+ if (second === undefined && method === 'GET') {
283
+ return json(res, 200, { publicKey: store.publicKey, prefs: store.prefs(userId), devices: store.subscriptions(userId).length }), true;
284
+ }
285
+ if (second === 'subscribe' && isPost) {
286
+ const body = await readBody(req);
287
+ try { store.subscribe(userId, body.subscription, String(req.headers['user-agent'] ?? '').slice(0, 200)); }
288
+ catch (e) { return json(res, 400, { error: String(e?.message ?? e) }), true; }
289
+ return json(res, 200, { ok: true, devices: store.subscriptions(userId).length }), true;
290
+ }
291
+ if (second === 'unsubscribe' && isPost) {
292
+ const body = await readBody(req);
293
+ store.unsubscribe(String(body.endpoint ?? ''), userId);
294
+ return json(res, 200, { ok: true, devices: store.subscriptions(userId).length }), true;
295
+ }
296
+ if (second === 'prefs' && isPost) {
297
+ const body = await readBody(req);
298
+ return json(res, 200, { prefs: store.setPrefs(userId, body) }), true;
299
+ }
300
+ if (second === 'test' && isPost) {
301
+ await readBody(req);
302
+ const sent = await store.notify(userId, { title: 'bom', body: 'Notifications work on this device.', url: '/', tag: 'test' });
303
+ if (sent === 0) return json(res, 409, { error: 'No device took the test notification. Turn notifications on again on this device.' }), true;
304
+ return json(res, 200, { sent }), true;
305
+ }
306
+ }
307
+
278
308
  // ---- providers/themes ----
279
309
  if (first === 'providers' && method === 'GET') {
280
310
  const models = ((await rpc('models.list')).models ?? []).map(mapModel);
@@ -1,7 +1,7 @@
1
1
  {
2
- "name": "Pi Pocket",
3
- "short_name": "Pi Pocket",
4
- "description": "Durable, multiplayer Pi agents in your browser",
2
+ "name": "bom",
3
+ "short_name": "bom",
4
+ "description": "bom × pi — durable factories of pi agents in your browser",
5
5
  "start_url": "/",
6
6
  "scope": "/",
7
7
  "display": "standalone",
@@ -0,0 +1,211 @@
1
+ // push.mjs — Web Push không dependency (port từ pi-pocket src/server/push.ts, MIT):
2
+ // RFC 8291 aes128gcm + RFC 8292 VAPID (ES256) + RFC 8030 POST. PushStore giữ VAPID keys,
3
+ // subscription mỗi user, prefs — push.json ở dataDir (atomic write 0600).
4
+ import {
5
+ createCipheriv, createECDH, createHash, createHmac, createPrivateKey, randomBytes, sign,
6
+ } from 'node:crypto';
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ export const DEFAULT_PREFS = Object.freeze({ done: true, approval: true, chat: true, mention: true, limit: true });
11
+ const DEFAULT_SUBJECT = 'mailto:bom-pi@localhost';
12
+ const DEFAULT_RECORD_SIZE = 4096;
13
+ const MAX_BODY = 1000;
14
+ const MAX_TITLE = 200;
15
+ const MAX_DEVICES = 10;
16
+
17
+ const hmac = (key, ...data) => {
18
+ const mac = createHmac('sha256', key);
19
+ for (const part of data) mac.update(part);
20
+ return mac.digest();
21
+ };
22
+ const decode = (value) => Buffer.from(String(value).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'), 'base64url');
23
+ const pad32 = (value) => (value.length >= 32 ? value : Buffer.concat([Buffer.alloc(32 - value.length), value]));
24
+
25
+ export function generateVapidKeys() {
26
+ const ecdh = createECDH('prime256v1');
27
+ ecdh.generateKeys();
28
+ return { publicKey: ecdh.getPublicKey().toString('base64url'), privateKey: pad32(ecdh.getPrivateKey()).toString('base64url') };
29
+ }
30
+
31
+ /** RFC 8291 aes128gcm body cho MỘT record. */
32
+ export function encryptPayload(plaintext, keys, options = {}) {
33
+ const data = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
34
+ const uaPublic = decode(keys.p256dh);
35
+ const authSecret = decode(keys.auth);
36
+ if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) throw new Error('p256dh must be an uncompressed P-256 point');
37
+ if (authSecret.length !== 16) throw new Error('auth must be 16 bytes');
38
+ const recordSize = options.recordSize ?? DEFAULT_RECORD_SIZE;
39
+ if (data.length + 1 + 16 > recordSize) throw new Error(`push payload too large: ${data.length} bytes`);
40
+ const salt = options.salt ?? randomBytes(16);
41
+ const ecdh = createECDH('prime256v1');
42
+ if (options.serverKeys === undefined) ecdh.generateKeys();
43
+ else ecdh.setPrivateKey(options.serverKeys.privateKey);
44
+ const asPublic = ecdh.getPublicKey();
45
+ const ecdhSecret = ecdh.computeSecret(uaPublic);
46
+ const prkKey = hmac(authSecret, ecdhSecret);
47
+ const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0', 'latin1'), uaPublic, asPublic]);
48
+ const ikm = hmac(prkKey, keyInfo, Buffer.from([1]));
49
+ const prk = hmac(salt, ikm);
50
+ const cek = hmac(prk, Buffer.from('Content-Encoding: aes128gcm\0\x01', 'latin1')).subarray(0, 16);
51
+ const nonce = hmac(prk, Buffer.from('Content-Encoding: nonce\0\x01', 'latin1')).subarray(0, 12);
52
+ const cipher = createCipheriv('aes-128-gcm', cek, nonce);
53
+ const padded = Buffer.concat([data, Buffer.from([2])]);
54
+ const ciphertext = Buffer.concat([cipher.update(padded), cipher.final(), cipher.getAuthTag()]);
55
+ const header = Buffer.alloc(16 + 4 + 1);
56
+ salt.copy(header, 0);
57
+ header.writeUInt32BE(recordSize, 16);
58
+ header.writeUInt8(asPublic.length, 20);
59
+ return Buffer.concat([header, asPublic, ciphertext]);
60
+ }
61
+
62
+ /** Authorization `vapid t=<jwt>, k=<pub>` (ES256, ieee-p1363 raw r||s). */
63
+ export function vapidAuthorization(endpoint, vapid, subject, now = Date.now()) {
64
+ const publicKey = decode(vapid.publicKey);
65
+ if (publicKey.length !== 65 || publicKey[0] !== 0x04) throw new Error('VAPID public key must be an uncompressed P-256 point');
66
+ const key = createPrivateKey({
67
+ key: {
68
+ kty: 'EC', crv: 'P-256',
69
+ x: publicKey.subarray(1, 33).toString('base64url'),
70
+ y: publicKey.subarray(33, 65).toString('base64url'),
71
+ d: pad32(decode(vapid.privateKey)).toString('base64url'),
72
+ },
73
+ format: 'jwk',
74
+ });
75
+ const header = Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256' })).toString('base64url');
76
+ const claims = { aud: new URL(endpoint).origin, exp: Math.floor(now / 1000) + 12 * 60 * 60, sub: subject };
77
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
78
+ const signingInput = `${header}.${payload}`;
79
+ const signature = sign('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' });
80
+ return `vapid t=${signingInput}.${signature.toString('base64url')}, k=${publicKey.toString('base64url')}`;
81
+ }
82
+
83
+ const topicFor = (tag) => (/^[A-Za-z0-9_-]{1,32}$/.test(tag) ? tag : createHash('sha256').update(tag).digest('base64url').slice(0, 32));
84
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
85
+
86
+ /** POST bản mã tới push service — trả HTTP status. */
87
+ export async function sendPush(subscription, message, vapid, options = {}) {
88
+ // JS không có type-check như TS gốc — mặc định an toàn khi trường thiếu
89
+ const payload = { title: clip(String(message.title ?? 'bom'), MAX_TITLE), body: clip(String(message.body ?? ''), MAX_BODY), url: message.url };
90
+ if (message.tag !== undefined) payload.tag = message.tag;
91
+ if (message.approval !== undefined) payload.approval = message.approval;
92
+ const body = encryptPayload(JSON.stringify(payload), subscription.keys);
93
+ const headers = {
94
+ 'Content-Encoding': 'aes128gcm',
95
+ 'Content-Type': 'application/octet-stream',
96
+ TTL: String(options.ttl ?? 24 * 60 * 60),
97
+ Urgency: options.urgency ?? 'normal',
98
+ Authorization: vapidAuthorization(subscription.endpoint, vapid, options.subject ?? DEFAULT_SUBJECT),
99
+ };
100
+ if (message.tag !== undefined && message.tag !== '') headers.Topic = topicFor(message.tag);
101
+ const doFetch = options.fetch ?? fetch;
102
+ const response = await doFetch(subscription.endpoint, { method: 'POST', headers, body: new Uint8Array(body) });
103
+ await response.arrayBuffer().catch(() => undefined);
104
+ return response.status;
105
+ }
106
+
107
+ // Chỉ nhận endpoint của các push service trình duyệt thật (chặn dùng server dò địa chỉ nội bộ)
108
+ const PUSH_HOSTS = [
109
+ /^fcm\.googleapis\.com$/,
110
+ /^android\.googleapis\.com$/,
111
+ /(^|\.)push\.services\.mozilla\.com$/,
112
+ /(^|\.)notify\.windows\.com$/,
113
+ /(^|\.)push\.apple\.com$/,
114
+ ];
115
+
116
+ export function validateSubscription(subscription) {
117
+ if (subscription === null || typeof subscription !== 'object') throw new Error('subscription must be an object');
118
+ if (typeof subscription.endpoint !== 'string') throw new Error('subscription endpoint is missing');
119
+ let url;
120
+ try { url = new URL(subscription.endpoint); } catch { throw new Error('subscription endpoint is not a valid URL'); }
121
+ if (url.protocol !== 'https:') throw new Error('subscription endpoint must be an https URL');
122
+ if (url.port !== '' || url.username !== '' || url.password !== '' || !PUSH_HOSTS.some((host) => host.test(url.hostname))) {
123
+ throw new Error(`${url.host} is not a browser push service`);
124
+ }
125
+ const keys = subscription.keys;
126
+ if (keys === null || typeof keys !== 'object' || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') {
127
+ throw new Error('subscription keys.p256dh and keys.auth are required');
128
+ }
129
+ const p256dh = decode(keys.p256dh);
130
+ if (p256dh.length !== 65 || p256dh[0] !== 0x04) throw new Error('subscription keys.p256dh must be a 65-byte uncompressed P-256 public key');
131
+ if (decode(keys.auth).length !== 16) throw new Error('subscription keys.auth must be 16 bytes');
132
+ return { endpoint: subscription.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } };
133
+ }
134
+
135
+ /** push.json ở dataDir — VAPID sinh lần đầu, subscriptions + prefs mỗi user. */
136
+ export class PushStore {
137
+ constructor(directory) {
138
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
139
+ this.file = join(directory, 'push.json');
140
+ if (existsSync(this.file)) {
141
+ const loaded = JSON.parse(readFileSync(this.file, 'utf8'));
142
+ this.data = { version: 1, vapid: loaded.vapid ?? generateVapidKeys(), subscriptions: loaded.subscriptions ?? [], prefs: loaded.prefs ?? {} };
143
+ if (loaded.vapid === undefined) this.save();
144
+ } else {
145
+ this.data = { version: 1, vapid: generateVapidKeys(), subscriptions: [], prefs: {} };
146
+ this.save();
147
+ }
148
+ }
149
+ get publicKey() { return this.data.vapid.publicKey; }
150
+ get vapid() { return { ...this.data.vapid }; }
151
+ subscriptions(userId) {
152
+ return this.data.subscriptions.filter((s) => userId === undefined || s.userId === userId).map((s) => ({ ...s }));
153
+ }
154
+ subscribe(userId, subscription, userAgent) {
155
+ const clean = validateSubscription(subscription);
156
+ const stored = { ...clean, userId, createdAt: Date.now() };
157
+ if (userAgent !== undefined && userAgent !== '') stored.userAgent = String(userAgent).slice(0, 300);
158
+ const others = this.data.subscriptions.filter((s) => s.endpoint !== clean.endpoint);
159
+ const theirs = others.filter((s) => s.userId === userId);
160
+ const dropped = new Set(theirs.slice(0, Math.max(0, theirs.length - (MAX_DEVICES - 1))));
161
+ this.data.subscriptions = [...others.filter((s) => !dropped.has(s)), stored];
162
+ this.save();
163
+ }
164
+ unsubscribe(endpoint, userId) {
165
+ const before = this.data.subscriptions.length;
166
+ this.data.subscriptions = this.data.subscriptions.filter(
167
+ (s) => s.endpoint !== endpoint || (userId !== undefined && s.userId === userId),
168
+ );
169
+ if (this.data.subscriptions.length === before) return false;
170
+ this.save();
171
+ return true;
172
+ }
173
+ prefs(userId) { return { ...DEFAULT_PREFS, ...this.data.prefs[userId] }; }
174
+ setPrefs(userId, patch) {
175
+ const stored = { ...this.data.prefs[userId] };
176
+ for (const name of Object.keys(DEFAULT_PREFS)) {
177
+ if (typeof patch[name] === 'boolean') stored[name] = patch[name];
178
+ }
179
+ this.data.prefs[userId] = stored;
180
+ this.save();
181
+ return this.prefs(userId);
182
+ }
183
+ /** Gửi tới mọi subscription của user; bỏ subscription chết (404/410). Không throw; trả số 2xx. */
184
+ async notify(userId, message, options = {}) {
185
+ const targets = this.subscriptions(userId);
186
+ if (targets.length === 0) return 0;
187
+ const vapid = this.vapid;
188
+ const results = await Promise.allSettled(targets.map((sub) => sendPush(sub, message, vapid, options)));
189
+ let accepted = 0;
190
+ const gone = new Set();
191
+ results.forEach((r, index) => {
192
+ if (r.status !== 'fulfilled') return;
193
+ if (r.value >= 200 && r.value < 300) accepted++;
194
+ else if (r.value === 404 || r.value === 410) gone.add(targets[index]?.endpoint ?? '');
195
+ });
196
+ if (gone.size > 0) {
197
+ try {
198
+ this.data.subscriptions = this.data.subscriptions.filter((s) => !(s.userId === userId && gone.has(s.endpoint)));
199
+ this.save();
200
+ } catch { /* lưu fail — thử lại lần tới */ }
201
+ }
202
+ return accepted;
203
+ }
204
+ save() {
205
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
206
+ const temp = `${this.file}.${process.pid}.tmp`;
207
+ writeFileSync(temp, `${JSON.stringify(this.data, null, '\t')}\n`, { mode: 0o600 });
208
+ renameSync(temp, this.file);
209
+ chmodSync(this.file, 0o600);
210
+ }
211
+ }
@@ -15,6 +15,7 @@ import { readFile } from 'node:fs/promises';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import process from 'node:process';
17
17
  import { COOKIE, makePocketAdapter } from './pocket-adapter.mjs';
18
+ import { PushStore } from './push.mjs';
18
19
 
19
20
  // UI pocket (clone từ TannerMidd/pi-pocket, MIT) nằm ở public/ — serve tĩnh
20
21
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -110,7 +111,7 @@ async function verifyDeviceToken(token) {
110
111
  }
111
112
  const authorized = async (req, url) => {
112
113
  if (url.pathname === '/api/pair/start') return true; // public — tự vệ bằng code + rate-limit daemon
113
- 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
114
115
  const header = req.headers.authorization ?? '';
115
116
  const bearer = header.startsWith('Bearer ') ? header.slice(7) : null;
116
117
  const qToken = url.searchParams.get('token');
@@ -122,7 +123,8 @@ const authorized = async (req, url) => {
122
123
  };
123
124
 
124
125
  // ---- Events fan-out cho pocket adapter: một socket dài, events.update của daemon
125
- // báo mọi client SSE pocket làm mới (pull-on-push). ----
126
+ // báo mọi client SSE pocket làm mới (pull-on-push). Socket sống độc lập SSE client —
127
+ // push notification phải nổ khi KHÔNG ai mở web, nên tạo ngay từ boot + tự nối lại. ----
126
128
  const eventListeners = new Set();
127
129
  const watchedConvs = new Set();
128
130
  let eventsSocket = null;
@@ -145,18 +147,27 @@ async function ensureEventsSocket(cid) {
145
147
  const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
146
148
  try {
147
149
  const msg = JSON.parse(line);
148
- if (msg.method === 'events.update') for (const fn of eventListeners) fn(msg.params);
150
+ if (msg.method === 'events.update') {
151
+ for (const fn of eventListeners) fn(msg.params);
152
+ pushApprovalWatch(); // push "needs approval" — kể cả khi tab đóng
153
+ pushDoneWatch(); // push "bom finished" khi conv busy→idle
154
+ }
149
155
  } catch { /* dòng chưa trọn */ }
150
156
  }
151
157
  });
152
- conn.on('close', () => { eventsSocket = null; watchedConvs.clear(); });
153
- conn.on('error', () => { eventsSocket = null; });
158
+ conn.on('close', () => { eventsSocket = null; watchedConvs.clear(); scheduleEventsReconnect(); });
159
+ conn.on('error', () => { eventsSocket = null; scheduleEventsReconnect(); });
154
160
  conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: {} }) + '\n');
155
161
  if (Number.isInteger(cid) && cid > 0) {
156
162
  watchedConvs.add(cid);
157
163
  conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: { conversationId: cid } }) + '\n');
158
164
  }
159
165
  }
166
+ let reconnectTimer = null;
167
+ function scheduleEventsReconnect() {
168
+ if (reconnectTimer) return;
169
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; ensureEventsSocket().catch(() => scheduleEventsReconnect()); }, 2000);
170
+ }
160
171
  rpc.__subscribeEvents = async (cid, onChange) => {
161
172
  await ensureEventsSocket(cid);
162
173
  eventListeners.add(onChange);
@@ -166,7 +177,108 @@ rpc.__subscribeEvents = async (cid, onChange) => {
166
177
  // ---- pocket wire (cookie pocket_auth — sign-in qua /login?token= sau khi pair) ----
167
178
  const cookieToken = (req) => (req.headers.cookie ?? '').match(new RegExp(`${COOKIE}=([^;]+)`))?.[1] ?? null;
168
179
  const pocketCookieOk = async (req) => { const t = cookieToken(req); return t && (TOKEN ? t === TOKEN || await verifyDeviceToken(t) : await verifyDeviceToken(t)); };
169
- const pocketRoute = makePocketAdapter({ rpc, checkToken: async (t) => (TOKEN ? t === TOKEN : false) || (await verifyDeviceToken(t)) });
180
+
181
+ // ---- Web Push (port pi-pocket: RFC 8291/8292/8030, không dependency — push.mjs) ----
182
+ // push.json ở dataDir daemon (~/.local/state/bom-pi) — VAPID keys ổn định qua restart,
183
+ // subscriptions + prefs mỗi user. pushApprovalWatch() theo dõi approval mới qua events.update
184
+ // và bắn notification "bom needs approval" tới mọi thiết bị đã subscribe (kể cả tab đóng). ----
185
+ const DATA_DIR = resolve(process.env.BOM_PI_STATE_DIR || process.env.XDG_STATE_HOME || join(process.env.HOME ?? '/home', '.local', 'state'), 'bom-pi');
186
+ let pushStore = null;
187
+ try { pushStore = new PushStore(DATA_DIR); } catch (e) { console.error(`push disabled: ${e.message}`); }
188
+ const PUSH_USER = 'bom-owner';
189
+ let knownApprovalIds = null; // null = chưa nạp mốc — lần đầu không bắn loạt approval cũ
190
+ let approvalWatchTimer = null;
191
+ function pushApprovalWatch() {
192
+ if (!pushStore || approvalWatchTimer) return;
193
+ approvalWatchTimer = setTimeout(() => {
194
+ approvalWatchTimer = null;
195
+ void (async () => {
196
+ try {
197
+ if (pushStore.subscriptions(PUSH_USER).length === 0) { knownApprovalIds = null; return; } // chưa ai subscribe — bỏ qua
198
+ const pending = (await rpc('approvals.list')).pending ?? [];
199
+ const ids = new Set(pending.map((a) => String(a.id)));
200
+ if (knownApprovalIds === null) { knownApprovalIds = ids; return; }
201
+ const fresh = pending.filter((a) => !knownApprovalIds.has(String(a.id)));
202
+ knownApprovalIds = ids;
203
+ if (fresh.length === 0 || !pushStore.prefs(PUSH_USER).approval) return;
204
+ for (const a of fresh) {
205
+ // Khuôn alerts.ts của pocket: Allow từ notification chỉ khi cả call ngắn + không ký tự ngụy trang
206
+ const subject = String(a.command ?? a.summary ?? '');
207
+ const call = `${a.tool ?? 'call'}: ${subject}`.slice(0, 120);
208
+ const shown = call.length <= 120 && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(subject);
209
+ void pushStore.notify(PUSH_USER, {
210
+ title: 'bom needs approval',
211
+ body: call || 'A tool call is waiting for you.',
212
+ url: `/s/${Number(a.conversationId ?? 0)}`,
213
+ tag: `approval-${a.id}`,
214
+ approval: { id: String(a.id), allow: shown },
215
+ }, { urgency: 'high' })
216
+ .then((sent) => console.error(`push approval ${String(a.id).slice(0, 24)} → ${sent} thiết bị`))
217
+ .catch((e) => console.error(`push approval ${String(a.id).slice(0, 24)} lỗi: ${e.message}`));
218
+ }
219
+ } catch { /* daemon bận — events.update kế tiếp thử lại */ }
220
+ })();
221
+ }, 500);
222
+ }
223
+
224
+ const pocketRoute = makePocketAdapter({ rpc, checkToken: async (t) => (TOKEN ? t === TOKEN : false) || (await verifyDeviceToken(t)), push: pushStore ? { store: pushStore, userId: PUSH_USER } : null });
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
+ }
170
282
 
171
283
  // ---- HTML (mobile-first, không framework) ----
172
284
  const HTML = `<!doctype html>
@@ -593,18 +705,6 @@ async function go(){
593
705
  }
594
706
  </script></body></html>`;
595
707
 
596
- // ---- PWA (M8): install màn hình chính + notification approvals ----
597
- const MANIFEST = {
598
- name: 'bom', short_name: 'bom', start_url: '/', display: 'standalone',
599
- background_color: '#101418', theme_color: '#101418',
600
- icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
601
- };
602
- const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#101418"/><text x="50" y="68" font-size="56" text-anchor="middle" fill="#58a6ff" font-family="monospace" font-weight="bold">b</text></svg>`;
603
- const SW_JS = `// bom service worker — network-first, không cache (app luôn tươi; SW chỉ để installable PWA)
604
- self.addEventListener('install', () => self.skipWaiting());
605
- self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
606
- self.addEventListener('fetch', () => {});`;
607
-
608
708
  const http = createHttpServer(async (req, res) => {
609
709
  const url = new URL(req.url, 'http://x');
610
710
  if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
@@ -626,19 +726,9 @@ const http = createHttpServer(async (req, res) => {
626
726
  try { return json2(res, 200, await rpc('pair.poll', { enrollId: p.enrollId })); }
627
727
  catch (e) { return json2(res, 404, { error: e.message.replace(/^\d+: /, '') }); }
628
728
  }
629
- if (req.method === 'GET' && url.pathname === '/manifest.webmanifest') {
630
- res.writeHead(200, { 'content-type': 'application/manifest+json', ...CORS });
631
- return res.end(JSON.stringify(MANIFEST));
632
- }
633
- if (req.method === 'GET' && url.pathname === '/icon.svg') {
634
- res.writeHead(200, { 'content-type': 'image/svg+xml', ...CORS });
635
- return res.end(ICON_SVG);
636
- }
637
- if (req.method === 'GET' && url.pathname === '/sw.js') {
638
- res.writeHead(200, { 'content-type': 'application/javascript', ...CORS });
639
- return res.end(SW_JS);
640
- }
641
729
  // ---- UI pocket (public/): shell + module + vendor tĩnh, KHÔNG cần token ----
730
+ // serveStatic phục vụ cả sw.js/manifest/icon của pocket (public/) — không còn hằng
731
+ // SW cũ: sw.js pocket có push handler (notification từ push service).
642
732
  if (req.method === 'GET' && await serveStatic(res, url)) return;
643
733
  // ---- /login?token= : sign-in pocket UI bằng device token (sau pair) hoặc master ----
644
734
  if (req.method === 'GET' && url.pathname === '/login') {
@@ -657,6 +747,17 @@ const http = createHttpServer(async (req, res) => {
657
747
  }
658
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`' })); }
659
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
+
660
761
  if (req.method === 'GET' && url.pathname === '/') {
661
762
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...CORS });
662
763
  return res.end(HTML);
@@ -770,5 +871,11 @@ const http = createHttpServer(async (req, res) => {
770
871
  });
771
872
 
772
873
  http.listen(PORT, LISTEN, () => {
773
- console.error(`bom-web listening http://${LISTEN}:${PORT} — bom-core at ${socketPath}${TOKEN ? ' — token required' : ' — NO TOKEN (loopback only)'}`);
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'}`);
875
+ // Socket events sống từ boot: push approval phải nổ khi không ai mở web
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?.();
774
881
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bom-pi",
3
- "version": "0.0.2",
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": {
@@ -17,6 +17,7 @@
17
17
  "packages/cli/templates",
18
18
  "apps/web/server.mjs",
19
19
  "apps/web/pocket-adapter.mjs",
20
+ "apps/web/push.mjs",
20
21
  "apps/web/public",
21
22
  "systemd",
22
23
  "README.md"
@@ -27,7 +27,36 @@ 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
- function rpc(method, params) {
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
+ // Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
36
+ // thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
37
+ function unreachable(e) {
38
+ const head = `cannot reach bom-core at ${socketPath}: ${e.message}`;
39
+ if (process.platform === 'win32') {
40
+ return `${head}
41
+
42
+ bom-core chạy trên máy chủ Linux (unix socket + systemd) — CLI chưa chạy được trên Windows.
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`;
49
+ }
50
+ return `${head}
51
+
52
+ daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
53
+ chưa cài daemon?: bom-pi setup
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`;
57
+ }
58
+
59
+ function rpcSocket(method, params) {
31
60
  return new Promise((done, fail) => {
32
61
  const conn = connect(socketPath);
33
62
  let buf = '';
@@ -57,12 +86,25 @@ function rpc(method, params) {
57
86
  });
58
87
  // Daemon chết giữa chừng / đóng connection không trả lời
59
88
  conn.on('close', () => settle(fail, new Error('bom-core closed the connection without a reply')));
60
- conn.on('error', (e) => settle(fail, new Error(`cannot reach bom-core at ${socketPath}: ${e.message}`)));
89
+ conn.on('error', (e) => settle(fail, new Error(unreachable(e))));
61
90
  });
62
91
  }
63
92
 
64
93
  const pretty = (o) => console.log(JSON.stringify(o, null, 2));
65
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
+
66
108
  // Kiểm protocol sau hello — mọi lệnh đều chạy qua đây
67
109
  function checkProtocol(hello) {
68
110
  if (typeof hello?.protocol === 'number' && hello.protocol > CLIENT_PROTOCOL) {
@@ -74,6 +116,10 @@ function checkProtocol(hello) {
74
116
 
75
117
  // ---- watch: mở 1 connection, subscribe, in transcript live đến khi Ctrl+C ----
76
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
+ }
77
123
  const conn = connect(socketPath);
78
124
  let gotReply = false;
79
125
  conn.on('connect', () => {
@@ -99,7 +145,7 @@ function watch() {
99
145
  }
100
146
  }
101
147
  });
102
- conn.on('error', (e) => { console.error(`cannot reach bom-core at ${socketPath}: ${e.message}`); process.exit(1); });
148
+ conn.on('error', (e) => { console.error(unreachable(e)); process.exit(1); });
103
149
  conn.on('close', () => { if (!gotReply) { console.error('bom-core closed the connection without a reply'); process.exit(1); } process.exit(0); });
104
150
  }
105
151
  const printEntry = (e) => console.log(`[${e.kind}]${e.role ? ` ${e.role}:` : ''} ${e.text ?? ''}`);
@@ -71,6 +71,12 @@ async function shutdown(reason) {
71
71
  if (closing) return;
72
72
  closing = true;
73
73
  log('đóng:', reason);
74
+ // Turn đang chờ approval không bao giờ settle ⇒ Session.close kẹt mãi.
75
+ // Từ chối hết mọi approval treo để turn kết thúc, rồi mới đóng storage.
76
+ for (const [, p] of pendingApprovals) {
77
+ try { p.resolve({ allow: false, reason: 'daemon shutdown' }); } catch { /* resolve rồi */ }
78
+ }
79
+ pendingApprovals.clear();
74
80
  let failed = false;
75
81
  if (heartbeatTimer) clearInterval(heartbeatTimer);
76
82
  if (scheduleTimer) clearInterval(scheduleTimer);
@@ -618,6 +624,10 @@ function clearWatchState(ownerKey) {
618
624
  async function agentStateOf(id) {
619
625
  try { return await harness.snapshot(AgentDoc, id, BACKGROUND_CONTEXT); } catch { return undefined; }
620
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
+ }
621
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)
622
632
  function deriveFactory(cid) {
623
633
  return factories.find((f) => f.conversationId === cid || f.childConversationIds.includes(cid)) ?? null;
@@ -1045,7 +1055,7 @@ let taskRegistry = null; // gán trong boot sau createRegistry (registry là loc
1045
1055
  // ---- Mở Harness (pin exact @earendil-works 1.4.0 — xem DECISIONS.md) ----
1046
1056
  const { Harness } = await import('@earendil-works/pi-durable');
1047
1057
  const { openNodeSqliteStorage } = await import('@earendil-works/pi-durable/storage/sqlite/node');
1048
- const { createRegistry, AgentDoc, defineExtension, wrapTool } = await import('@earendil-works/pi-durable');
1058
+ const { createRegistry, AgentDoc, LiveDoc, defineExtension, wrapTool } = await import('@earendil-works/pi-durable');
1049
1059
  const { CodingTools } = await import('@earendil-works/pi-durable/tools');
1050
1060
  const { NodeExecutionEnv } = await import('@earendil-works/pi-durable/env/node');
1051
1061
  const { ModelRuntime } = await import('@earendil-works/pi-coding-agent');
@@ -1465,6 +1475,7 @@ const methods = {
1465
1475
  model: agentState?.model ?? null,
1466
1476
  cwd: agentState?.cwd ?? null,
1467
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
1468
1479
  });
1469
1480
  }
1470
1481
  return { active: activeConvId, count: out.length, conversations: out };