@danieltmn/tamnora 0.10.0 → 0.11.1

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.
@@ -6,11 +6,7 @@ import { pidAlive, killPidTree } from './pid.js';
6
6
  /**
7
7
  * Acceso remoto. Proveedores:
8
8
  * - tailscale: VPN mesh privada + `tailscale serve` (recomendado; sin exposicion).
9
- * - tunnelmole: tunel publico gratis (URL aleatoria) — LEGACY.
10
- * - relay: mailbox HTTP propio (harness.tamnora.com) — LEGACY.
11
- *
12
- * Los tuneles publicos se retiran en la proxima version. `tunnelmole` se
13
- * conserva solo para el feature de `preview`; `startRelay` esta condenado.
9
+ * - tunnelmole: tunel publico gratis (URL aleatoria); solo para `preview`.
14
10
  */
15
11
 
16
12
  function killTree(child) {
@@ -116,7 +112,7 @@ function spawnTunnel(bin, args, opts = {}) {
116
112
  });
117
113
  }
118
114
 
119
- // LEGACY: proveedor legacy de `bridge`; se conserva para el feature `preview`.
115
+ // Tunel publico para el feature `preview` (no lo usa `bridge`).
120
116
  export function startTunnelmole(port, opts = {}) {
121
117
  return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
122
118
  ...opts,
@@ -127,13 +123,6 @@ export function startTunnelmole(port, opts = {}) {
127
123
  });
128
124
  }
129
125
 
