bom-pi 0.0.5 → 0.0.7

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.
@@ -657,7 +657,7 @@ button{width:100%;background:#1f6feb;color:#fff;border:0;border-radius:8px;paddi
657
657
  function guessName(){
658
658
  const u=navigator.userAgent;
659
659
  const os=/iPad/.test(u)?'iPad':/iPhone/.test(u)?'iPhone':/Android/.test(u)?'Android':/Windows/.test(u)?'Windows':/Mac/.test(u)?'Mac':'Device';
660
- const br=/Edg\//.test(u)?'Edge':/CriOS|Chrome\//.test(u)?'Chrome':/FxiOS|Firefox\//.test(u)?'Firefox':/Safari\//.test(u)?'Safari':'Browser';
660
+ const br=/Edg\\//.test(u)?'Edge':/CriOS|Chrome\\//.test(u)?'Chrome':/FxiOS|Firefox\\//.test(u)?'Firefox':/Safari\\//.test(u)?'Safari':'Browser';
661
661
  return os+' · '+br;
662
662
  }
663
663
  if(!code)return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bom-pi",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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": {
@@ -5,7 +5,7 @@
5
5
  // Mọi lệnh conversation nhận --cid N (mặc định: conversation active của daemon).
6
6
  import { connect } from 'node:net';
7
7
  import { join, resolve } from 'node:path';
8
- import { userInfo } from 'node:os';
8
+ import { userInfo, homedir, hostname } from 'node:os';
9
9
  import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
10
10
  import { execFile } from 'node:child_process';
11
11
  import { promisify } from 'node:util';
@@ -27,10 +27,18 @@ const cid = arg('cid', null); // conversation id cho submit/transcript/reset/wat
27
27
  const cidParams = () => (cid ? { conversationId: Number(cid) } : {});
28
28
  const cmd = process.argv[2] ?? 'status';
29
29
 
30
- // Remote (thiết bị khác — Windows/phone đã pair): BOM_PI_URL=http://host:8791 (+ BOM_PI_TOKEN
31
- // device token bom_… copy sau khi pair qua browser). CLI nói chuyện web /rpc thay unix socket.
32
- const REMOTE_URL = arg('url', process.env.BOM_PI_URL || null);
33
- const REMOTE_TOKEN = arg('token', process.env.BOM_PI_TOKEN || null);
30
+ // Remote (thiết bị khác — Windows/phone): pair MỘT lần bằng 'bom-pi pair <code> --url …'
31
+ // ⇒ CLI tự lưu {url, token} vào ~/.bom-pi.json và dùng mãi (env BOM_PI_URL/BOM_PI_TOKEN
32
+ // vẫn đè lên nếu đặt; --url/--token đè lên tất cả). CLI nói chuyện web /rpc thay unix socket.
33
+ const CONFIG_PATH = join(homedir(), '.bom-pi.json');
34
+ let cliConfig = {};
35
+ try { cliConfig = JSON.parse(await readFile(CONFIG_PATH, 'utf8')); } catch { /* chưa pair bao giờ */ }
36
+ async function saveCliConfig(next) {
37
+ cliConfig = next;
38
+ await writeFile(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
39
+ }
40
+ const REMOTE_URL = arg('url', process.env.BOM_PI_URL || cliConfig.url || null);
41
+ const REMOTE_TOKEN = arg('token', process.env.BOM_PI_TOKEN || cliConfig.token || null);
34
42
 
35
43
  // -v/--version trước mọi thứ — user kiểm bản nào đang chạy (bài học: máy Windows kẹt bản cũ)
36
44
  const VERSION = (await import('../../../package.json', { with: { type: 'json' } })).default.version;
@@ -39,28 +47,25 @@ if (cmd === '-v' || cmd === '--version' || cmd === 'version') {
39
47
  process.exit(0);
40
48
  }
41
49
 
42
- // Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
43
- // thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
50
+ // Không thấy daemon: hướng dẫn theo nơi đang chạy — máy này (chưa start?) hay
51
+ // thiết bị khác (pair remote một lần).
44
52
  function unreachable(e) {
45
- const head = `cannot reach bom-core at ${socketPath}: ${e.message}`;
53
+ const head = `cannot reach bom-core at ${socketPath} (và http://127.0.0.1:8790): ${e.message}`;
46
54
  if (process.platform === 'win32') {
47
55
  return `${head}
48
56
 
49
- bom-core chạy trên máy chủ Linux (unix socket + systemd) — CLI chưa chạy được trên Windows.
50
- Hai đường từ máy khác:
51
- · WEB (đầy đủ nhất): mở http://<ip-máy-chạy-daemon>:8791 (hoặc tunnel https://…) rồi pair
52
- bằng code in ra từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ đó.
53
- · CLI REMOTE: pair qua browser trước, copy device token bom_…, rồi đặt:
54
- set BOM_PI_URL=http://<ip>:8791 & set BOM_PI_TOKEN=bom_…
55
- (PowerShell: $env:BOM_PI_URL=…; $env:BOM_PI_TOKEN=…) rồi chạy bom-pi status`;
57
+ daemon chưa chạy trên máy này. Mở terminal khác và chạy: bom-core
58
+ (giữ cửa sổ mở — daemon HTTP loopback 127.0.0.1:8790; sau này sẽ có service tự khởi động)
59
+ hoặc điều khiển MÁY KHÁC: pair một lần bằng
60
+ bom-pi pair <CODE> --url http://<ip-máy-khác>:8791`;
56
61
  }
57
62
  return `${head}
58
63
 
59
64
  daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
60
65
  chưa cài daemon?: bom-pi setup
61
- ở THIẾT BỊ KHÁC?: CLI có thể nói chuyện daemon từ xa — pair qua browser trước
62
- (http://<ip-máy-chạy-daemon>:8791), copy device token, rồi:
63
- BOM_PI_URL=http://<ip>:8791 BOM_PI_TOKEN=bom_… bom-pi status`;
66
+ chạy tay không systemd?: bom-core
67
+ ở THIẾT BỊ KHÁC?: pair một lần rồi mọi lệnh chạy remote:
68
+ bom-pi pair <CODE> --url http://<ip-máy-chạy-daemon>:8791`;
64
69
  }
65
70
 
66
71
  function rpcSocket(method, params) {
@@ -93,24 +98,76 @@ function rpcSocket(method, params) {
93
98
  });
94
99
  // Daemon chết giữa chừng / đóng connection không trả lời
95
100
  conn.on('close', () => settle(fail, new Error('bom-core closed the connection without a reply')));
96
- conn.on('error', (e) => settle(fail, new Error(unreachable(e))));
101
+ conn.on('error', (e) => {
102
+ const err = new Error(unreachable(e));
103
+ err.code = e.code; // giữ code (ENOENT/ECONNREFUSED) để dispatcher fallback HTTP
104
+ settle(fail, err);
105
+ });
97
106
  });
98
107
  }
