@danieltmn/tamnora 0.8.0 → 0.9.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/harness/packages/cli/src/cli.js +1 -1
  3. package/harness/packages/cli/src/commands/bridge.js +180 -119
  4. package/harness/packages/cli/src/commands/server.js +26 -50
  5. package/harness/packages/cli/src/lib/daemon.js +92 -40
  6. package/harness/packages/cli/test/release.test.js +9 -0
  7. package/harness/packages/core/src/agent/modes.js +1 -1
  8. package/harness/packages/core/src/index.js +14 -1
  9. package/harness/packages/core/src/open.js +43 -0
  10. package/harness/packages/core/src/pid.js +35 -0
  11. package/harness/packages/core/src/tools/executors.js +16 -0
  12. package/harness/packages/core/src/tools/labels.js +2 -0
  13. package/harness/packages/core/src/tools/permissions.js +1 -0
  14. package/harness/packages/core/src/tools/schema.js +17 -0
  15. package/harness/packages/core/src/tts/edge.js +232 -0
  16. package/harness/packages/core/src/tunnel.js +180 -19
  17. package/harness/packages/core/test/open.test.js +19 -0
  18. package/harness/packages/core/test/tools.test.js +12 -0
  19. package/harness/packages/core/test/tts.test.js +41 -0
  20. package/harness/packages/server/public/app.js +4665 -3975
  21. package/harness/packages/server/public/index.html +62 -5
  22. package/harness/packages/server/public/reconcile.js +121 -0
  23. package/harness/packages/server/public/relayClient.js +170 -0
  24. package/harness/packages/server/public/runStream.js +207 -0
  25. package/harness/packages/server/public/styles.css +142 -5
  26. package/harness/packages/server/src/auth.js +29 -5
  27. package/harness/packages/server/src/lib/agentRun.js +77 -0
  28. package/harness/packages/server/src/lib/announcer.js +92 -0
  29. package/harness/packages/server/src/lib/approvals.js +25 -3
  30. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  31. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  32. package/harness/packages/server/src/lib/procs.js +3 -18
  33. package/harness/packages/server/src/lib/runManager.js +24 -2
  34. package/harness/packages/server/src/lib/sessionView.js +43 -0
  35. package/harness/packages/server/src/lib/tasks.js +4 -9
  36. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  37. package/harness/packages/server/src/lib/workspace.js +37 -1
  38. package/harness/packages/server/src/routes/chat.js +48 -47
  39. package/harness/packages/server/src/routes/events.js +35 -0
  40. package/harness/packages/server/src/routes/files.js +22 -26
  41. package/harness/packages/server/src/routes/folders.js +9 -17
  42. package/harness/packages/server/src/routes/git.js +6 -14
  43. package/harness/packages/server/src/routes/procs.js +14 -16
  44. package/harness/packages/server/src/routes/sessions.js +12 -19
  45. package/harness/packages/server/src/routes/shell.js +5 -9
  46. package/harness/packages/server/src/routes/state.js +32 -0
  47. package/harness/packages/server/src/routes/tts.js +82 -0
  48. package/harness/packages/server/src/server.js +21 -1
  49. package/harness/packages/server/test/announcer.test.js +51 -0
  50. package/harness/packages/server/test/reconcile.test.js +146 -0
  51. package/harness/packages/server/test/relay.test.js +219 -0
  52. package/harness/packages/server/test/runStream.test.js +245 -0
  53. package/harness/packages/server/test/security.test.js +252 -0
  54. package/harness/packages/server/test/server.test.js +511 -1
  55. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  56. package/harness/packages/server/test/tts.test.js +133 -0
  57. 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.8.0</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.1</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,9 @@
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="attach-preview" class="attach-preview" hidden></div>
135
143
  <div id="chat-queue" class="chat-queue" hidden></div>
136
144
  <div class="composer-row">
137
145
  <span class="prompt-mark">❯</span>
@@ -156,6 +164,8 @@
156
164
  <span id="sb-model">—</span>
157
165
  <span class="dot">·</span>
158
166
  <span id="sb-activity" class="sb-activity" hidden></span>