130
- /**
131
- * Relay propio (harness.tamnora.com): mailbox HTTP sobre cPanel (solo PHP).
132
- * El daemon no recibe conexiones: llama hacia afuera (a=poll), ejecuta cada
133
- * request contra el server local y empuja las respuestas (a=push).
134
- *
135
- * LEGACY / condenado: se retira en la proxima version (usar tailscale).
136
- */
137
126
  /**
138
127
  * Tailscale: VPN mesh (WireGuard). `tailscale serve` publica HTTPS dentro del
139
128
  * tailnet reenviando a loopback, asi que no hay exposicion publica y las
@@ -245,152 +234,11 @@ export function resetTailscaleServe() {
245
234
  return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
246
235
  }
247
236
 
248
- export function startRelay(port, opts = {}) {
249
- const relayUrl = String(opts.relayUrl ?? '').replace(/\/+$/, '');
250
- const token = String(opts.token ?? '');
251
- if (!relayUrl || !/^https?:\/\//.test(relayUrl)) {
252
- throw new Error('relay: falta tunnel.relayUrl (https://harness.tamnora.com/index.php)');
253
- }
254
- if (!token) throw new Error('relay: falta tunnel.token');
255
- const log = opts.log ?? (() => {});
256
- const local = `http://127.0.0.1:${port}`;
257
- const state = { stopped: false };
258
-
259
- const call = async (action, body = {}, { timeoutMs = 30000 } = {}) => {
260
- const res = await fetch(`${relayUrl}?a=${action}`, {
261
- method: 'POST',
262
- headers: { 'Content-Type': 'application/json' },
263
- body: JSON.stringify({ token, ...body }),
264
- signal: AbortSignal.timeout(timeoutMs),
265
- });
266
- if (!res.ok) throw new Error(`relay ${action}: HTTP ${res.status}`);
267
- return res.json();
268
- };
269
-
270
- async function push(rid, chunks) {
271
- await call('push', { rid, chunks }, { timeoutMs: 60000 });
272
- }
273
-
274
- async function handleRequest(row) {
275
- const envelope = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
276
- const method = envelope.method ?? 'GET';
277
- const path = String(envelope.path ?? '/');
278
- const target = `${local}${path.startsWith('/') ? path : `/${path}`}`;
279
- const init = { method, headers: { ...envelope.headers } };
280
- delete init.headers.host;
281
- if (envelope.body != null) init.body = envelope.body;
282
- const res = await fetch(target, init);
283
- const contentType = String(res.headers.get('content-type') ?? '');
284
- const flat = {};
285
- for (const header of ['content-type']) {
286
- const value = res.headers.get(header);
287
- if (value) flat[header] = value;
288
- }
289
- const cookies = res.headers.getSetCookie?.() ?? [];
290
- if (cookies.length) flat['set-cookie'] = cookies;
291
-
292
- if (!contentType.includes('text/event-stream')) {
293
- const buffer = Buffer.from(await res.arrayBuffer());
294
- await push(row.id, [
295
- {
296
- status: res.status,
297
- headers: flat,
298
- body: buffer.toString('base64'),
299
- done: true,
300
- },
301
- ]);
302
- return;
303
- }
304
-
305
- // SSE: reenviamos cada frame tal cual (el cliente sabe reconectar con cursor).
306
- let seq = 0;
307
- await push(row.id, [{ seq: seq++, status: res.status, headers: flat, done: false }]);
308
- const reader = res.body.getReader();
309
- const decoder = new TextDecoder();
310
- let buffer = '';
311
- for (;;) {
312
- const { value, done } = await reader.read();
313
- if (done) break;
314
- buffer += decoder.decode(value, { stream: true });
315
- let index;
316
- while ((index = buffer.indexOf('\n\n')) !== -1) {
317
- const frame = buffer.slice(0, index);
318
- buffer = buffer.slice(index + 2);
319
- let ev = '';
320
- let data = '';
321
- let id = null;
322
- for (const line of frame.split('\n')) {
323
- if (line.startsWith('event:')) ev = line.slice(6).trim();
324
- else if (line.startsWith('data:')) data += line.slice(5).trim();
325
- else if (line.startsWith('id:')) id = line.slice(3).trim();
326
- }
327
- if (!ev && !data) continue;
328
- await push(row.id, [{ seq: seq++, ev: ev || 'message', data, id, done: false }]);
329
- }
330
- }
331
- await push(row.id, [{ done: true }]);
332
- }
333
-
334
- async function pump() {
335
- const registered = await call('register', {}, { timeoutMs: 20000 });
336
- let cursor = Number(registered.cursor) || 0;
337
- log(`relay: conectado a ${relayUrl} (inbox desde ${cursor})`);
338
- while (!state.stopped) {
339
- let rows;
340
- try {
341
- const batch = await call('poll', { after: cursor, wait: 20 }, { timeoutMs: 40000 });
342
- rows = batch.rows ?? [];
343
- } catch (error) {
344
- if (state.stopped) break;
345
- log(`relay poll: ${error?.message ?? error}`);
346
- await new Promise((r) => setTimeout(r, 3000));
347
- continue;
348
- }
349
- for (const row of rows) {
350
- cursor = Math.max(cursor, Number(row.id) || 0);
351
- try {
352
- await handleRequest(row);
353
- } catch (error) {
354
- log(`relay request ${row.id}: ${error?.message ?? error}`);
355
- try {
356
- await push(row.id, [
357
- {
358
- status: 502,
359
- headers: { 'content-type': 'application/json; charset=utf-8' },
360
- body: Buffer.from(JSON.stringify({ error: `harness inaccesible: ${error?.message ?? error}` })).toString('base64'),
361
- done: true,
362
- },
363
- ]);
364
- } catch {
365
- /* nada */
366
- }
367
- }
368
- }
369
- }
370
- }
371
-
372
- pump().catch((error) => {
373
- if (!state.stopped) log(`relay pump: ${error?.message ?? error}`);
374
- });
375
-
376
- return {
377
- url: relayUrl,
378
- provider: 'relay',
379
- pid: 0,
380
- child: null,
381
- probeUrl: `${relayUrl}?a=status`,
382
- stop() {
383
- state.stopped = true;
384
- },
385
- };
386
- }
387
-
388
237
  /** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
389
238
  export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
390
239
  const p = String(provider || 'tunnelmole').toLowerCase();
391
240
  if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
392
241
  if (p === 'tailscale' || p === 'ts') return startTailscaleServe(port, opts);
393
242
  if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
394
- if (p === 'relay') return startRelay(port, opts);
395
243
  throw new Error(`proveedor de tunel no soportado: ${provider}`);
396
244
  }
@@ -48,13 +48,15 @@ test('ensureApp creates app.json with a secret and roundtrips', async () => {
48
48
  assert.equal(readApp().port, 4599);
49
49
  });
50
50
 
51
- test('readApp migra un tunnelmole guardado a tailscale (legacy)', async () => {
51
+ test('readApp migra los proveedores retirados (tunnelmole/relay) a tailscale', async () => {
52
52
  const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
53
53
  setHome(dir);
54
- const app = ensureApp();
55
- app.tunnel = { ...app.tunnel, provider: 'tunnelmole' };
56
- writeApp(app);
57
- assert.equal(readApp().tunnel.provider, 'tailscale');
54
+ for (const provider of ['tunnelmole', 'relay']) {
55
+ const app = ensureApp();
56
+ app.tunnel = { ...app.tunnel, provider };
57
+ writeApp(app);
58
+ assert.equal(readApp().tunnel.provider, 'tailscale', provider);
59
+ }
58
60
  });
59
61
 
60
62
  test('migrateLegacyDb copies the legacy project database to the global home', async () => {
@@ -3,7 +3,6 @@
3
3
  import { renderMarkdown, speechText } from './markdown.js';
4
4
  import { streamSse, followRun } from './runStream.js';
5
5
  import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
6
- import { installRelayTransport, showRelayTokenGate } from './relayClient.js';
7
6
 
8
7
  const $ = (sel) => document.querySelector(sel);
9
8
  let csrfToken = '';
@@ -97,30 +96,7 @@ function icon(name, size = 16, className = '') {
97
96
 
98
97
  /* --- HTTP / SSE ---------------------------------------------------------- */