99
108
 
109
+ // HTTP loopback (Windows / nơi không có unix socket): cùng /rpc, bearer http.token
110
+ // của daemon trên MÁY NÀY — token tự sinh lúc daemon khởi động, mode 0600.
111
+ const LOCAL_HTTP = `http://127.0.0.1:${arg('port', process.env.BOM_PI_PORT || 8790)}`;
112
+ const dataDir = process.env.BOM_PI_DATA_DIR || join(homedir(), '.local/state/bom-pi');
113
+ const httpTokenPath = join(dataDir, 'http.token');
114
+ let httpToken = null;
115
+ async function localHttpToken() {
116
+ if (httpToken !== null) return httpToken;
117
+ httpToken = (await readFile(httpTokenPath, 'utf8')).trim();
118
+ return httpToken;
119
+ }
120
+ async function rpcHttp(method, params) {
121
+ const token = await localHttpToken(); // ENOENT ⇒ không có daemon HTTP ⇒ lỗi unreachable gốc
122
+ let r;
123
+ try {
124
+ r = await fetch(`${LOCAL_HTTP}/rpc`, {
125
+ method: 'POST',
126
+ headers: { 'content-type': 'application/json', Authorization: `Bearer ${token}` },
127
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
128
+ });
129
+ } catch (e) {
130
+ const err = new Error(`cannot reach bom-core at ${LOCAL_HTTP}: ${e.message}`);
131
+ err.code = e.code;
132
+ throw err;
133
+ }
134
+ const j = await r.json().catch(() => ({}));
135
+ if (!r.ok) throw new Error(`bom-core ${r.status}: ${j?.error?.message ?? r.statusText}`);
136
+ if (j.error) throw new Error(`bom-core error ${j.error.code}: ${j.error.message}`);
137
+ return j.result;
138
+ }
139
+
140
+ // Local: ưu tiên unix socket; socket không có (Windows / chạy không socket) → HTTP loopback
141
+ async function rpcLocal(method, params) {
142
+ try {
143
+ return await rpcSocket(method, params);
144
+ } catch (e) {
145
+ if (e?.code === 'ENOENT' || e?.code === 'ECONNREFUSED') {
146
+ try { return await rpcHttp(method, params); } catch { /* rơi xuống lỗi socket gốc cho đúng thông điệp */ }
147
+ }
148
+ throw e;
149
+ }
150
+ }
151
+
100
152
  const pretty = (o) => console.log(JSON.stringify(o, null, 2));
