@danieltmn/tamnora 0.7.1 → 0.9.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +9 -0
  3. package/SECURITY.md +11 -6
  4. package/harness/packages/cli/src/cli.js +1 -1
  5. package/harness/packages/cli/src/commands/bridge.js +180 -119
  6. package/harness/packages/cli/src/commands/run.js +1 -1
  7. package/harness/packages/cli/src/commands/server.js +26 -50
  8. package/harness/packages/cli/src/commands/tui.js +1 -1
  9. package/harness/packages/cli/src/lib/daemon.js +92 -40
  10. package/harness/packages/cli/test/release.test.js +9 -0
  11. package/harness/packages/core/src/agent/modes.js +1 -1
  12. package/harness/packages/core/src/agent/runAgent.js +3 -0
  13. package/harness/packages/core/src/git/index.js +177 -0
  14. package/harness/packages/core/src/index.js +16 -1
  15. package/harness/packages/core/src/open.js +43 -0
  16. package/harness/packages/core/src/pid.js +35 -0
  17. package/harness/packages/core/src/procs/kill.js +95 -0
  18. package/harness/packages/core/src/tools/executors.js +16 -0
  19. package/harness/packages/core/src/tools/labels.js +2 -0
  20. package/harness/packages/core/src/tools/permissions.js +1 -0
  21. package/harness/packages/core/src/tools/schema.js +17 -0
  22. package/harness/packages/core/src/tts/edge.js +232 -0
  23. package/harness/packages/core/src/tunnel.js +180 -19
  24. package/harness/packages/core/test/open.test.js +19 -0
  25. package/harness/packages/core/test/procs.test.js +50 -0
  26. package/harness/packages/core/test/tools.test.js +12 -0
  27. package/harness/packages/core/test/tts.test.js +41 -0
  28. package/harness/packages/server/public/app.js +4715 -3669
  29. package/harness/packages/server/public/index.html +94 -5
  30. package/harness/packages/server/public/landing/index.html +5 -5
  31. package/harness/packages/server/public/reconcile.js +121 -0
  32. package/harness/packages/server/public/relayClient.js +170 -0
  33. package/harness/packages/server/public/runStream.js +207 -0
  34. package/harness/packages/server/public/styles.css +196 -23
  35. package/harness/packages/server/src/auth.js +29 -5
  36. package/harness/packages/server/src/lib/agentRun.js +77 -0
  37. package/harness/packages/server/src/lib/announcer.js +92 -0
  38. package/harness/packages/server/src/lib/approvals.js +25 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  40. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  41. package/harness/packages/server/src/lib/procs.js +3 -18
  42. package/harness/packages/server/src/lib/runManager.js +24 -2
  43. package/harness/packages/server/src/lib/sessionView.js +43 -0
  44. package/harness/packages/server/src/lib/tasks.js +4 -9
  45. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  46. package/harness/packages/server/src/lib/workspace.js +37 -1
  47. package/harness/packages/server/src/routes/chat.js +51 -47
  48. package/harness/packages/server/src/routes/events.js +35 -0
  49. package/harness/packages/server/src/routes/files.js +22 -26
  50. package/harness/packages/server/src/routes/folders.js +9 -17
  51. package/harness/packages/server/src/routes/git.js +35 -0
  52. package/harness/packages/server/src/routes/procs.js +25 -15
  53. package/harness/packages/server/src/routes/sessions.js +12 -19
  54. package/harness/packages/server/src/routes/shell.js +19 -0
  55. package/harness/packages/server/src/routes/state.js +32 -0
  56. package/harness/packages/server/src/routes/tts.js +82 -0
  57. package/harness/packages/server/src/server.js +25 -1
  58. package/harness/packages/server/test/announcer.test.js +51 -0
  59. package/harness/packages/server/test/reconcile.test.js +146 -0
  60. package/harness/packages/server/test/relay.test.js +219 -0
  61. package/harness/packages/server/test/runStream.test.js +245 -0
  62. package/harness/packages/server/test/security.test.js +252 -0
  63. package/harness/packages/server/test/server.test.js +617 -1
  64. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  65. package/harness/packages/server/test/tts.test.js +133 -0
  66. package/package.json +1 -1
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Tamnora Console</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23231a17'/%3E%3Ctext x='16' y='23' font-family='Georgia,serif' font-size='18' font-weight='700' fill='%23e8dfd6' text-anchor='middle'%3ET%3C/text%3E%3C/svg%3E" />
7
8
  <script>