99
98
 
100
- // Tamnora Relay: cuando la consola se sirve desde harness.tamnora.com/console,
101
- // el page define `window.TAMNORA_RELAY = { base }` y todo el trafico /api pasa
102
- // por el mailbox del relay (relaClient). Sin esa variable es el harness local.
103
- const RELAY_CONFIG = window.TAMNORA_RELAY ?? null;
104
- const relayToken = RELAY_CONFIG
105
- ? new URLSearchParams(location.search).get('t') || localStorage.getItem('tamnora-relay-token') || ''
106
- : '';
107
- const relayTransport = RELAY_CONFIG
108
- ? installRelayTransport({ base: RELAY_CONFIG.base, token: relayToken })
109
- : null;
110
-
111
- if (RELAY_CONFIG && !relayTransport.enabled) {
112
- showRelayTokenGate();
113
- throw new Error('[relay] falta el token de acceso');
114
- }
115
-
116
99
  async function api(path, { method = 'GET', body, headers = {} } = {}) {
117
- if (relayTransport?.enabled) {
118
- return relayTransport.request(path, {
119
- method,
120
- body,
121
- headers: method !== 'GET' && csrfToken ? { 'x-csrf': csrfToken, ...headers } : headers,
122
- });
123
- }
124
100
  const init = {};
125
101
  if (body) init['Content-Type'] = 'application/json';
126
102
  if (method !== 'GET' && csrfToken) init['x-csrf'] = csrfToken;
@@ -145,13 +121,10 @@ async function api(path, { method = 'GET', body, headers = {} } = {}) {
145
121
  }
146
122
 
147
123
  const streamPost = (url, body, onEvent, signal) =>
148
- relayTransport?.enabled
149
- ? relayTransport.stream(url, { method: 'POST', body, signal, headers: csrfToken ? { 'x-csrf': csrfToken } : {} }, onEvent)
150
- : streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
124
+ streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
151
125
 
152
126
  /** SSE GET de la consola (/api/sessions/:id/stream, /api/events). */
153
- const openStream = (url, opts, onEvent) =>
154
- relayTransport?.enabled ? relayTransport.stream(url, opts, onEvent) : streamSse(url, opts, onEvent);
127
+ const openStream = (url, opts, onEvent) => streamSse(url, opts, onEvent);
155
128
 
156
129
  /* --- localStorage (todas las lecturas/escrituras pasan por aca) ------------ */
157
130
 
@@ -601,7 +574,7 @@ if (logout) {
601
574
  } catch {
602
575
  // igual salimos
603
576
  }
604
- location.href = relayTransport?.enabled ? location.pathname : '/landing';
577
+ location.href = '/landing';
605
578
  });