167
+ <span id="sb-sync" class="sb-channel retrying" hidden title="sin sincronía con el server"></span>
168
+ <span id="sb-channel" class="sb-channel" hidden title="canal de eventos entre dispositivos"></span>
159
169
  <span id="sb-rounds">listo</span>
160
170
  <span class="spacer"></span>
161
171
  <span id="sb-context" class="ctx muted" title="uso del contexto del modelo">
@@ -395,6 +405,7 @@
395
405
  <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
396
406
  <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
397
407
  <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
408
+ <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
398
409
  <button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
399
410
  </nav>
400
411
 
@@ -517,6 +528,52 @@
517
528
  </div>
518
529
  </div>
519
530
 
531
+ <div class="panel ajustes-panel" data-panel="voz">
532
+ <div class="row between">
533
+ <h2>Voz</h2>
534
+ <span id="voz-status" class="summary">cargando…</span>
535
+ </div>
536
+ <p class="muted verify-line">
537
+ El agente cuenta lo que hace con frases cortas (voces neuronales de Edge, sin API
538
+ key): arranque, herramientas y cierre. No lee la respuesta entera. En Telegram llega
539
+ como nota de voz al terminar.
540
+ </p>
541
+ <label class="check" title="hablar mientras trabaja">
542
+ <input id="voz-enabled" type="checkbox" /> Hablar mientras trabaja
543
+ </label>
544
+ <label class="field"><span>voz</span>
545
+ <select id="voz-voice" title="voz del catálogo de Edge"></select>
546
+ </label>
547
+ <label class="field"><span>velocidad</span>
548
+ <select id="voz-rate" title="velocidad del habla">
549
+ <option value="-20%">más lenta</option>
550
+ <option value="+0%">normal</option>
551
+ <option value="+20%">más rápida</option>
552
+ </select>
553
+ </label>
554
+ <div class="voz-announce">
555
+ <span class="muted">qué cuenta:</span>
556
+ <label class="check" title="avisar al arrancar el turno">
557
+ <input id="voz-ann-start" type="checkbox" /> el arranque
558
+ </label>
559
+ <label class="check" title="avisar las herramientas (como mucho una cada 20 s)">
560
+ <input id="voz-ann-tools" type="checkbox" /> las herramientas
561
+ </label>
562
+ <label class="check" title="avisar al terminar">
563
+ <input id="voz-ann-end" type="checkbox" /> el cierre
564
+ </label>
565
+ </div>
566
+ <p id="voz-ffmpeg" class="muted" hidden>
567
+ Sin <code>ffmpeg</code> en el PATH, en Telegram llega como audio MP3 en vez de nota
568
+ de voz.
569
+ </p>
570
+ <div class="row">
571
+ <button id="voz-save" class="btn primary" type="button">guardar</button>
572
+ <button id="voz-test" class="btn" type="button">probar voz</button>
573
+ <span id="voz-msg" class="summary"></span>
574
+ </div>
575
+ </div>
576
+
520
577
  <div class="panel ajustes-panel" data-panel="limites">
521
578
  <div class="row between">
522
579
  <h2>Límites del agente</h2>
@@ -698,6 +755,6 @@
698
755
  </div>
699
756
  </dialog>
700
757
 
701
- <script type="module" src="/app.js"></script>
758
+ <script type="module" src="app.js"></script>
702
759
  </body>
703
760
  </html>
