@danieltmn/openbridge 0.6.1 → 0.6.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ Todos los cambios relevantes de OpenBridge. Formato basado en
4
4
  [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
5
5
  [Versionado Semantico](https://semver.org/lang/es/).
6
6
 
7
+ ## [0.6.3] - 2026-09-15
8
+
9
+ ### Agregado
10
+
11
+ - **Sincronizacion casi en tiempo real**: el puente vigila la base de opencode
12
+ (`opencode.db`/`-wal`) y dispara un barrido tras ~8 s de calma, en vez de
13
+ esperar 15 min (que queda como respaldo cada 3 min).
14
+
15
+ ### Corregido
16
+
17
+ - El barrido ya **no se pierde** cuando opencode esta ocupado: se marca pendiente
18
+ y corre al terminar el mensaje (antes se salteaba el tick entero).
19
+ - **Mensajes del TUI en sesiones web**: las sesiones vinculadas a la web ya no
20
+ quedan solo con refresco de tokens; se importan los mensajes nuevos.
21
+ - **Sin duplicados**: el merge deduplica por el id de mensaje de opencode
22
+ (`oc_msg`) y "adopta" el mensaje optimista que publico la web. Paridad en el
23
+ hub Node (`store.sessionImport`) y en el PHP.
24
+
25
+ ## [0.6.2] - 2026-09-14
26
+
27
+ ### Corregido
28
+
29
+ - **Tunel en Windows**: los shims `.cmd` (p. ej. `tmole`) se lanzaban con doble
30
+ cita (`cmd.exe /d /s /c "\"ruta\""`) y fallaban con "no se reconoce como un
31
+ comando". Ahora se usa `windowsVerbatimArguments` y, si `tmole` falla, el
32
+ puente **reintenta con `npx --yes tunnelmole`** (antes solo lo intentaba si
33
+ `tmole` no estaba en el PATH). Timeout del tunel: 85 s.
34
+
7
35
  ## [0.6.1] - 2026-09-14
8
36
 
9
37
  ### Agregado
package/README.md CHANGED
@@ -145,6 +145,16 @@ Ejemplo concreto:
145
145
  El puente remoto solo necesita salida a internet hacia el hub; no abre puertos ni
146
146
  túnel propio. Para que arranque solo en cada PC: `openbridge autostart install`.
147
147
 
148
+ ## Sincronización (opencode es la fuente)
149
+
150
+ La conversación vive en **opencode** (tu PC); la web la refleja. Los mensajes que
151
+ pasás por la web se guardan al instante; los que hacés en el **TUI de opencode**
152
+ se importan por un barrido que ahora corre **casi en tiempo real**: el puente
153
+ vigila la base de opencode (`opencode.db`) y sincroniza a los pocos segundos
154
+ (respaldo cada 3 min). Si la sesión empezó en la web y la seguís en el TUI (o al
155
+ revés), los mensajes nuevos se agregan sin duplicarse: el merge identifica cada
156
+ mensaje por su id de opencode (`oc_msg`).
157
+
148
158
  ## Hub en hosting PHP (URL fija, sin túnel)
149
159
 
150
160
  Además del hub Node, OpenBridge puede correr su hub en un **hosting PHP** (cPanel),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/openbridge",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Tu opencode en el celular, sin hosting: corre la app, el puente y un tunel publico desde tu PC.",
5
5
  "type": "commonjs",
6
6
  "author": "tamnora",
@@ -540,6 +540,7 @@ function streamCli(args, opts, onPartial) {
540
540
  let settled = false;
541
541
  let timer = null;
542
542
  let sessionID = null;
543
+ let assistantMsgID = null;
543
544
  let errorText = '';
544
545
  const texts = [];
545
546
  const reasons = [];
@@ -577,6 +578,7 @@ function streamCli(args, opts, onPartial) {
577
578
  killed: false,
578
579
  canceled: false,
579
580
  sessionID: sessionID,
581
+ assistantMsgID: assistantMsgID,
580
582
  errorText: errorText,
581
583
  }, extra || {});
582
584
  }
@@ -636,6 +638,9 @@ function streamCli(args, opts, onPartial) {
636
638
  if (ev.part && typeof ev.part.text === 'string') {
637
639
  if (ev.type === 'text') texts.push(ev.part.text);
638
640
  else if (ev.type === 'reasoning') reasons.push(ev.part.text);
641
+ // Id del mensaje de opencode (msg_...): identifica la respuesta
642
+ // para que el hub no la duplique al importar del TUI.
643
+ if (typeof ev.messageID === 'string' && ev.messageID) assistantMsgID = ev.messageID;
639
644
  }
640
645
  if (ev.type === 'error') {
641
646
  errorText = errorMessage(ev);
@@ -1369,6 +1374,7 @@ async function exportAndImport(folder, sess, target) {
1369
1374
  ts: ts,
1370
1375
  agent: role === 'assistant' ? lastAgent : (info.agent || ''),
1371
1376
  };
1377
+ if (info.id) out.oc_msg = String(info.id);
1372
1378
  if (reasoning) out.reasoning = reasoning;
1373
1379
  msgs.push(out);
1374
1380
  }
@@ -1447,18 +1453,27 @@ async function sweepTarget(t, opts) {
1447
1453
  if (!sessions.length) continue;
1448
1454
  const fstate = target.folders[folder] || (target.folders[folder] = {});
1449
1455
  for (const s of sessions) {
1450
- if (fstate[s.id] === s.updated) { seen.add(s.id); continue; } // sin cambios desde el último barrido
1456
+ // Sin cambios desde el último barrido (el marcador es el "updated"
1457
+ // de la lista, con precision de minuto). Con `force` (watcher) se
1458
+ // re-exportan las sesiones tocadas en los últimos 30 min para no
1459
+ // perder cambios dentro del mismo minuto.
1460
+ const unchanged = fstate[s.id] === s.updated;
1461
+ const forceRecent = !!(opts && opts.force) && listUpdatedRecent(s.updated, 30 * 60 * 1000);
1462
+ if (unchanged && !forceRecent) { seen.add(s.id); continue; }
1451
1463
  if (seen.has(s.id)) { fstate[s.id] = s.updated; continue; } // ya atendida en esta pasada
1452
1464
  seen.add(s.id);
1453
1465
  if (knownOc.includes(s.id)) {
1454
- // Chat web vinculado: no se importa (sería duplicar), solo se
1455
- // refrescan tokens/costo desde el export de opencode.
1466
+ // Chat web vinculado: el hub ya tiene lo que publicó la web,
1467
+ // pero el TUI puede haber agregado mensajes. Se importa igual
1468
+ // (el merge deduplica por oc_msg) y el hub lo marca `importada`,
1469
+ // con lo que sale de known_oc y sigue sincronizando.
1456
1470
  try {
1457
- await refreshTokens(folder, s, t);
1471
+ await exportAndImport(folder, s, t);
1458
1472
  fstate[s.id] = s.updated;
1459
1473
  saveSyncState();
1474
+ imported++;
1460
1475
  } catch (e) {
1461
- log('aviso: no pude refrescar tokens de ' + s.id + ': ' + e.message);
1476
+ log('aviso: no pude sincronizar ' + s.id + ': ' + e.message);
1462
1477
  }
1463
1478
  continue;
1464
1479
  }
@@ -1496,6 +1511,61 @@ async function sweepTarget(t, opts) {
1496
1511
  return imported;
1497
1512
  }
1498
1513
 
1514
+ // ---------------------------------------------------------------------------
1515
+ // Sincronizacion casi en tiempo real. opencode persiste en su base SQLite
1516
+ // (opencode.db / -wal / -shm); un watcher liviano mira mtime+tamano y dispara
1517
+ // un barrido tras un periodo de calma, en vez de esperar 15 min.
1518
+ // ---------------------------------------------------------------------------
1519
+ let sweepPending = false;
1520
+ let lastSweepAt = 0;
1521
+
1522
+ function scheduleSweep(reason) {
1523
+ if (busy || sweepRunning) { sweepPending = true; return; }
1524
+ if (Date.now() - lastSweepAt < 30000) { sweepPending = true; return; }
1525
+ lastSweepAt = Date.now();
1526
+ syncSessions({ silent: true, force: reason === 'watcher' }).catch(handleError);
1527
+ }
1528
+
1529
+ function opencodeDataDirs() {
1530
+ const dirs = [];
1531
+ if (process.env.XDG_DATA_HOME) dirs.push(path.join(process.env.XDG_DATA_HOME, 'opencode'));
1532
+ dirs.push(path.join(os.homedir(), '.local', 'share', 'opencode'));
1533
+ dirs.push(path.join(os.homedir(), 'AppData', 'Local', 'opencode'));
1534
+ return dirs;
1535
+ }
1536
+ function opencodeDbSignature() {
1537
+ let sig = '';
1538
+ for (const d of opencodeDataDirs()) {
1539
+ for (const f of ['opencode.db', 'opencode.db-wal', 'opencode.db-shm']) {
1540
+ try {
1541
+ const st = fs.statSync(path.join(d, f));
1542
+ sig += f + ':' + Math.round(st.mtimeMs) + ':' + st.size + ';';
1543
+ } catch (e) { /* no existe en esa ruta */ }
1544
+ }
1545
+ }
1546
+ return sig;
1547
+ }
1548
+ // `updated` de `session list` es "HH:MM · D/M/YYYY" (precision de minuto).
1549
+ function listUpdatedRecent(updated, windowMs) {
1550
+ const m = String(updated || '').match(/^(\d{1,2}):(\d{2})\s*·\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/);
1551
+ if (!m) return false;
1552
+ const ts = new Date(Number(m[5]), Number(m[4]) - 1, Number(m[3]), Number(m[1]), Number(m[2])).getTime();
1553
+ return ts > 0 && (Date.now() - ts) <= windowMs;
1554
+ }
1555
+ function startSyncWatcher() {
1556
+ let last = opencodeDbSignature();
1557
+ let timer = null;
1558
+ setInterval(() => {
1559
+ const sig = opencodeDbSignature();
1560
+ if (sig === '' || sig === last) return;
1561
+ last = sig;
1562
+ if (timer) clearTimeout(timer);
1563
+ timer = setTimeout(() => scheduleSweep('watcher'), 8000);
1564
+ }, 4000);
1565
+ // Respaldo por si el watcher no ve el cambio (otra ruta o filesystem).
1566
+ setInterval(() => scheduleSweep('periodico'), 3 * 60 * 1000);
1567
+ }
1568
+
1499
1569
  // ---------------------------------------------------------------------------
1500
1570
  // Archivos: listado y lectura (read-only) resueltos por el puente,
1501
1571
  // siempre dentro del workspace y sin recorrer basura (node_modules, .git...).
@@ -1700,10 +1770,24 @@ function tunnelSpawn(port, onLine, onFail, onUrls, triedNpx = false) {
1700
1770
  return;
1701
1771
  }
1702
1772
  const args = (spec.pre || []).concat(triedNpx ? ['--yes', 'tunnelmole', String(port)] : [String(port)]);
1773
+ let settled = false;
1774
+ // Si `tmole` existe pero falla (shim roto, sin PATH, etc.), reintentamos con
1775
+ // `npx --yes tunnelmole` en vez de darnos por vencidos.
1776
+ const retryNpx = () => {
1777
+ if (settled) return;
1778
+ settled = true;
1779
+ log('túnel: tmole falló, reintentando con npx tunnelmole…');
1780
+ tunnelSpawn(port, onLine, onFail, onUrls, true);
1781
+ };
1703
1782
  let proc;
1704
1783
  try {
1705
- proc = spawn(spec.bin, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
1784
+ proc = spawn(spec.bin, args, {
1785
+ stdio: ['ignore', 'pipe', 'pipe'],
1786
+ windowsHide: true,
1787
+ windowsVerbatimArguments: !!spec.verbatim,
1788
+ });
1706
1789
  } catch (e) {
1790
+ if (!triedNpx) { retryNpx(); return; }
1707
1791
  onFail('no se pudo iniciar tunnelmole (' + e.message + '). Instalalo con: npm i -g tunnelmole');
1708
1792
  return;
1709
1793
  }
@@ -1711,14 +1795,17 @@ function tunnelSpawn(port, onLine, onFail, onUrls, triedNpx = false) {
1711
1795
  const feed = (d) => {
1712
1796
  buf += String(d);
1713
1797
  const urls = parseTunnelUrls(buf);
1714
- if (urls.https || urls.http) onUrls(urls);
1798
+ if (urls.https || urls.http) { settled = true; onUrls(urls); }
1715
1799
  };
1716
1800
  proc.stdout.on('data', feed);
1717
1801
  proc.stderr.on('data', feed);
1718
1802
  proc.on('error', (e) => {
1803
+ if (!triedNpx) { retryNpx(); return; }
1719
1804
  onFail('no se pudo iniciar tunnelmole (' + e.message + '). Instalalo con: npm i -g tunnelmole');
1720
1805
  });
1721
1806
  proc.on('exit', (code) => {
1807
+ if (settled) return;
1808
+ if (!triedNpx) { retryNpx(); return; }
1722
1809
  onFail('tmole terminó (código ' + code + ')' + (buf ? ': ' + buf.slice(0, 160).trim() : ''));
1723
1810
  });
1724
1811
  onLine(proc);
@@ -1756,10 +1843,10 @@ async function tunnelStart(cmd) {
1756
1843
  };
1757
1844
  entry = { port, proc: null, urls: { https: '', http: '' }, startedAt: Date.now() };
1758
1845
  tunnels.set(port, entry);
1759
- // 60 s: con el fallback de npx, la primera vez descarga tunnelmole.
1846
+ // 85 s: con el fallback de npx, la primera vez descarga tunnelmole.
1760
1847
  timer = setTimeout(() => {
1761
- finish(false, 'tunnelmole no devolvió URLs (60 s). ¿Está instalado? npm i -g tunnelmole');
1762
- }, 60000);
1848
+ finish(false, 'tunnelmole no devolvió URLs (85 s). ¿Está instalado? npm i -g tunnelmole');
1849
+ }, 85000);
1763
1850
  tunnelSpawn(
1764
1851
  port,
1765
1852
  (proc) => { entry.proc = proc; },
@@ -1913,7 +2000,9 @@ function procResolveBin(token, allowSet) {
1913
2000
  const found = procFindOnPath(bare);
1914
2001
  if (!found) throw new Error('no se encontró "' + token + '" en el PATH');
1915
2002
  if (found.ext.toLowerCase() === '.exe') return { bin: found.p, pre: [] };
1916
- return { bin: 'cmd.exe', pre: ['/d', '/s', '/c', '"' + found.p + '"'] };
2003
+ // Shim .cmd/.bat: se ejecuta con cmd.exe. `verbatim` evita que Node vuelva a
2004
+ // citar el argumento (que ya viene entre comillas) y cmd reciba "\"ruta\"".
2005
+ return { bin: 'cmd.exe', pre: ['/d', '/s', '/c', '"' + found.p + '"'], verbatim: true };
1917
2006
  }
1918
2007
 
1919
2008
  async function procDone(cmd, ok, payload, error) {
@@ -2000,6 +2089,7 @@ async function procStart(cmd) {
2000
2089
  stdio: ['ignore', 'pipe', 'pipe'],
2001
2090
  env: process.env,
2002
2091
  windowsHide: true,
2092
+ windowsVerbatimArguments: !!spec.verbatim,
2003
2093
  });
2004
2094
  } catch (e) {
2005
2095
  await procDone(cmd, false, null, 'no se pudo iniciar: ' + e.message);
@@ -2301,6 +2391,7 @@ async function tick(opts) {
2301
2391
  text: r.text,
2302
2392
  reasoning: r.reasoning || '',
2303
2393
  opencode_session: r.opencodeSession || '',
2394
+ oc_msg: r.assistantMsgID || '',
2304
2395
  canceled: !!r.canceled,
2305
2396
  };
2306
2397
  try {
@@ -2334,6 +2425,9 @@ async function tick(opts) {
2334
2425
  activeMsgTarget = null;
2335
2426
  busy = false;
2336
2427
  for (const t of involvedTargets) notifyBusy(t, null);
2428
+ // El watcher pudo pedir un barrido mientras opencode estaba ocupado:
2429
+ // se corre ahora que terminó (en vez de perderse).
2430
+ if (sweepPending) { sweepPending = false; scheduleSweep('pendiente'); }
2337
2431
  }
2338
2432
  return 'trabajo';
2339
2433
  }
@@ -2396,8 +2490,7 @@ process.on('SIGINT', () => {
2396
2490
  // Historial único: primer poll hecho (lastKnownOc cargado), importamos
2397
2491
  // en segundo plano y repetimos cada 15 minutos.
2398
2492
  syncSessions().catch(handleError);
2399
- setInterval(() => {
2400
- if (!busy && !sweepRunning) syncSessions({ silent: true }).catch(handleError);
2401
- }, 15 * 60 * 1000);
2493
+ // Watcher de la base de opencode: sincroniza en segundos, no cada 15 min.
2494
+ startSyncWatcher();
2402
2495
  scheduleTick(POLL_QUICK_MS);
2403
2496
  })();
@@ -198,9 +198,14 @@ async function sessionImport(ocSession, name, folder, model, agent, updatedTs, m
198
198
  let added = 0;
199
199
  await jsonfile.update(paths.messagesFile(sid), { messages: [], nextId: 1 }, (data) => {
200
200
  const known = {};
201
- for (const m of data.messages) {
201
+ const knownOc = {};
202
+ const byText = {};
203
+ data.messages.forEach((m, i) => {
202
204
  known[String(m.role || '') + '|' + String(m.ts || '') + '|' + md5(mbSubstr(m.text || '', 0, 400))] = true;
203
- }
205
+ const ocId = String(m.oc_msg || '').trim();
206
+ if (ocId !== '') knownOc[ocId] = true;
207
+ else byText[String(m.role || '') + '|' + md5(mbSubstr(String(m.text || '').trim(), 0, 400))] = i;
208
+ });
204
209
  for (const m of (Array.isArray(messages) ? messages : [])) {
205
210
  if (!m || typeof m !== 'object') continue;
206
211
  const role = String(m.role || '');
@@ -210,12 +215,29 @@ async function sessionImport(ocSession, name, folder, model, agent, updatedTs, m
210
215
  if (Array.from(text).length > 50000) text = mbSubstr(text, 0, 50000);
211
216
  let ts = String(m.ts || '');
212
217
  if (ts === '' || isNaN(Date.parse(ts))) ts = nowIso();
218
+ const ocMsg = String(m.oc_msg || '').trim();
219
+ // Identidad de opencode: si ya esta, es el mismo mensaje.
220
+ if (ocMsg !== '' && knownOc[ocMsg]) continue;
213
221
  const key = role + '|' + ts + '|' + md5(mbSubstr(text, 0, 400));
214
222
  if (known[key]) continue;
223
+ // Adopcion: llego de opencode (con oc_msg) y existe uno de la web
224
+ // con el mismo rol+texto y sin id. Se le asigna el id.
225
+ if (ocMsg !== '') {
226
+ const tk = role + '|' + md5(mbSubstr(text, 0, 400));
227
+ if (byText[tk] !== undefined) {
228
+ data.messages[byText[tk]].oc_msg = ocMsg;
229
+ knownOc[ocMsg] = true;
230
+ known[key] = true;
231
+ delete byText[tk];
232
+ continue;
233
+ }
234
+ }
215
235
  known[key] = true;
236
+ if (ocMsg !== '') knownOc[ocMsg] = true;
216
237
  const id = data.nextId;
217
238
  data.nextId = id + 1;
218
239
  const msg = { id, role, text, ts, status: 'done' };
240
+ if (ocMsg !== '') msg.oc_msg = ocMsg;
219
241
  const reasoning = String(m.reasoning || '').trim();
220
242
  if (reasoning !== '') msg.reasoning = Array.from(reasoning).length > 50000 ? mbSubstr(reasoning, 0, 50000) : reasoning;
221
243
  if (m.agent) msg.agent = mbSubstr(String(m.agent), 0, 40);
package/src/web/routes.js CHANGED
@@ -627,6 +627,7 @@ async function handleApi(ctx) {
627
627
  const userId = parseInt(body.user_id, 10) || 0;
628
628
  const text = String(body.text || '').trim();
629
629
  const reasoning = String(body.reasoning || '').trim();
630
+ const ocMsg = String(body.oc_msg || '').trim();
630
631
  if (sid <= 0 || userId <= 0) return ok({ ok: false, error: 'session_id y user_id son obligatorios' }, 400);
631
632
  if (Array.from(text).length > 50000 || Array.from(reasoning).length > 50000) return ok({ ok: false, error: 'Respuesta demasiado larga' }, 400);
632
633
  if (text === '' && reasoning === '') return ok({ ok: false, error: 'Nada para publicar' }, 400);
@@ -639,6 +640,7 @@ async function handleApi(ctx) {
639
640
  if (draftIdx >= 0) {
640
641
  data.messages[draftIdx].text = text;
641
642
  if (reasoning !== '') data.messages[draftIdx].reasoning = reasoning;
643
+ if (ocMsg !== '') data.messages[draftIdx].oc_msg = ocMsg;
642
644
  data.messages[draftIdx].ts = store.nowIso();
643
645
  } else {
644
646
  const aid = data.nextId;
@@ -648,6 +650,7 @@ async function handleApi(ctx) {
648
650
  draft_for: userId, agent: store.messageAgentOf(data, userId),
649
651
  };
650
652
  if (reasoning !== '') draft.reasoning = reasoning;
653
+ if (ocMsg !== '') draft.oc_msg = ocMsg;
651
654
  data.messages.push(draft);
652
655
  }
653
656
  });
@@ -664,6 +667,7 @@ async function handleApi(ctx) {
664
667
  if (!exists) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
665
668
  const oc = body.opencode_session ? String(body.opencode_session).trim() : '';
666
669
  const reasoning = String(body.reasoning || '').trim();
670
+ const ocMsg = String(body.oc_msg || '').trim();
667
671
  const clearSession = !!body.clear_session;
668
672
  const canceled = !!body.canceled;
669
673
  await store.sessionsUpdate((sd) => {
@@ -706,6 +710,7 @@ async function handleApi(ctx) {
706
710
  m.answered_ts = store.nowIso();
707
711
  m.ts = store.nowIso();
708
712
  if (reasoning !== '') m.reasoning = reasoning; else delete m.reasoning;
713
+ if (ocMsg !== '') m.oc_msg = ocMsg;
709
714
  if (canceled) m.canceled = true; else delete m.canceled;
710
715
  if (!m.agent) m.agent = store.messageAgentOf(data, userId, (sess && sess.agent) || '');
711
716
  if (author && !m.author) m.author = author;
@@ -715,6 +720,7 @@ async function handleApi(ctx) {
715
720
  data.nextId = aid + 1;
716
721
  const nm = { id: aid, role: 'assistant', text, ts: store.nowIso(), status: 'done', agent: store.messageAgentOf(data, userId, (sess && sess.agent) || '') };
717
722
  if (reasoning !== '') nm.reasoning = reasoning;
723
+ if (ocMsg !== '') nm.oc_msg = ocMsg;
718
724
  if (canceled) nm.canceled = true;
719
725
  if (author) nm.author = author;
720
726
  data.messages.push(nm);