606
579
  }
607
580
 
@@ -3604,9 +3577,11 @@ function renderProviderList() {
3604
3577
  )
3605
3578
  );
3606
3579
  li.appendChild(main);
3607
- li.appendChild(button('usar', (event) => { event.stopPropagation(); useProvider(p.id); }));
3608
- li.appendChild(button('editar', (event) => { event.stopPropagation(); openProviderForm(p); }));
3609
- if (p.stored) li.appendChild(button('borrar', (event) => { event.stopPropagation(); removeProvider(p.id); }, 'btn danger'));
3580
+ const actions = el('div', 'pactions');
3581
+ actions.appendChild(button('usar', (event) => { event.stopPropagation(); useProvider(p.id); }));
3582
+ actions.appendChild(button('editar', (event) => { event.stopPropagation(); openProviderForm(p); }));
3583
+ if (p.stored) actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeProvider(p.id); }, 'btn danger'));
3584
+ li.appendChild(actions);
3610
3585
  li.addEventListener('click', () => openProviderForm(p));
3611
3586
  providerListEl.appendChild(li);
3612
3587
  }
@@ -3806,9 +3781,11 @@ async function refreshMcp({ connect = false } = {}) {
3806
3781
  }
3807
3782
  main.append(title, el('div', 'pmeta', parts.join(' · ')));
3808
3783
  li.appendChild(main);
3809
- li.appendChild(button(s.enabled ? 'deshabilitar' : 'habilitar', (event) => { event.stopPropagation(); toggleMcp(s); }));
3810
- li.appendChild(button('editar', (event) => { event.stopPropagation(); openMcpForm(s); }));
3811
- if (s.source !== 'config') li.appendChild(button('borrar', (event) => { event.stopPropagation(); removeMcp(s.id); }, 'btn danger'));
3784
+ const actions = el('div', 'pactions');
3785
+ actions.appendChild(button(s.enabled ? 'deshabilitar' : 'habilitar', (event) => { event.stopPropagation(); toggleMcp(s); }));
3786
+ actions.appendChild(button('editar', (event) => { event.stopPropagation(); openMcpForm(s); }));
3787
+ if (s.source !== 'config') actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeMcp(s.id); }, 'btn danger'));
3788
+ li.appendChild(actions);
3812
3789
  li.addEventListener('click', () => openMcpForm(s));
3813
3790
  mcpListEl.appendChild(li);
3814
3791
  }
@@ -3922,9 +3899,11 @@ async function refreshSkills() {
3922
3899
  el('div', 'pmeta', s.description || s.when || '(sin descripción)')
3923
3900
  );
3924
3901
  li.appendChild(main);
3925
- li.appendChild(button(s.enabled ? 'deshabilitar' : 'habilitar', (event) => { event.stopPropagation(); toggleSkill(s.id); }));
3926
- li.appendChild(button('editar', (event) => { event.stopPropagation(); openSkillForm(s.id); }));
3927
- if (s.removable) li.appendChild(button('borrar', (event) => { event.stopPropagation(); removeSkill(s.id); }, 'btn danger'));
3902
+ const actions = el('div', 'pactions');
3903
+ actions.appendChild(button(s.enabled ? 'deshabilitar' : 'habilitar', (event) => { event.stopPropagation(); toggleSkill(s.id); }));
3904
+ actions.appendChild(button('editar', (event) => { event.stopPropagation(); openSkillForm(s.id); }));
3905
+ if (s.removable) actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeSkill(s.id); }, 'btn danger'));
3906
+ li.appendChild(actions);
3928
3907
  li.addEventListener('click', () => openSkillForm(s.id));