@@ -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
+ }
@@ -0,0 +1,207 @@
1
+ // Streaming SSE resumible para la consola.
2
+ //
3
+ // El tunel (o un proxy) puede cortar la conexion sin que el `fetch` se entere:
4
+ // `reader.read()` queda esperando para siempre y el turno se queda "pensando..."
5
+ // sin fin. Estos helpers agregan un watchdog de silencio, recuerdan el cursor
6
+ // (`id:`) de cada frame para reanudar donde se quedo, y reintentan con backoff.
7
+
8
+ // El server manda un comentario `: ping` cada 15 s (lib/http.js). Tres pings
9
+ // perdidos ya no son transitorios: algo se murio.
10
+ export const SILENCE_MS = 45000;
11
+
12
+ const DEFAULT_BACKOFF = [1000, 2000, 4000, 8000, 10000];
13
+
14
+ function abortError() {
15
+ return new DOMException('Aborted', 'AbortError');
16
+ }
17
+
18
+ /** Espera `ms` abortable: si el caller cancela, no hay que esperar al timer. */
19
+ export function delay(ms, signal) {
20
+ return new Promise((resolve, reject) => {
21
+ if (signal?.aborted) return reject(abortError());
22
+ const timer = setTimeout(() => {
23
+ signal?.removeEventListener('abort', onAbort);
24
+ resolve();
25
+ }, ms);
26
+ function onAbort() {
27
+ clearTimeout(timer);
28
+ reject(abortError());
29
+ }
30
+ signal?.addEventListener('abort', onAbort, { once: true });
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Combina varias senales en una. No usamos `AbortSignal.any` porque en Android
36
+ * Chrome viejo no existe y la consola se abre mucho desde el celular.
37
+ */
38
+ export function linkSignals(signals) {
39
+ const list = signals.filter(Boolean);
40
+ if (!list.length) return { signal: undefined, abort: () => {} };
41
+ if (list.length === 1) return { signal: list[0], abort: () => list[0].abort() };
42
+ const controller = new AbortController();
43
+ const onAbort = (event) => controller.abort(event.target.reason);
44
+ for (const signal of list) {
45
+ if (signal.aborted) {
46
+ controller.abort(signal.reason);
47
+ break;
48
+ }
49
+ signal.addEventListener('abort', onAbort, { once: true });
50
+ }
51
+ return { signal: controller.signal, abort: () => controller.abort() };
52
+ }
53
+
54
+ async function httpError(res) {
55
+ let message = res.statusText;
56
+ try {
57
+ message = (await res.json())?.error ?? message;
58
+ } catch {
59
+ // nos quedamos con statusText
60
+ }
61
+ const error = new Error(message || `HTTP ${res.status}`);
62
+ error.status = res.status;
63
+ return error;
64
+ }
65
+
66
+ /** El stream quedo mudo: cortamos por nuestra cuenta, no el servidor. */
67
+ function silentError(from) {
68
+ const error = new Error('La conexion quedo muda (se corto el stream).');
69
+ error.name = 'SilentStream';
70
+ error.resumable = true;
71
+ error.from = from;
72
+ return error;
73
+ }
74
+
75
+ /**
76
+ * Lee un stream SSE y entrega `(event, data, id)` por frame.
77
+ *
78
+ * Corta si pasan `silenceMs` sin bytes. Al hacerlo tira un error `SilentStream`
79
+ * con `from` = ultimo cursor recibido, para que el llamador pueda reanudar
80
+ * desde ahi en vez de desde cero.
81
+ */
82
+ export async function streamSse(
83
+ url,
84
+ { method = 'GET', body, csrf, signal, silenceMs = SILENCE_MS, fetchImpl = fetch } = {},
85
+ onEvent
86
+ ) {
87
+ const headers = {};
88
+ if (body) headers['Content-Type'] = 'application/json';
89
+ if (method !== 'GET' && csrf) headers['x-csrf'] = csrf;
90
+
91
+ const watchdog = new AbortController();
92
+ let lastId = null;
93
+ let timer = null;
94
+ let silent = false;
95
+ const linked = linkSignals([signal, watchdog.signal]);
96
+ const arm = () => {
97
+ if (timer) clearTimeout(timer);
98
+ timer = setTimeout(() => {
99
+ silent = true;
100
+ watchdog.abort();
101
+ }, silenceMs);
102
+ };
103
+
104
+ try {
105
+ const res = await fetchImpl(url, {
106
+ method,
107
+ headers,
108
+ body: body ? JSON.stringify(body) : undefined,
109
+ signal: linked.signal,
110
+ });
111
+ if (!res.ok) throw await httpError(res);
112
+ const reader = res.body.getReader();
113
+ const decoder = new TextDecoder();
114
+ let buffer = '';
115
+ arm();
116
+ for (;;) {
117
+ const { value, done } = await reader.read();
118
+ // Cualquier byte cuenta como vida: el heartbeat es un comentario SSE y
119
+ // nunca llega a `onEvent`.
120
+ arm();
121
+ if (done) break;
122
+ buffer += decoder.decode(value, { stream: true });
123
+ let index;
124
+ while ((index = buffer.indexOf('\n\n')) !== -1) {
125
+ const frame = buffer.slice(0, index);
126
+ buffer = buffer.slice(index + 2);
127
+ let event = null;
128
+ let data = '';
129
+ let id = null;
130
+ for (const line of frame.split('\n')) {
131
+ if (line.startsWith('event:')) event = line.slice(6).trim();
132
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
133
+ else if (line.startsWith('id:')) id = line.slice(3).trim();
134
+ }
135
+ if (id !== null) lastId = id;
136
+ // Frame de comentario (`: ping`): el watchdog ya lo conto como vida,
137
+ // pero no hay nada que entregar.
138
+ if (!event && !data) continue;
139
+ let parsed = {};
140
+ if (data) {
141
+ try {
142
+ parsed = JSON.parse(data);
143
+ } catch {
144
+ parsed = { raw: data };
145
+ }
146
+ }
147
+ try {
148
+ onEvent(event || 'message', parsed, id);
149
+ } catch (error) {
150
+ // Un error del handler NO es una caida del transporte: si lo dejamos
151
+ // pasar, `followRun` reintenta en bucle y el sintoma es un
152
+ // "reconectando..." eterno que no dice nada. Se propaga con su tipo
153
+ // para que el llamador pueda distinguirlo y no reintentar.
154
+ error.eventHandler = true;
155
+ throw error;
156
+ }
157
+ }
158
+ }
159
+ if (silent) throw silentError(lastId);
160
+ } catch (error) {
161
+ // Si el watchdog fue el que corto, el AbortError obscurece la causa.
162
+ if (silent && error?.name === 'AbortError') throw silentError(lastId);
163
+ throw error;
164
+ } finally {
165
+ if (timer) clearTimeout(timer);
166
+ }
167
+ }
168
+
169
+ export function backoffDelay(index, backoff = DEFAULT_BACKOFF) {
170
+ const step = backoff[Math.min(Math.max(index, 0), backoff.length - 1)];
171
+ return Number(step) || 1000;
172
+ }
173
+
174
+ /**
175
+ * Reintenta `open()` con backoff hasta que el run termine o se agoten los
176
+ * intentos.
177
+ *
178
+ * `open(cursor)` hace una conexion y devuelve `{ finished, cursor }` cuando el
179
+ * turno acaba, o tira error si el transporte se cayo. Los `AbortError` nunca se
180
+ * reintentan: son el usuario cancelando o el boton `parar`. Tampoco los errores
181
+ * marcados con `eventHandler`: esos son un bug de la consola, y reintentarlos
182
+ * solo tapa el sintoma y quema el backoff.
183
+ *
184
+ * `from` es el cursor inicial: se pasa el ultimo `id:` conocido cuando ya
185
+ * estabamos escuchando este turno y lo que se perdio esta despues.
186
+ */
187
+ export async function followRun({ open, onRetry = null, backoff = DEFAULT_BACKOFF, attempts = Infinity, signal, from = 0, sleep = delay }) {
188
+ let cursor = Number(from) || 0;
189
+ let attempt = 0;
190
+ for (;;) {
191
+ if (signal?.aborted) throw abortError();
192
+ try {
193
+ const result = await open(cursor, signal);
194
+ if (result?.cursor !== undefined) cursor = result.cursor;
195
+ return { finished: true, cursor, attempts: attempt };
196
+ } catch (error) {
197
+ if (error?.name === 'AbortError' || error?.eventHandler) throw error;
198
+ // El error sabe hasta donde llegamos: preferimos su cursor.
199
+ if (error?.from != null) cursor = Number(error.from) || cursor;
200
+ attempt += 1;
201
+ if (attempt > attempts) throw error;
202
+ const wait = backoffDelay(attempt - 1, backoff);
203
+ onRetry?.({ attempt, delay: wait, error, cursor });
204
+ await sleep(wait, signal);
205
+ }
206
+ }
207
+ }