101
153
 
102
154
  // Dispatcher: remote (/rpc qua HTTP) khi BOM_PI_URL đặt — thiết bị khác không có unix socket
103
155
  async function rpcRemote(method, params) {
104
- const r = await fetch(`${REMOTE_URL.replace(/\/+$/, '')}/rpc`, {
105
- method: 'POST',
106
- headers: { 'content-type': 'application/json', ...(REMOTE_TOKEN ? { Authorization: `Bearer ${REMOTE_TOKEN}` } : {}) },
107
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
108
- });
156
+ let r;
157
+ try {
158
+ r = await fetch(`${REMOTE_URL.replace(/\/+$/, '')}/rpc`, {
159
+ method: 'POST',
160
+ headers: { 'content-type': 'application/json', ...(REMOTE_TOKEN ? { Authorization: `Bearer ${REMOTE_TOKEN}` } : {}) },
161
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
162
+ });
163
+ } catch (e) {
164
+ throw new Error(`không nối được ${REMOTE_URL}: ${e.message} — kiểm tra URL và web daemon (bom-web) đang chạy ở đó`);
165
+ }
109
166
  const j = await r.json().catch(() => ({}));
110
167
  if (!r.ok) throw new Error(`bom-web ${REMOTE_URL} ${r.status}: ${j?.error?.message ?? r.statusText}`);
111
168
  return j;
112
169
  }
113
- const rpc = REMOTE_URL ? rpcRemote : rpcSocket;
170
+ const rpc = REMOTE_URL ? rpcRemote : rpcLocal;
114
171
 
115
172
  // Kiểm protocol sau hello — mọi lệnh đều chạy qua đây
116
173
  function checkProtocol(hello) {
@@ -124,11 +181,32 @@ function checkProtocol(hello) {
124
181
  // ---- watch: mở 1 connection, subscribe, in transcript live đến khi Ctrl+C ----
125
182
  function watch() {
126
183
  if (REMOTE_URL) {
127
- console.error('watch cần kết nối socket local — từ thiết bị khác hãy mở web (transcript live có sẵn trong UI)');
184
+ console.error('watch cần kết nối local — từ thiết bị khác hãy mở web (transcript live có sẵn trong UI)');
128
185
  process.exit(64);
129
186
  }
130
- const conn = connect(socketPath);
131
187
  let gotReply = false;
188
+ const onMsg = (msg) => {
189
+ if (msg.id === 1 && !gotReply) {
190
+ gotReply = true;
191
+ if (msg.error) { console.error(`bom-core error ${msg.error.code}: ${msg.error.message}`); process.exit(1); }
192
+ const { conversationId, seq, snapshot } = msg.result;
193
+ console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
194
+ for (const e of snapshot) printEntry(e);
195
+ } else if (msg.id === null && msg.result && !gotReply) {
196
+ // dòng result đầu của stream /events (cùng shape reply events.subscribe)
197
+ gotReply = true;
198
+ const { conversationId, seq, snapshot } = msg.result;
199
+ console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
200
+ for (const e of snapshot) printEntry(e);
201
+ } else if (msg.method === 'events.update') {
202
+ const { conversationId, seq, entries } = msg.params;
203
+ console.error(`--- ${conversationId} seq ${seq}`);
204
+ for (const e of entries) printEntry(e);
205
+ } else if (msg.method === 'events.heartbeat') {
206
+ console.error(`· heartbeat seq ${msg.params.seq}`);
207
+ }
208
+ };
209
+ const conn = connect(socketPath);
132
210
  conn.on('connect', () => {
133
211
  conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: cidParams() }) + '\n');
134
212
  });