3929
3908
  skillListEl.appendChild(li);
3930
3909
  }
@@ -5200,9 +5179,11 @@ async function refreshProcs() {
5200
5179
  el('div', 'pmeta', `${proc.status}${proc.port ? ` · puerto ${proc.port}` : ''}`)
5201
5180
  );
5202
5181
  li.appendChild(main);
5203
- li.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
5204
- if (proc.port) li.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(`http://127.0.0.1:${proc.port}`); }));
5205
- if (proc.running) li.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
5182
+ const actions = el('div', 'pactions');
5183
+ actions.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
5184
+ if (proc.port) actions.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(`http://127.0.0.1:${proc.port}`); }));
5185
+ if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
5186
+ li.appendChild(actions);
5206
5187
  procsListEl.appendChild(li);
5207
5188
  }
5208
5189
  }
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.10.0</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.11.1</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -1220,6 +1220,16 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
1220
1220
  .providers .pid { font-weight: 700; }
1221
1221
  .providers .pmeta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1222
1222
  .providers .grow { flex: 1; min-width: 0; }
1223
+ .providers .pactions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
1224
+ .providers li.muted {
1225
+ border: 0;
1226
+ background: none;
1227
+ backdrop-filter: none;
1228
+ box-shadow: none;
1229
+ cursor: default;
1230
+ padding-left: 2px;
1231
+ }
1232
+ .providers li.muted:hover { border: 0; }
1223
1233
 
1224
1234
  .summary { color: var(--muted); }
1225
1235
  .summary.ok { color: var(--ok); }
@@ -1598,17 +1608,38 @@ dialog.file-modal.preview-maximized {
1598
1608
  }
1599
1609
  .composer {
1600
1610
  margin: 8px 10px 6px;
1601
- padding: 8px 10px;
1611
+ padding: 9px 10px;
1602
1612
  border-radius: 18px;
1603
1613
  }
1614
+ /* En un movil el input flotaba sin fondo y casi no se notaba: lo sacamos a su
1615
+ propia linea, con campo visible y 16px (evita el zoom de iOS al enfocar). */
1616
+ .composer-row {
1617
+ flex-wrap: wrap;
1618
+ align-items: center;
1619
+ gap: 8px;
1620
+ }
1604
1621
  .composer textarea {
1622
+ order: -1;
1623
+ flex: 1 0 100%;
1605
1624
  max-height: 40vh;
1625
+ min-height: 46px;
1626
+ padding: 11px 13px;
1627
+ border: 1px solid var(--border);
1628
+ border-radius: 14px;
1629
+ background: var(--bg);
1630
+ font-size: 16px;
1631
+ line-height: 1.45;
1632
+ }
1633
+ .composer textarea:focus {
1634
+ border: 1px solid var(--accent);
1635
+ box-shadow: 0 0 0 4px var(--accent-soft);
1606
1636
  }
1607
1637
  .ctrl-pill {
1608
1638
  max-width: 46vw;
1609
1639
  }
1610
1640
  .model-pill {
1611
- max-width: 40vw;
1641
+ max-width: 46vw;
1642
+ margin-left: auto;
1612
1643
  }
