bom-pi 0.0.6 → 0.0.8

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.6",
3
+ "version": "0.0.8",
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": {
@@ -4,10 +4,12 @@
4
4
  // submit <text...> [--model p/m] | reset [handoff...] | transcript | watch | shutdown
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
- import { join, resolve } from 'node:path';
7
+ import { dirname, join, resolve } from 'node:path';
8
8
  import { userInfo, homedir, hostname } from 'node:os';
9
9
  import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
10
- import { execFile } from 'node:child_process';
10
+ import { openSync } from 'node:fs';
11
+ import { execFile, spawn } from 'node:child_process';
12
+ import { fileURLToPath } from 'node:url';
11
13
  import { promisify } from 'node:util';
12
14
  import process from 'node:process';
13
15
 
@@ -27,6 +29,13 @@ const cid = arg('cid', null); // conversation id cho submit/transcript/reset/wat
27
29
  const cidParams = () => (cid ? { conversationId: Number(cid) } : {});
28
30
  const cmd = process.argv[2] ?? 'status';
29
31
 
32
+ // HTTP loopback của daemon TRÊN MÁY NÀY (Windows / không unix socket)
33
+ const LOCAL_HTTP = `http://127.0.0.1:${arg('port', process.env.BOM_PI_PORT || 8790)}`;
34
+ const dataDir = process.env.BOM_PI_DATA_DIR || join(homedir(), '.local/state/bom-pi');
35
+ const httpTokenPath = join(dataDir, 'http.token');
36
+ // Tự khởi động daemon nền khi CLI cần mà chưa có — trừ lệnh tắt/hỏi trạng thái
37
+ const AUTO_START = cmd !== 'shutdown' && !(cmd === 'daemon' && ['stop', 'status', 'log'].includes(process.argv[3]));
38
+
30
39
  // Remote (thiết bị khác — Windows/phone): pair MỘT lần bằng 'bom-pi pair <code> --url …'
31
40
  // ⇒ CLI tự lưu {url, token} vào ~/.bom-pi.json và dùng mãi (env BOM_PI_URL/BOM_PI_TOKEN
32
41
  // vẫn đè lên nếu đặt; --url/--token đè lên tất cả). CLI nói chuyện web /rpc thay unix socket.
@@ -47,23 +56,25 @@ if (cmd === '-v' || cmd === '--version' || cmd === 'version') {
47
56
  process.exit(0);
48
57
  }
49
58
 
50
- // Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
51
- // thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
59
+ // Không thấy daemon: hướng dẫn theo nơi đang chạy — máy này (chưa start?) hay
60
+ // thiết bị khác (pair remote một lần). CLI đã THỬ tự khởi động daemon nền trước khi
61
+ // in cái này — thấy nó nghĩa là auto-start cũng chết (xem log).
52
62
  function unreachable(e) {
53
- const head = `cannot reach bom-core at ${socketPath}: ${e.message}`;
63
+ const head = `cannot reach bom-core at ${socketPath} (và ${LOCAL_HTTP}): ${e.message}`;
54
64
  if (process.platform === 'win32') {
55
65
  return `${head}
56
66
 
57
- bom-core chạy trên máy chủ Linux (unix socket + systemd) — CLI chưa chạy được trên Windows.
58
- Từ máy khác, pair MỘT LẦN bằng CLI (cần code in từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ):
59
- bom-pi pair <CODE> --url http://<ip-máy-chủ>:8791
60
- xong CLI tự nhớ (lưu ~/.bom-pi.json) — mọi lệnh chạy remote: bom-pi status / sessions …
61
- (hoặc dùng WEB đầy đủ: mở http://<ip-máy-chủ>:8791 — pair bằng cùng code đó)`;
67
+ CLI đã thử tự khởi động daemon nền mà không lên — xem log: ${join(dataDir, 'bom-core.log')}
68
+ thử chạy tay để thấy lỗi: bom-core
69
+ hoặc điều khiển MÁY KHÁC: pair một lần bằng
70
+ bom-pi pair <CODE> --url http://<ip-máy-khác>:8791`;
62
71
  }
63
72
  return `${head}
64
73
 
65
74
  daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
66
75
  chưa cài daemon?: bom-pi setup
76
+ chạy tay không systemd?: bom-pi daemon start
77
+ log daemon do CLI khởi động: bom-pi daemon log
67
78
  ở THIẾT BỊ KHÁC?: pair một lần rồi mọi lệnh chạy remote:
68
79
  bom-pi pair <CODE> --url http://<ip-máy-chạy-daemon>:8791`;
69
80
  }