@@ -137,24 +215,39 @@ function watch() {
137
215
  if (!raw.trim()) continue;
138
216
  let msg;
139
217
  try { msg = JSON.parse(raw); } catch { continue; }
140
- if (msg.id === 1 && !gotReply) {
141
- gotReply = true;
142
- if (msg.error) { console.error(`bom-core error ${msg.error.code}: ${msg.error.message}`); process.exit(1); }
143
- const { conversationId, seq, snapshot } = msg.result;
144
- console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
145
- for (const e of snapshot) printEntry(e);
146
- } else if (msg.method === 'events.update') {
147
- const { conversationId, seq, entries } = msg.params;
148
- console.error(`--- ${conversationId} seq ${seq}`);
149
- for (const e of entries) printEntry(e);
150
- } else if (msg.method === 'events.heartbeat') {
151
- console.error(`· heartbeat seq ${msg.params.seq}`);
152
- }
218
+ onMsg(msg);
153
219
  }
154
220
  });
155
- conn.on('error', (e) => { console.error(unreachable(e)); process.exit(1); });
221
+ conn.on('error', (e) => {
222
+ if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') { watchHttp(onMsg, () => gotReply); return; }
223
+ console.error(unreachable(e)); process.exit(1);
224
+ });
156
225
  conn.on('close', () => { if (!gotReply) { console.error('bom-core closed the connection without a reply'); process.exit(1); } process.exit(0); });
157
226
  }
227
+
228
+ // Windows / không unix socket: stream /events của HTTP loopback (NDJSON, cùng grammar socket)
229
+ async function watchHttp(onMsg, gotReply) {
230
+ let token;
231
+ try { token = await localHttpToken(); } catch { console.error(unreachable(new Error('no socket, no http.token'))); process.exit(1); }
232
+ const q = cid ? `?conversationId=${encodeURIComponent(cid)}` : '';
233
+ let res;
234
+ try { res = await fetch(`${LOCAL_HTTP}/events${q}`, { headers: { Authorization: `Bearer ${token}` } }); }
235
+ catch (e) { console.error(unreachable(e)); process.exit(1); }
236
+ if (!res.ok) { console.error(`bom-core ${res.status}: watch qua ${LOCAL_HTTP}/events thất bại`); process.exit(1); }
237
+ let buf = '';
238
+ for await (const chunk of res.body) {
239
+ buf += chunk.toString('utf8');
240
+ let nl;
241
+ while ((nl = buf.indexOf('\n')) >= 0) {
242
+ const line = buf.slice(0, nl).trim();
243
+ buf = buf.slice(nl + 1);
244
+ if (!line) continue;
245
+ try { onMsg(JSON.parse(line)); } catch { /* dòng lệch — bỏ */ }
246
+ }
247
+ }
248
+ if (!gotReply()) { console.error('stream /events đóng không có reply'); process.exit(1); }
249
+ process.exit(0);
250
+ }
158
251
  const printEntry = (e) => console.log(`[${e.kind}]${e.role ? ` ${e.role}:` : ''} ${e.text ?? ''}`);
159
252
 
160
253
  const t0 = Date.now();