1613
1644
  .composer-status {
1614
1645
  flex-wrap: wrap;
@@ -1617,6 +1648,26 @@ dialog.file-modal.preview-maximized {
1617
1648
  .composer-status .spacer {
1618
1649
  display: none;
1619
1650
  }
1651
+ /* MCP / skills / proveedores / procesos: el titulo se apretaba contra los
1652
+ botones y la descripcion quedaba cortada. Pasamos las acciones a su propia
1653
+ linea y dejamos que el texto respire. */
1654
+ .providers li {
1655
+ flex-wrap: wrap;
1656
+ align-items: flex-start;
1657
+ gap: 8px 10px;
1658
+ padding: 11px 12px;
1659
+ }
1660
+ .providers .pactions {
1661
+ flex: 0 0 100%;
1662
+ justify-content: flex-end;
1663
+ }
1664
+ .providers .grow .pmeta {
1665
+ white-space: normal;
1666
+ overflow: hidden;
1667
+ display: -webkit-box;
1668
+ -webkit-line-clamp: 2;
1669
+ -webkit-box-orient: vertical;
1670
+ }
1620
1671
  .empty-brand {
1621
1672
  font-size: clamp(30px, 12vw, 46px);
1622
1673
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",
@@ -1,170 +0,0 @@
1
- // Transporte relay para la consola estatica (harness.tamnora.com/console/).
2
- //
3
- // La misma consola corre en el harness local y en el tamnora del sitio: aqui
4
- // cada llamada a `api()` y cada stream se manda por el mailbox del relay y el
5
- // daemon local (`tamnora bridge --tunnel relay`) la ejecuta contra su propio
6
- // server. El token (?t=...) es la credencial compartida con el mailbox.
7
- //
8
- // Requiere runStream.js (streamSse/delay) que ya carga la consola.
9
-
10
- import { streamSse, delay } from './runStream.js';
11
-
12
- export function installRelayTransport({ base, token }) {
13
- const cleanToken = String(token ?? '').trim();
14
- if (!cleanToken) {
15
- console.warn('[relay] sin token: la consola vera solo la pantalla de login');
16
- }
17
-
18
- const b64decode = (value) =>
19
- new TextDecoder().decode(Uint8Array.from(atob(String(value ?? '')), (c) => c.charCodeAt(0)));
20
-
21
- const relayUrl = (action) => `${base}?a=${action}&t=${encodeURIComponent(cleanToken)}`;
22
-
23
- async function post(action, payload, signal) {
24
- const res = await fetch(relayUrl(action), {
25
- method: 'POST',
26
- headers: { 'Content-Type': 'application/json' },
27
- body: JSON.stringify(payload),
28
- signal,
29
- });
30
- if (!res.ok) {
31
- const error = new Error(`relay HTTP ${res.status}`);
32
- error.status = res.status;
33
- throw error;
34
- }
35
- return res.json();
36
- }
37
-
38
- function applyCookies(headers) {
39
- for (const cookie of headers?.['set-cookie'] ?? []) {
40
- const kv = String(cookie).split(';')[0];
41
- if (kv && kv.includes('=')) document.cookie = `${kv}; Path=/`;
42
- }
43
- }
44
-
45
- /** Request sincrono: PHP espera inline la respuesta completa del mailbox. */
46
- async function request(path, { method = 'GET', body, headers = {} } = {}) {
47
- const envelope = {
48
- method,
49
- path,
50
- headers,
51
- body: body === undefined ? null : JSON.stringify(body),
52
- stream: false,
53
- };
54
- const data = await post('send', envelope);
55
- if (data.error) {
56
- const error = new Error(data.error);
57
- error.status = Number(data.status) || 0;
58
- throw error;
59
- }
60
- applyCookies(data.headers);
61
- const text = b64decode(data.body);
62
- let parsed = null;
63
- try {
64
- parsed = JSON.parse(text);
65
- } catch {
66
- parsed = { raw: text };
67
- }
68
- if (Number(data.status) >= 400) {
69
- const error = new Error(parsed?.error ?? `HTTP ${data.status}`);
70
- error.status = Number(data.status);
71
- throw error;
72
- }
73
- return parsed;
74
- }
75
-
76
- const parseOrRaw = (text) => {
77
- try {
78
- return JSON.parse(text);
79
- } catch {
80
- return { raw: text };
81
- }
82
- };
83
-
84
- /**
85
- * Stream via mailbox: abre el rid y consume `a=stream` (SSE con cursor).
86
- * Si el PHP corta (max_execution_time) reconecta y repite desde el cursor:
87
- * el replay es del mailbox, NO re-ejecuta el request original.
88
- */
89
- async function stream(path, { method = 'POST', body, headers = {}, signal, silenceMs } = {}, onEvent) {
90
- const envelope = {
91
- method,
92
- path,
93
- headers,
94
- body: body === undefined ? null : JSON.stringify(body),
95
- stream: true,
96
- };
97
- const opened = await post('send', envelope, signal);
98
- if (opened.error) {
99
- const error = new Error(opened.error);
100
- error.status = Number(opened.status) || 0;
101
- throw error;
102
- }
103
- const rid = opened.rid;
104
- let cursor = 0;
105
- let attempt = 0;
106
- for (;;) {
107
- if (signal?.aborted) {
108
- const error = new Error('Aborted', 'AbortError');
109
- error.name = 'AbortError';
110
- throw error;
111
- }
112
- try {
113
- let finished = false;
114
- await streamSse(
115
- `${base}?a=stream&t=${encodeURIComponent(cleanToken)}&rid=${encodeURIComponent(rid)}&from=${cursor}`,
116
- { signal, silenceMs },
117
- (event, data, id) => {
118
- if (id != null) cursor = Number(id) || cursor;
119
- if (event === 'chunk') {
120
- const payload = data?.payload ?? {};
121
- if (!payload.done) {
122
- onEvent(payload.ev || 'message', payload.data != null ? parseOrRaw(payload.data) : {}, payload.id ?? null);
123
- }
124
- return;
125
- }
126
- // meta: headers del run o cierre del daemon.
127
- if (data?.payload?.done) finished = true;
128
- else if (data?.payload?.error) {
129
- const error = new Error(data.payload.error);
130
- error.fatalRelay = true;
131
- throw error;
132
- }
133
- }
134
- );
135
- return { finished };
136
- } catch (error) {
137
- if (error?.name === 'AbortError' || error?.fatalRelay) throw error;
138
- attempt += 1;
139
- await delay(Math.min(8000, 1000 * 2 ** (attempt - 1)), signal);
140
- }
141
- }
142
- }
143
-
144
- window.relayTransport = { token: cleanToken, base, request, stream, enabled: Boolean(cleanToken) };
145
- return window.relayTransport;
146
- }
147
-
148
- /** Pantalla minima si no hay token: pide uno y recarga. */
149
- export function showRelayTokenGate() {
150
- document.body.innerHTML = '';
151
- document.body.style.visibility = 'visible';
152
- const form = document.createElement('form');
153
- form.style.cssText = 'max-width:420px;margin:15vh auto;font-family:ui-sans-serif,system-ui;padding:0 16px';
154
- form.innerHTML = `
155
- <h1 style="font-size:20px">Tamnora Console</h1>
156
- <p style="color:#777;font-size:14px">Pegá el token del bridge (o escaneá el QR del <code>tamnora bridge --tunnel relay</code>).</p>
157
- <input name="t" placeholder="token" style="width:100%;padding:10px;font-size:14px" autocomplete="off" />
158
- <button style="margin-top:10px;padding:10px 16px">entrar</button>`;
159
- form.addEventListener('submit', (event) => {
160
- event.preventDefault();
161
- const value = form.elements.t.value.trim();
162
- if (!value) return;
163
- localStorage.setItem('tamnora-relay-token', value);
164
- const params = new URLSearchParams(location.search);
165
- params.delete('t');
166
- location.href = location.pathname + (params.toString() ? `?${params}` : '');
167
- location.reload();
168
- });
169
- document.body.append(form);
170
- }