@@ -98,8 +109,103 @@ function rpcSocket(method, params) {
98
109
  });
99
110
  // Daemon chết giữa chừng / đóng connection không trả lời
100
111
  conn.on('close', () => settle(fail, new Error('bom-core closed the connection without a reply')));
101
- conn.on('error', (e) => settle(fail, new Error(unreachable(e))));
112
+ conn.on('error', (e) => {
113
+ const err = new Error(unreachable(e));
114
+ err.code = e.code; // giữ code (ENOENT/ECONNREFUSED) để dispatcher fallback HTTP
115
+ settle(fail, err);
116
+ });
117
+ });
118
+ }
119
+
120
+ // HTTP loopback (Windows / nơi không có unix socket): cùng /rpc, bearer http.token
121
+ // của daemon trên MÁY NÀY — token tự sinh lúc daemon khởi động, mode 0600.
122
+ let httpToken = null;
123
+ async function localHttpToken() {
124
+ if (httpToken !== null) return httpToken;
125
+ httpToken = (await readFile(httpTokenPath, 'utf8')).trim();
126
+ return httpToken;
127
+ }
128
+ async function rpcHttp(method, params) {
129
+ const token = await localHttpToken(); // ENOENT ⇒ không có daemon HTTP ⇒ lỗi unreachable gốc
130
+ let r;
131
+ try {
132
+ r = await fetch(`${LOCAL_HTTP}/rpc`, {
133
+ method: 'POST',
134
+ headers: { 'content-type': 'application/json', Authorization: `Bearer ${token}` },
135
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
136
+ });
137
+ } catch (e) {
138
+ const err = new Error(`cannot reach bom-core at ${LOCAL_HTTP}: ${e.message}`);
139
+ err.code = e.code;
140
+ throw err;
141
+ }
142
+ const j = await r.json().catch(() => ({}));
143
+ if (!r.ok) throw new Error(`bom-core ${r.status}: ${j?.error?.message ?? r.statusText}`);
144
+ if (j.error) throw new Error(`bom-core error ${j.error.code}: ${j.error.message}`);
145
+ return j.result;
146
+ }
147
+
148
+ // ---- Auto-start daemon nền (mô hình "mỗi máy một daemon") ----
149
+ // Bài học log Windows 2026-11-03: daemon foreground chiếm terminal ⇒ Ctrl+C giết daemon
150
+ // giữa chừng ⇒ mọi lệnh sau lỗi. CLI giờ tự đưa daemon lên NỀN (detached, log ra file)
151
+ // khi cần và chưa có — không ai phải giữ terminal.
152
+ const daemonMjsPath = () => resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'packages', 'core', 'src', 'daemon.mjs');
153
+ const daemonLogPath = () => join(dataDir, 'bom-core.log');
154
+ let daemonAliveCache = false;
155
+
156
+ async function daemonAlive() {
157
+ if (daemonAliveCache) return true;
158
+ const sockOk = await new Promise((done) => {
159
+ const c = connect(socketPath);
160
+ c.once('connect', () => { c.destroy(); done(true); });
161
+ c.once('error', () => done(false));
162
+ });
163
+ if (sockOk) { daemonAliveCache = true; return true; }
164
+ try {
165
+ const r = await fetch(`${LOCAL_HTTP}/healthz`, { signal: AbortSignal.timeout(800) });
166
+ daemonAliveCache = r.ok;
167
+ return r.ok;
168
+ } catch { return false; }
169
+ }
170
+
171
+ async function ensureDaemon() {
172
+ if (await daemonAlive()) return true;
173
+ const daemon = daemonMjsPath();
174
+ try { await stat(daemon); } catch { return false; } // không tìm thấy file daemon — cứ báo unreachable
175
+ await mkdir(dataDir, { recursive: true });
176
+ const logFd = openSync(daemonLogPath(), 'a');
177
+ const args = [daemon];
178
+ const portArg = process.argv.indexOf('--port');
179
+ if (portArg >= 0 && process.argv[portArg + 1]) args.push('--port', process.argv[portArg + 1]);
180
+ const child = spawn(process.execPath, args, {
181
+ detached: true, // sống sót sau khi CLI exit — Ctrl+C terminal không đụng được
182
+ stdio: ['ignore', logFd, logFd],
183
+ windowsHide: true,
184
+ env: { ...process.env },
102
185
  });
186
+ child.unref();
187
+ for (let i = 0; i < 80; i++) {
188
+ await new Promise((r) => setTimeout(r, 250)); // mở storage mất vài giây lần đầu
189
+ if (await daemonAlive()) return true;
190
+ }
191
+ return false;
192
+ }
193
+
194
+ // Local: ưu tiên unix socket; socket không có (Windows / chạy không socket) → HTTP loopback
195
+ async function rpcLocal(method, params) {
196
+ try {
197
+ return await rpcSocket(method, params);
198
+ } catch (e) {
199
+ if (e?.code === 'ENOENT' || e?.code === 'ECONNREFUSED') {
200
+ try { return await rpcHttp(method, params); } catch {
201
+ // không socket, không HTTP → tự đưa daemon nền lên rồi thử lại lần cuối
202
+ if (AUTO_START && (await ensureDaemon())) {
203
+ return await rpcHttp(method, params);
204
+ }
205
+ }
206
+ }
207
+ throw e;
208
+ }
103
209
  }