@@ -317,7 +410,45 @@ try {
317
410
  // M8 + security: pairing 2 CHIỀU — thiết bị nhập code (chiều 1) ⇒ enrollment chờ;
318
411
  // token chỉ cấp khi duyệt tại máy (chiều 2): pair approve <mã ghép>
319
412
  const sub2 = process.argv[3];
320
- if (!sub2) {
413
+ if (/^[A-Z2-7]{8}$/i.test(sub2 ?? '')) {
414
+ // THIẾT BỊ KHÁC pair từ CLI: code in ra từ máy chủ + URL web của máy chủ đó.
415
+ // /api/pair/start + /api/pair/poll công khai (code + operator duyệt tự vệ) —
416
+ // xong CLI TỰ LƯU {url, token} vào ~/.bom-pi.json, các lệnh sau chạy remote luôn.
417
+ const url = arg('url', REMOTE_URL || process.env.BOM_PI_URL || cliConfig.url || null);
418
+ if (!url) {
419
+ console.error('usage: bom-pi pair <code> --url http://<ip-máy-chủ>:8791 [--name tên-máy]\n (URL là máy CHẠY DAEMON — in từ lệnh \'bom-pi pair\' trên máy đó, vd https://bom.example.io.vn hoặc http://192.168.1.4:8791)');
420
+ process.exit(64);
421
+ }
422
+ const base = url.replace(/\/+$/, '');
423
+ const name = arg('name', `${hostname() || 'device'} (cli)`);
424
+ const post = async (path, body) => {
425
+ const r = await fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
426
+ const j = await r.json().catch(() => ({}));
427
+ if (!r.ok) throw new Error(`${path} ${r.status}: ${j?.error ?? r.statusText}`);
428
+ return j;
429
+ };
430
+ const st = await post('/api/pair/start', { code: sub2.toUpperCase(), name });
431
+ console.log(`đã gửi — chờ duyệt tại MÁY CHỦ. Ghép mã: ${st.displayId}`);
432
+ console.log(`(trên máy chủ: bom-pi pair approve ${st.displayId})`);
433
+ const t0 = Date.now();
434
+ for (;;) {
435
+ await new Promise((s) => setTimeout(s, st.pollAfterMs || 1500));
436
+ const p = await post('/api/pair/poll', { enrollId: st.enrollId });
437
+ if (p.status === 'approved') {
438
+ await saveCliConfig({ url: base, token: p.deviceToken, deviceId: p.deviceId, pairedAt: Date.now() });
439
+ console.log(`\nPAIRED ✓ — đã lưu ${CONFIG_PATH}`);
440
+ console.log(`mọi lệnh giờ tự chạy tới ${base}: bom-pi status / sessions / submit …`);
441
+ console.log(`(gỡ: bom-pi unpair)`);
442
+ process.exit(0);
443
+ }
444
+ if (p.status === 'rejected') { console.error('từ chối bởi operator tại máy chủ.'); process.exit(1); }
445
+ if (Date.now() - t0 > 5 * 60 * 1000) { console.error('hết thời gian chờ duyệt (5 phút) — chạy lại.'); process.exit(1); }
446
+ process.stdout.write('.');
447
+ }
448
+ } else if (!sub2 && REMOTE_URL) {
449
+ console.error(`máy này đã pair remote tới ${REMOTE_URL} (config ${CONFIG_PATH}).\n 'bom-pi pair' (in code/QR) là lệnh chạy TRÊN MÁY CHỦ — chạy đó, hoặc gỡ remote: bom-pi unpair.`);
450
+ process.exit(64);
451
+ } else if (!sub2) {
321
452
  const r = await rpc('pair.code');
322
453
  const nets = await import('node:os');
323
454
  const lan = Object.values(nets.networkInterfaces() ?? {}).flat()
@@ -361,7 +492,24 @@ try {
361
492
  console.log(`${d.revokedAtMs ? '○' : '●'} ${d.id} ${(d.name ?? '?').padEnd(18)} ${d.revokedAtMs ? 'revoked ' + new Date(d.revokedAtMs).toLocaleString() : 'paired ' + when} hash ${d.tokenHash8}`);
362
493
  }
363
494
  console.error(`# ${r.devices.filter((d) => !d.revokedAtMs).length} đang sống — thu hồi: bom-pi device rm <id>`);
495
+ } else if (cmd === 'unpair') {
496
+ // Gỡ remote khỏi máy này: thu hồi device TẠI MÁY CHỦ (best-effort) rồi xoá config local
497
+ if (!cliConfig.url || !cliConfig.token) { console.error('# máy này chưa pair remote (không có config)'); process.exit(0); }
498
+ try {
499
+ await rpcRemote('devices.remove', { id: cliConfig.deviceId });
500
+ console.log('đã thu hồi device tại máy chủ.');
501
+ } catch (e) { console.error(`# không thu hồi được tại máy chủ (${e.message}) — xoá config local thôi.`); }
502
+ await saveCliConfig({});
503
+ console.log(`đã xoá ${CONFIG_PATH} — CLI quay về local socket.`);
364
504
  } else if (cmd === 'setup') {
505
+ if (process.platform === 'win32') {
506
+ // Windows không có systemd — daemon chạy foreground; service tự khởi động là việc sau
507
+ console.log('Windows: mở terminal riêng và chạy bom-core (daemon HTTP 127.0.0.1:8790, giữ mở)');
508
+ console.log('CLI máy này dùng được ngay sau đó: bom-pi status');
509
+ console.log('Web UI máy này (tuỳ chọn): bom-web --port 8791 rồi mở http://127.0.0.1:8791');
510
+ console.log('Điều khiển máy KHÁC: bom-pi pair <CODE> --url http://<ip-máy-khác>:8791');
511
+ process.exit(0);
512
+ }
365
513
  // Sinh systemd user units đúng đường dẫn cài (npm global HOẶC working tree) + enable.
366
514
  // root = 3 cấp trên file CLI này: packages/cli/src → gốc package (cả hai kiểu cài đều đúng)
367
515
  const { fileURLToPath } = await import('node:url');
@@ -495,7 +643,7 @@ WantedBy=default.target
495
643
  } else if (cmd === 'shutdown') {
496
644
  pretty(await rpc('shutdown'));
497
645
  } else {
498
- console.error('usage: bom-pi <hello|status|models|agents|sessions|new|use|submit|reset|transcript|approvals|approve|deny|schedules|schedule|unschedule|factory|limits|abort|watch|shutdown|version> [--socket PATH] [--cid N] [--url http://host:8791] [--token bom_…]\n từ thiết bị khác: BOM_PI_URL + BOM_PI_TOKEN (xem bom-pi version)');
646
+ console.error('usage: bom-pi <hello|status|models|agents|sessions|new|use|submit|reset|transcript|approvals|approve|deny|schedules|schedule|unschedule|factory|limits|abort|watch|unpair|version> [--socket PATH] [--cid N] [--url http://host:8791] [--token bom_…]\n thiết bị khác: bom-pi pair <CODE> --url http://host:8791 — pair một lần, CLI tự nhớ (xem bom-pi version)');
499
647
  process.exit(64);
500
648
  }
501
649
  if (cmd !== 'watch') process.exit(0);
@@ -24,10 +24,11 @@
24
24
  // kill giữa turn sẽ chạy lại (restartLimit 5). Tools: CodingTools (read/write/edit/bash).
25
25
 
26
26
  import { connect, createServer } from 'node:net';
27
+ import { createServer as createHttpServer } from 'node:http';
27
28
  import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
28
29
  import { chmodSync } from 'node:fs';
29
30
  import { dirname, join, relative, resolve, sep } from 'node:path';
30
- import { homedir, userInfo } from 'node:os';
31
+ import { homedir, platform, userInfo } from 'node:os';
31
32
  import process from 'node:process';
32
33
  import { execFile as execFileCb } from 'node:child_process';
33
34
  import { promisify } from 'node:util';
@@ -44,7 +45,11 @@ function arg(name, fallback) {
44
45
 
45
46
  const runtimeDir = process.env.XDG_RUNTIME_DIR || `/tmp/run-${userInfo().uid}`;
46
47
  const socketPath = arg('socket', resolve(runtimeDir, 'bom-pi.sock'));
47
- const dataDir = arg('data', resolve(homedir(), '.local/state/bom-pi'));
48
+ const dataDir = arg('data', process.env.BOM_PI_DATA_DIR || resolve(homedir(), '.local/state/bom-pi'));
49
+ // HTTP loopback (bước mesh hóa): nền tảng không có unix socket (Windows) vẫn chạy daemon
50
+ // đầy đủ; Linux chạy song song cả hai. Token tự sinh trên chính máy này (http.token 0600).
51
+ const httpPort = Number(arg('port', process.env.BOM_PI_PORT || 8790));
52
+ const httpTokenPath = join(dataDir, 'http.token');
48
53
  const defaultCwd = arg('cwd', homedir()); // thư mục làm việc mặc định của agent (đổi qua --cwd)
49
54
  const dbPath = resolve(dataDir, 'pi.sqlite');
50
55
  const lockPath = join(dataDir, 'bom-core.lock');
@@ -57,6 +62,7 @@ let models = null; // ModelRuntime (pi-coding-agent) — credentials pi thật
57
62
  let harness = null;
58
63
  let storage = null;
59
64
  let server = null;
65
+ let httpServer = null;
60
66
  let listening = false;
61
67
  let closing = false;
62
68
  let signalWhileOpening = false;
@@ -80,6 +86,10 @@ async function shutdown(reason) {
80
86
  let failed = false;
81
87
  if (heartbeatTimer) clearInterval(heartbeatTimer);
82
88
  if (scheduleTimer) clearInterval(scheduleTimer);
89
+ if (httpServer) {
90
+ httpServer.close(); // stream /events đang mở không được giữ chân exit
91
+ httpServer.closeAllConnections?.();
92
+ }
83
93
  for (const [, w] of watches) {
84
94
  try { await w.handle.stop(); } catch {}
85
95
  }
@@ -2099,6 +2109,12 @@ const methods = {
2099
2109
  const writeMsg = (conn, obj) => conn.write(JSON.stringify(obj) + '\n');
2100
2110
 
2101
2111
  async function handleOne(conn, msg) {
2112
+ // Method đẩy notification phải chạy trên connection dài (socket hoặc /events stream),
2113
+ // không phải POST /rpc một-shot: nhận snapshot rồi mất mọi events sau đó.
2114
+ if (conn.__httpRpc && (String(msg?.method ?? '').startsWith('events.') || msg?.method === 'approvals.subscribe')) {
2115
+ if (msg?.id !== undefined) writeMsg(conn, { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `${msg.method} cần stream dài — dùng GET /events (bearer http.token) hoặc unix socket` } });
2116
+ return;
2117
+ }
2102
2118
  // -32600 invalid request: null / không phải object / thiếu jsonrpc 2.0 / method không phải chuỗi
2103
2119
  if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
2104
2120
  writeMsg(conn, { jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32600, message: 'invalid request' } });
@@ -2156,26 +2172,117 @@ server = createServer((conn) => {
2156
2172
  handle(conn, line).catch((e) => log('lỗi xử lý request:', e?.message ?? e)); // không để reject giết daemon
2157
2173
  }
2158
2174
  });
2159
- conn.on('close', () => {
2160
- approvalConns.delete(conn);
2161
- // bỏ mọi subscribe của connection này; conversation hết subscriber → stop watch
2162
- for (const convId of connConvs.get(conn) ?? []) {
2163
- const w = watches.get(convId);
2164
- if (!w) continue;
2165
- w.conns.delete(conn);
2166
- if (!w.conns.size) {
2167
- w.handle.stop().catch(() => {});
2168
- watches.delete(convId);
2169
- }
2170
- }
2171
- connConvs.delete(conn);
2172
- });
2175
+ conn.on('close', () => dropConn(conn));
2173
2176
  conn.on('error', (e) => log('conn lỗi:', e.message));
2174
2177
  });
2175
2178
 
2176
- await rm(socketPath, { force: true }); // an toàn: probe ở trên đã xác nhận không daemon sống
2177
- await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
2178
- await new Promise((res) => server.listen(socketPath, res));
2179
- chmodSync(socketPath, 0o600); // socket chỉ owner nối được
2180
- listening = true;
2181
- log('nghe tại', socketPath, '— protocol', PROTOCOL, '— pid', process.pid);
2179
+ // Dọn subscribe của một connection đã đi: dùng cho cả socket thật và conn ảo HTTP
2180
+ function dropConn(conn) {
2181
+ approvalConns.delete(conn);
2182
+ // bỏ mọi subscribe của connection này; conversation hết subscriber → stop watch
2183
+ for (const convId of connConvs.get(conn) ?? []) {
2184
+ const w = watches.get(convId);
2185
+ if (!w) continue;
2186
+ w.conns.delete(conn);
2187
+ if (!w.conns.size) {
2188
+ w.handle.stop().catch(() => {});
2189
+ watches.delete(convId);
2190
+ }
2191
+ }
2192
+ connConvs.delete(conn);
2193
+ }
2194
+
2195
+ if (platform() === 'win32') {
2196
+ // Windows không có unix socket — daemon vẫn đầy đủ qua HTTP loopback bên dưới
2197
+ log('windows: bỏ unix socket, chỉ HTTP loopback 127.0.0.1:' + httpPort);
2198
+ } else {
2199
+ await rm(socketPath, { force: true }); // an toàn: probe ở trên đã xác nhận không daemon sống
2200
+ await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
2201
+ await new Promise((res) => server.listen(socketPath, res));
2202
+ chmodSync(socketPath, 0o600); // socket chỉ owner nối được
2203
+ listening = true;
2204
+ log('nghe tại', socketPath, '— protocol', PROTOCOL, '— pid', process.pid);
2205
+ }
2206
+
2207
+ // ---- HTTP loopback: /healthz public, /rpc + /events cần bearer http.token ----
2208
+ // Cùng bảng methods và cùng grammar NDJSON với socket; /events là stream dài
2209
+ // (dòng result của events.subscribe rồi các notification events.update/heartbeat).
2210
+ let httpToken = '';
2211
+ try { httpToken = (await readFile(httpTokenPath, 'utf8')).trim(); } catch { /* sinh mới ngay dưới */ }
2212
+ if (!/^[A-Za-z0-9_-]{43}$/.test(httpToken)) {
2213
+ httpToken = randomBytes(32).toString('base64url');
2214
+ await writeFile(httpTokenPath, httpToken + '\n', { mode: 0o600 });
2215
+ chmodSync(httpTokenPath, 0o600);
2216
+ }
2217
+ const httpAuthed = (req) => {
2218
+ const got = Buffer.from(String(req.headers.authorization ?? ''));
2219
+ const want = Buffer.from(`Bearer ${httpToken}`);
2220
+ return got.length === want.length && timingSafeEqual(got, want);
2221
+ };
2222
+ httpServer = createHttpServer(async (req, res) => {
2223
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
2224
+ if (req.method === 'GET' && url.pathname === '/healthz') {
2225
+ res.writeHead(200, { 'content-type': 'application/json' });
2226
+ res.end(JSON.stringify({ ok: true, name: 'bom-core', version: VERSION, protocol: PROTOCOL }));
2227
+ return;
2228
+ }
2229
+ if (!httpAuthed(req)) {
2230
+ res.writeHead(401, { 'content-type': 'application/json' });
2231
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32000, message: 'unauthorized: Authorization: Bearer <token từ http.token>' } }));
2232
+ return;
2233
+ }
2234
+ if (req.method === 'POST' && url.pathname === '/rpc') {
2235
+ const chunks = []; let size = 0;
2236
+ for await (const c of req) {
2237
+ size += c.length;
2238
+ if (size > 1_048_576) { res.writeHead(413); res.end(); return; } // 1 MB — như ws.maxPayload spec tunnel
2239
+ chunks.push(c);
2240
+ }
2241
+ let msg;
2242
+ try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
2243
+ res.writeHead(200, { 'content-type': 'application/json' });
2244
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
2245
+ return;
2246
+ }
2247
+ if (Array.isArray(msg) && msg.length === 0) {
2248
+ res.writeHead(200, { 'content-type': 'application/json' });
2249
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'invalid request: empty batch' } }));
2250
+ return;
2251
+ }
2252
+ const replies = [];
2253
+ const vconn = { __httpRpc: true, write: (s) => { const line = String(s).trim(); if (line) replies.push(JSON.parse(line)); }, on: () => {} };
2254
+ const items = Array.isArray(msg) ? msg : [msg];
2255
+ for (const item of items) await handleOne(vconn, item);
2256
+ dropConn(vconn);
2257
+ res.writeHead(200, { 'content-type': 'application/json' });
2258
+ res.end(JSON.stringify(Array.isArray(msg) ? replies : replies[0] ?? null));
2259
+ return;
2260
+ }
2261
+ if (req.method === 'GET' && url.pathname === '/events') {
2262
+ res.writeHead(200, { 'content-type': 'application/x-ndjson', 'cache-control': 'no-store' });
2263
+ const vconn = { __httpStream: true, write: (s) => res.write(s), on: () => {} };
2264
+ res.on('close', () => dropConn(vconn));
2265
+ try {
2266
+ const convId = url.searchParams.get('conversationId') ?? undefined;
2267
+ const result = await methods.eventsSubscribe({ conversationId: convId }, { conn: vconn, push: (m, p) => writeMsg(vconn, { jsonrpc: '2.0', method: m, params: p ?? {} }) });
2268
+ res.write(JSON.stringify({ jsonrpc: '2.0', id: null, result }) + '\n');
2269
+ } catch (e) {
2270
+ res.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32000, message: e?.message ?? String(e) } }) + '\n');
2271
+ res.end();
2272
+ }
2273
+ return;
2274
+ }
2275
+ res.writeHead(404, { 'content-type': 'application/json' });
2276
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32601, message: `not found: ${req.method} ${url.pathname}` } }));
2277
+ });
2278
+ try {
2279
+ await new Promise((res, rej) => {
2280
+ httpServer.once('error', rej);
2281
+ httpServer.listen(httpPort, '127.0.0.1', () => { httpServer.off('error', rej); res(); });
2282
+ });
2283
+ } catch (e) {
2284
+ log(`không nghe được HTTP 127.0.0.1:${httpPort}: ${e.code ?? e.message} — cổng bị chiếm? chạy với --port <port khác>`);
2285
+ if (listening) await rm(socketPath, { force: true }); // dọn socket đã mở, không để lại mồ
2286
+ process.exit(1);
2287
+ }
2288
+ log('http loopback: 127.0.0.1:' + httpPort, '(/rpc /events /healthz) — token:', httpTokenPath);