8
9
  (function () {
9
10
  var theme = null;
@@ -29,11 +30,11 @@
29
30
  rel="stylesheet"
30
31
  href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Libre+Baskerville:wght@400;700&family=Manrope:wght@400;500;600;700&display=swap"
31
32
  />
32
- <link rel="stylesheet" href="/styles.css" />
33
+ <link rel="stylesheet" href="styles.css" />
33
34
  </head>
34
35
  <body>
35
36
  <header class="topbar">
36
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.7.1</span></div>
37
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.0</span></div>
37
38
  <nav class="tabs">
38
39
  <button class="tab active" data-tab="chat">chat</button>
39
40
  <button class="tab" data-tab="providers">proveedores</button>
@@ -57,6 +58,12 @@
57
58
  </div>
58
59
  </nav>
59
60
  <div class="settings">
61
+ <button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
62
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
63
+ <path d="M12 5v14M5 12h14" />
64
+ </svg>
65
+ <span class="topbar-new-label">nueva sesión</span>
66
+ </button>
60
67
  <button id="search-toggle" type="button" class="btn icon" title="buscar (Ctrl/Cmd+K)" aria-label="buscar">
61
68
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
62
69
  <circle cx="11" cy="11" r="8" />
@@ -130,8 +137,11 @@
130
137
  </div>
131
138
  </div>
132
139
  <form id="chat-form" class="composer">
133
- <div id="slash-menu" class="slash-menu" hidden></div>
134
- <div id="attach-preview" class="attach-preview" hidden></div>
140
+ <div id="slash-menu" class="slash-menu" hidden></div>
141
+ <div id="toasts" class="toasts" aria-live="polite"></div>
142
+ <div id="perm-banner" class="perm-banner" role="alert" aria-live="assertive" hidden></div>
143
+ <div id="attach-preview" class="attach-preview" hidden></div>
144
+ <div id="chat-queue" class="chat-queue" hidden></div>
135
145
  <div class="composer-row">
136
146
  <span class="prompt-mark">❯</span>
137
147
  <button type="button" id="attach-image" class="btn icon" title="Adjuntar imagen (o pegala / arrastrala)" aria-label="adjuntar imagen">
@@ -155,6 +165,8 @@
155
165
  <span id="sb-model">—</span>
156
166
  <span class="dot">·</span>
157
167
  <span id="sb-activity" class="sb-activity" hidden></span>
168
+ <span id="sb-sync" class="sb-channel retrying" hidden title="sin sincronía con el server"></span>
169
+ <span id="sb-channel" class="sb-channel" hidden title="canal de eventos entre dispositivos"></span>
158
170
  <span id="sb-rounds">listo</span>
159
171
  <span class="spacer"></span>
160
172
  <span id="sb-context" class="ctx muted" title="uso del contexto del modelo">
@@ -172,6 +184,7 @@
172
184
  <button type="button" class="xtab is-active" data-xp="files">estructura</button>
173
185
  <button type="button" class="xtab" data-xp="preview">preview</button>
174
186
  <button type="button" class="xtab" data-xp="procs">procs</button>
187
+ <button type="button" class="xtab" data-xp="git">git</button>
175
188
  </div>
176
189
  <span class="spacer"></span>
177
190
  <button id="explorer-refresh" type="button" class="btn icon" title="refrescar" aria-label="refrescar">
@@ -234,6 +247,18 @@
234
247
  <ul id="procs-list" class="providers"></ul>
235
248
  <pre id="procs-log" class="procs-log"></pre>
236
249
  </div>
250
+
251
+ <div id="xp-git" class="xp-pane" hidden>
252
+ <div class="xp-bar">
253
+ <span id="git-branch" class="git-branch">…</span>
254
+ <span id="git-sync" class="muted grow"></span>
255
+ </div>
256
+ <div class="explorer-filter">
257
+ <input id="git-filter" placeholder="filtrar por nombre…" spellcheck="false" />
258
+ </div>
259
+ <ul id="git-list" class="git-list"></ul>
260
+ <div id="git-status" class="explorer-status"></div>
261
+ </div>
237
262
  </aside>
238
263
 
239
264
  <button id="sidebar-handle" type="button" class="panel-handle panel-handle-left" title="mostrar sesiones" aria-label="mostrar panel de sesiones" aria-pressed="false">
@@ -280,6 +305,23 @@
280
305
  </div>
281
306
  </div>
282
307
  </dialog>
308
+
309
+ <dialog id="git-modal" class="modal file-modal">
310
+ <div class="modal-card">
311
+ <div class="row between">
312
+ <h3 id="git-title">cambios</h3>
313
+ <div class="file-actions">
314
+ <span id="git-meta" class="muted"></span>
315
+ <button id="git-close" type="button" class="btn icon" title="cerrar" aria-label="cerrar">
316
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
317
+ <path d="M18 6 6 18M6 6l12 12" />
318
+ </svg>
319
+ </button>
320
+ </div>
321
+ </div>
322
+ <pre id="git-diff" class="file-preview git-diff"></pre>
323
+ </div>
324
+ </dialog>
283
325
  </section>
284
326
 
285
327
  <section id="view-providers" class="view">
@@ -364,6 +406,7 @@
364
406
  <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
365
407
  <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
366
408
  <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
409
+ <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
367
410
  <button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
368
411
  </nav>
369
412
 
@@ -486,6 +529,52 @@
486
529
  </div>
487
530
  </div>
488
531
 
532
+ <div class="panel ajustes-panel" data-panel="voz">
533
+ <div class="row between">
534
+ <h2>Voz</h2>
535
+ <span id="voz-status" class="summary">cargando…</span>
536
+ </div>
537
+ <p class="muted verify-line">
538
+ El agente cuenta lo que hace con frases cortas (voces neuronales de Edge, sin API
539
+ key): arranque, herramientas y cierre. No lee la respuesta entera. En Telegram llega
540
+ como nota de voz al terminar.
541
+ </p>
542
+ <label class="check" title="hablar mientras trabaja">
543
+ <input id="voz-enabled" type="checkbox" /> Hablar mientras trabaja
544
+ </label>
545
+ <label class="field"><span>voz</span>
546
+ <select id="voz-voice" title="voz del catálogo de Edge"></select>
547
+ </label>
548
+ <label class="field"><span>velocidad</span>
549
+ <select id="voz-rate" title="velocidad del habla">
550
+ <option value="-20%">más lenta</option>
551
+ <option value="+0%">normal</option>
552
+ <option value="+20%">más rápida</option>
553
+ </select>
554
+ </label>
555
+ <div class="voz-announce">
556
+ <span class="muted">qué cuenta:</span>
557
+ <label class="check" title="avisar al arrancar el turno">
558
+ <input id="voz-ann-start" type="checkbox" /> el arranque
559
+ </label>
560
+ <label class="check" title="avisar las herramientas (como mucho una cada 20 s)">
561
+ <input id="voz-ann-tools" type="checkbox" /> las herramientas
562
+ </label>
563
+ <label class="check" title="avisar al terminar">
564
+ <input id="voz-ann-end" type="checkbox" /> el cierre
565
+ </label>
566
+ </div>
567
+ <p id="voz-ffmpeg" class="muted" hidden>
568
+ Sin <code>ffmpeg</code> en el PATH, en Telegram llega como audio MP3 en vez de nota
569
+ de voz.
570
+ </p>
571
+ <div class="row">
572
+ <button id="voz-save" class="btn primary" type="button">guardar</button>
573
+ <button id="voz-test" class="btn" type="button">probar voz</button>
574
+ <span id="voz-msg" class="summary"></span>
575
+ </div>
576
+ </div>
577
+
489
578
  <div class="panel ajustes-panel" data-panel="limites">
490
579
  <div class="row between">
491
580
  <h2>Límites del agente</h2>
@@ -667,6 +756,6 @@
667
756
  </div>
668
757
  </dialog>
669
758
 
670
- <script type="module" src="/app.js"></script>
759
+ <script type="module" src="app.js"></script>
671
760
  </body>
672
761
  </html>
@@ -450,8 +450,8 @@
450
450
  <h1>Tamnora<br /><span>Harness</span></h1>
451
451
 
452
452
  <p class="hero-copy">
453
- Un harness simple y centralizado para orquestar agentes de IA, gestionar procesos y mantener
454
- el control de tu operación desde cualquier dispositivo.
453
+ Un harness local para orquestar agentes de IA, ejecutar tools en tu workspace y mantener el
454
+ control desde tu máquina o el celular (túnel + QR o Telegram).
455
455
  </p>
456
456
 
457
457
  <div class="actions">
@@ -462,15 +462,15 @@
462
462
  <div class="features" id="caracteristicas">
463
463
  <div class="feature">
464
464
  <div class="feature-icon">◈</div>
465
- <div><b>Centralizado</b><span>Tus agentes y flujos en un solo lugar.</span></div>
465
+ <div><b>Local</b><span>Corre en tu máquina; el estado vive en tu casa.</span></div>
466
466
  </div>
467
467
  <div class="feature">
468
468
  <div class="feature-icon">↗</div>
469
- <div><b>Accesible</b><span>Disponible desde cualquier dispositivo.</span></div>
469
+ <div><b>Accesible</b><span>Consola web, TUI y Telegram; túnel con QR.</span></div>
470
470
  </div>
471
471
  <div class="feature">
472
472
  <div class="feature-icon">✓</div>
473
- <div><b>Control</b><span>Ejecuciones claras y trazables.</span></div>
473
+ <div><b>Control</b><span>Permisos allow/ask/deny y runs trazables.</span></div>
474
474
  </div>
475
475
  </div>
476
476
  </section>
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Reconciliacion entre el estado remoto (`GET /api/state`) y el local.
3
+ *
4
+ * Es puro a proposito: sin DOM, sin fetch, sin timers. Los tres intentos
5
+ * anteriores de arreglar "la consola no ve lo que pasa en otro dispositivo"
6
+ * fallaron porque esa logica vivia enterrada en un `app.js` de miles de lineas
7
+ * sin un solo test, asi que cada fix era una hipotesis. Este modulo se testea
8
+ * con `node:test` y cubre los casos que nos quemaron.
9
+ *
10
+ * Principio: los streams SSE son un **atajo de latencia**, nunca la verdad. La
11
+ * verdad se lee con un request corto, que es lo unico que sobrevive a un tunel
12
+ * con 4G (y a iOS matando los timers al mandar la pestana a background).
13
+ */
14
+
15
+ /** Estado local minimo: que sesiones tiene abiertas la consola y como. */
16
+ export function localState({ panes, asks }) {
17
+ return {
18
+ panes: [...panes].map(([id, pane]) => ({
19
+ id,
20
+ working: Boolean(pane.working),
21
+ attached: Boolean(pane.attached),
22
+ })),
23
+ asks: [...asks],
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Compara el estado remoto con el local y dice que hay que hacer.
29
+ *
30
+ * @param {{panes: Array, asks: Array<string>}} local
31
+ * @param {{sessions: Array, permissions: Array}} remote
32
+ */
33
+ export function diffState(local, remote) {
34
+ const sessions = remote?.sessions ?? [];
35
+ const permissions = remote?.permissions ?? [];
36
+ const panes = new Map((local?.panes ?? []).map((pane) => [pane.id, pane]));
37
+ const knownAsks = new Set(local?.asks ?? []);
38
+ const serverAsks = new Set(permissions.map((ask) => ask.askId));
39
+
40
+ const toAttach = [];
41
+ const toDetach = [];
42
+ const gone = [];
43
+
44
+ for (const session of sessions) {
45
+ const busy = session.running || session.awaiting > 0;
46
+ const pane = panes.get(session.id);
47
+ if (!pane) continue;
48
+ const attached = pane.working && pane.attached;
49
+ // Un permiso pendiente obliga a estar enganchado aunque `running` sea false:
50
+ // el permiso no se reconstruye desde los mensajes, vive en /api/state.
51
+ if (busy && !attached) toAttach.push(session.id);
52
+ else if (!busy && attached) toDetach.push(session.id);
53
+ }
54
+
55
+ for (const id of panes.keys()) {
56
+ if (!sessions.some((session) => session.id === id)) gone.push(id);
57
+ }
58
+
59
+ const newAsks = permissions.filter((ask) => !knownAsks.has(ask.askId));
60
+ const resolvedAsks = [...knownAsks].filter((askId) => !serverAsks.has(askId));
61
+
62
+ return {
63
+ toAttach,
64
+ toDetach,
65
+ gone,
66
+ newAsks,
67
+ resolvedAsks,
68
+ // Los permisos siguen esperando: el banner no se baja.
69
+ pending: permissions,
70
+ // Para repintar insignias sin volver a pegarle al server.
71
+ sessions,
72
+ remoteActive: sessions.filter((session) => session.running || session.awaiting > 0).map((session) => session.id),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Cada cuanto preguntar. Un permiso pendiente es algo que te esta esperando:
78
+ * ahí la latencia se nota y se paga. Idle, se aburre al server.
79
+ */
80
+ export function pollDelayMs(state) {
81
+ const permissions = state?.permissions?.length ?? 0;
82
+ const running = (state?.sessions ?? []).some((session) => session.running);
83
+ if (permissions > 0) return 5000;
84
+ if (running) return 10000;
85
+ return 25000;
86
+ }
87
+
88
+ export function humanSize(text) {
89
+ const bytes = new TextEncoder().encode(String(text ?? '')).length;
90
+ if (bytes < 1024) return `${bytes} B`;
91
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
92
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
93
+ }
94
+
95
+ /**
96
+ * Resumen legible de los argumentos de una tool, para un permiso.
97
+ *
98
+ * Volcar el JSON crudo era inutil: al pedir permiso para `write` se veia medio
99
+ * archivo entero con los `\n` escapados, que es justo lo contrario de una
100
+ * pantalla hecha para decidir. Para `bash` lo que interesa es el comando; para
101
+ * las tools de archivo, la ruta y cuanto se esta escribiendo.
102
+ */
103
+ export function summarizeArgs(name, args, max = 240) {
104
+ const cut = (value) => {
105
+ const text = String(value ?? '');
106
+ return text.length > max ? `${text.slice(0, max)}…` : text;
107
+ };
108
+ const a = args ?? {};
109
+ if (name === 'bash' && a.command) return cut(a.command);
110
+ if ((name === 'write' || name === 'edit' || name === 'read') && a.path) {
111
+ return a.content ? `${a.path} · ${humanSize(a.content)}` : String(a.path);
112
+ }
113
+ if ((name === 'fetch_url' || name === 'preview_url') && a.url) return cut(a.url);
114
+ if (name === 'task' && (a.description || a.prompt)) return cut(a.description || a.prompt);
115
+ const keys = Object.keys(a);
116
+ if (keys.length === 1 && typeof a[keys[0]] === 'string') return cut(a[keys[0]]);
117
+ if (!keys.length) return '';
118
+ // Cualquier otra tool: el JSON, pero en una linea para que no aparezca un
119
+ // muro con los saltos de linea escapados.
120
+ return cut(JSON.stringify(a).replace(/\\n/g, ' '));
121
+ }
@@ -0,0 +1,170 @@
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
+ }