104
210
 
105
211
  const pretty = (o) => console.log(JSON.stringify(o, null, 2));
@@ -120,7 +226,7 @@ async function rpcRemote(method, params) {
120
226
  if (!r.ok) throw new Error(`bom-web ${REMOTE_URL} ${r.status}: ${j?.error?.message ?? r.statusText}`);
121
227
  return j;
122
228
  }
123
- const rpc = REMOTE_URL ? rpcRemote : rpcSocket;
229
+ const rpc = REMOTE_URL ? rpcRemote : rpcLocal;
124
230
 
125
231
  // Kiểm protocol sau hello — mọi lệnh đều chạy qua đây
126
232
  function checkProtocol(hello) {
@@ -134,11 +240,32 @@ function checkProtocol(hello) {
134
240
  // ---- watch: mở 1 connection, subscribe, in transcript live đến khi Ctrl+C ----
135
241
  function watch() {
136
242
  if (REMOTE_URL) {
137
- 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)');
243
+ 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)');
138
244
  process.exit(64);
139
245
  }
140
- const conn = connect(socketPath);
141
246
  let gotReply = false;
247
+ const onMsg = (msg) => {
248
+ if (msg.id === 1 && !gotReply) {
249
+ gotReply = true;
250
+ if (msg.error) { console.error(`bom-core error ${msg.error.code}: ${msg.error.message}`); process.exit(1); }
251
+ const { conversationId, seq, snapshot } = msg.result;
252
+ console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
253
+ for (const e of snapshot) printEntry(e);
254
+ } else if (msg.id === null && msg.result && !gotReply) {
255
+ // dòng result đầu của stream /events (cùng shape reply events.subscribe)
256
+ gotReply = true;
257
+ const { conversationId, seq, snapshot } = msg.result;
258
+ console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
259
+ for (const e of snapshot) printEntry(e);
260
+ } else if (msg.method === 'events.update') {
261
+ const { conversationId, seq, entries } = msg.params;
262
+ console.error(`--- ${conversationId} seq ${seq}`);
263
+ for (const e of entries) printEntry(e);
264
+ } else if (msg.method === 'events.heartbeat') {
265
+ console.error(`· heartbeat seq ${msg.params.seq}`);
266
+ }
267
+ };
268
+ const conn = connect(socketPath);
142
269
  conn.on('connect', () => {
143
270
  conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: cidParams() }) + '\n');
144
271
  });
