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.
- package/apps/web/pocket-adapter.mjs +34 -4
- package/apps/web/public/manifest.webmanifest +3 -3
- package/apps/web/push.mjs +211 -0
- package/apps/web/server.mjs +138 -31
- package/package.json +2 -1
- package/packages/cli/src/bom-pi.mjs +49 -3
- package/packages/core/src/daemon.mjs +12 -1
|
@@ -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:
|
|
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
|
|
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": "
|
|
3
|
-
"short_name": "
|
|
4
|
-
"description": "
|
|
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
|
+
}
|
package/apps/web/server.mjs
CHANGED
|
@@ -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')
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 };
|