@@ -147,24 +274,43 @@ function watch() {
147
274
  if (!raw.trim()) continue;
148
275
  let msg;
149
276
  try { msg = JSON.parse(raw); } catch { continue; }
150
- if (msg.id === 1 && !gotReply) {
151
- gotReply = true;
152
- if (msg.error) { console.error(`bom-core error ${msg.error.code}: ${msg.error.message}`); process.exit(1); }
153
- const { conversationId, seq, snapshot } = msg.result;
154
- console.error(`# conversation ${conversationId} @seq ${seq} — Ctrl+C để thoát`);
155
- for (const e of snapshot) printEntry(e);
156
- } else if (msg.method === 'events.update') {
157
- const { conversationId, seq, entries } = msg.params;
158
- console.error(`--- ${conversationId} seq ${seq}`);
159
- for (const e of entries) printEntry(e);
160
- } else if (msg.method === 'events.heartbeat') {
161
- console.error(`· heartbeat seq ${msg.params.seq}`);
162
- }
277
+ onMsg(msg);
163
278
  }
164
279
  });
165
- conn.on('error', (e) => { console.error(unreachable(e)); process.exit(1); });
280
+ conn.on('error', (e) => {
281
+ if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') { watchHttp(onMsg, () => gotReply); return; }
282
+ console.error(unreachable(e)); process.exit(1);
283
+ });
166
284
  conn.on('close', () => { if (!gotReply) { console.error('bom-core closed the connection without a reply'); process.exit(1); } process.exit(0); });
167
285
  }
286
+
287
+ // Windows / không unix socket: stream /events của HTTP loopback (NDJSON, cùng grammar socket)
288
+ async function watchHttp(onMsg, gotReply) {
289
+ let token = null;
290
+ try { token = await localHttpToken(); }
291
+ catch {
292
+ if (AUTO_START && (await ensureDaemon())) { try { token = await localHttpToken(); } catch { /* vẫn không có — báo dưới */ } }
293
+ }
294
+ if (!token) { console.error(unreachable(new Error('no socket, no http.token'))); process.exit(1); }
295
+ const q = cid ? `?conversationId=${encodeURIComponent(cid)}` : '';
296
+ let res;
297
+ try { res = await fetch(`${LOCAL_HTTP}/events${q}`, { headers: { Authorization: `Bearer ${token}` } }); }
298
+ catch (e) { console.error(unreachable(e)); process.exit(1); }
299
+ if (!res.ok) { console.error(`bom-core ${res.status}: watch qua ${LOCAL_HTTP}/events thất bại`); process.exit(1); }
300
+ let buf = '';
301
+ for await (const chunk of res.body) {
302
+ buf += chunk.toString('utf8');
303
+ let nl;
304
+ while ((nl = buf.indexOf('\n')) >= 0) {
305
+ const line = buf.slice(0, nl).trim();
306
+ buf = buf.slice(nl + 1);
307
+ if (!line) continue;
308
+ try { onMsg(JSON.parse(line)); } catch { /* dòng lệch — bỏ */ }
309
+ }
310
+ }
311
+ if (!gotReply()) { console.error('stream /events đóng không có reply'); process.exit(1); }
312
+ process.exit(0);
313
+ }
168
314
  const printEntry = (e) => console.log(`[${e.kind}]${e.role ? ` ${e.role}:` : ''} ${e.text ?? ''}`);
169
315
 
170
316
  const t0 = Date.now();
@@ -366,6 +512,17 @@ try {
366
512
  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.`);
367
513
  process.exit(64);
368
514
  } else if (!sub2) {
515
+ // Link pair cần WEB phục vụ trang /pair — máy này chưa chạy web thì mint code vô ích
516
+ let webUp = false;
517
+ try { const r = await fetch('http://127.0.0.1:8791/', { redirect: 'manual', signal: AbortSignal.timeout(1200) }); webUp = r.status > 0; } catch { /* web không nghe */ }
518
+ if (!webUp) {
519
+ console.error(`máy này chưa chạy web (:8791) — link pair sẽ không mở được ở thiết bị khác.
520
+
521
+ chạy web máy này: bom-web --port 8791 (rồi chạy lại 'bom-pi pair')
522
+ hoặc GHÉP máy này vào máy khác (máy đã chạy daemon + web):
523
+ bom-pi pair <CODE> --url http://<ip-máy-đó>:8791`);
524
+ process.exit(64);
525
+ }
369
526
  const r = await rpc('pair.code');
370
527
  const nets = await import('node:os');
371
528
  const lan = Object.values(nets.networkInterfaces() ?? {}).flat()
@@ -419,10 +576,18 @@ try {
419
576
  await saveCliConfig({});
420
577
  console.log(`đã xoá ${CONFIG_PATH} — CLI quay về local socket.`);
421
578
  } else if (cmd === 'setup') {
579
+ if (process.platform === 'win32') {
580
+ // Windows không có systemd — daemon chạy foreground; service tự khởi động là việc sau
581
+ console.log('Windows: mở terminal riêng và chạy bom-core (daemon HTTP 127.0.0.1:8790, giữ mở)');
582
+ console.log('CLI máy này dùng được ngay sau đó: bom-pi status');
583
+ console.log('Web UI máy này (tuỳ chọn): bom-web --port 8791 rồi mở http://127.0.0.1:8791');
584
+ console.log('Điều khiển máy KHÁC: bom-pi pair <CODE> --url http://<ip-máy-khác>:8791');
585
+ process.exit(0);
586
+ }
422
587
  // Sinh systemd user units đúng đường dẫn cài (npm global HOẶC working tree) + enable.
423
588
  // root = 3 cấp trên file CLI này: packages/cli/src → gốc package (cả hai kiểu cài đều đúng)
424
589
  const { fileURLToPath } = await import('node:url');
425
- const root = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
590
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); // resolve(file,'..') pop filename — đi từ dirname
426
591
  const daemon = resolve(root, 'packages/core/src/daemon.mjs');
427
592
  const web = resolve(root, 'apps/web/server.mjs');
428
593
  for (const f of [daemon, web]) {
@@ -469,6 +634,23 @@ WantedBy=default.target
469
634
  console.log('✓ enabled: bom-core + bom-web (systemd user)');
470
635
  console.log('\nTiếp theo: bom-pi pair → quét QR trên phone → bom-pi pair approve <mã ghép>');
471
636
  console.log('Log: journalctl --user -u bom-core -f');
637
+ } else if (cmd === 'daemon') {
638
+ // bom-pi daemon [start|stop|status|log] — daemon nền do CLI quản (systemd máy chủ tự lo phần mình)
639
+ const sub3 = process.argv[3] ?? 'start';
640
+ if (sub3 === 'start') {
641
+ if (await ensureDaemon()) console.log(`daemon sống — ${LOCAL_HTTP} · log: ${daemonLogPath()}`);
642
+ else { console.error(`không đưa được daemon lên sau 20s — xem log: ${daemonLogPath()}`); process.exit(1); }
643
+ } else if (sub3 === 'stop') {
644
+ try { pretty(await rpc('shutdown')); console.log('đã gửi shutdown — daemon đóng storage rồi exit'); }
645
+ catch (e) { console.error('daemon không chạy (hoặc không tắt được):', e.message); process.exit(1); }
646
+ } else if (sub3 === 'status') {
647
+ console.log(await daemonAlive() ? 'daemon ĐANG chạy' : 'daemon KHÔNG chạy');
648
+ } else if (sub3 === 'log') {
649
+ try { console.log(await readFile(daemonLogPath(), 'utf8')); }
650
+ catch { console.error(`chưa có log CLI (${daemonLogPath()}) — systemd: journalctl --user -u bom-core`); }
651
+ } else {
652
+ console.error('usage: bom-pi daemon [start|stop|status|log]'); process.exit(64);
653
+ }
472
654
  } else if (cmd === 'device') {
473
655
  // bom-pi device rm <id>
474
656
  const sub2 = process.argv[3];
@@ -552,7 +734,7 @@ WantedBy=default.target
552
734
  } else if (cmd === 'shutdown') {
553
735
  pretty(await rpc('shutdown'));
554
736
  } else {
555
- 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)');
737
+ 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|daemon|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)');
556
738
  process.exit(64);
557
739
  }
558
740
  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);