@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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,101 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.9.0] - 2026-10-07
9
+
10
+ ### Agregado
11
+
12
+ - **Voz (TTS)**: el agente **narra su progreso** con frases cortas armadas por
13
+ plantillas sobre los eventos del run (nada de leer la respuesta) y las adjunta
14
+ como `say`. La consola las reproduce y Telegram manda una **nota de voz** al
15
+ cerrar el turno. El cliente (`core/src/tts/edge.js`) usa el **WebSocket global
16
+ de Node** contra las voces de Edge Read Aloud: gratis, sin API key ni
17
+ dependencias. Ajustes en la pestaña Voz (`app.json` -> `tts`).
18
+ - **Tool `speak`**: el agente puede pedir leer un texto en voz alta (por ejemplo,
19
+ un resumen en audio cuando se lo pedís). La consola lo reproduce en la tarjeta
20
+ del tool y Telegram lo manda como nota de voz.
21
+ - **`GET /api/state`, la verdad del servidor**: un snapshot chico con
22
+ `running`/`awaiting` de cada sesion y los permisos pendientes, en un solo
23
+ request y **sin mensajes**. Antes la consola necesitaba dos o tres round trips
24
+ por ciclo, y sobre el tunel cada uno es caro y una oportunidad de fallar.
25
+ - **Reconciliador con la logica testeada** (`public/reconcile.js` +
26
+ `test/reconcile.test.js`): `diffState` es puro y decide enganchar, soltar,
27
+ recargar y que permisos son nuevos o resueltos. Los SSE pasaron a ser **atajos
28
+ de latencia**: si se caen se pierde inmediatez, no correctitud. Antes la misma
29
+ decision estaba repartida en el canal global, un sweep y el handler de
30
+ permisos, y se contradijian.
31
+ - **Cadencia adaptativa**: 5 s cuando hay un permiso esperando una decision tuya,
32
+ 10 s con un run activo, 25 s idle.
33
+ - **Banner de permisos** (`#perm-banner`): no descartable, en el composer, con
34
+ `denegar` / `permitir una vez` / `autorizar todo`. Se puede decidir **desde
35
+ cualquier pestana o sesion**, sin estar enganchado al run.
36
+ - **Los argumentos de un permiso se leen en una linea**: `summarizeArgs` (puro,
37
+ en `reconcile.js`) muestra el comando de `bash`, la ruta y el tamano de `write`,
38
+ o la url de `fetch_url`. Antes se volcaba el JSON crudo y al pedir permiso para
39
+ escribir un archivo se veia medio archivo con los `\n` escapados.
40
+ - **Señales cuando la pestana esta en background**: `document.title` con `(⚠ n)`
41
+ y favicon con punto rojo. Con iOS mandando la app a background son las unicas
42
+ que quedan.
43
+ - **El server abre la consola en el navegador**: al quedar escuchando, `tamnora
44
+ server` abre la URL en el navegador predeterminado (tambien cuando ya estaba
45
+ corriendo). Se apaga con `--no-open`. New `core/src/open.js`; en Windows usa
46
+ `rundll32` para no pasar la URL por un shell.
47
+ - **SSE resumible** (`server/public/runStream.js`): el watchdog de silencio (45 s;
48
+ 30 s para el canal global) detecta el stream colgado en el que
49
+ `reader.read()` espera para siempre —tipico en un corte de tunel— y reconecta
50
+ con backoff desde el ultimo cursor, sin repintar el pane.
51
+ - **Canal global de eventos** (`GET /api/events`): un SSE por pestana con
52
+ `run_start`/`run_end`, permisos y un `snapshot`. Sirve para enterarse al
53
+ instante de una tarea pedida desde otro dispositivo.
54
+ - **Varios clientes en la misma sesion**: al pedir una tarea mientras otro cliente
55
+ tiene el turno, el mensaje va a la cola visible en vez de desaparecer; un
56
+ cliente observador ahora dibuja el mensaje del usuario (se emitia antes de
57
+ persistirse).
58
+
59
+ ### Corregido
60
+
61
+ - **Aprobar un permiso ya no lo hace reaparecer en bucle**: `decide()` se
62
+ tragaba el error del `POST` (`.catch(() => {})`). La UI se cerraba igual,
63
+ borraba el permiso local y el siguiente snapshot lo volvia a traer porque el
64
+ server seguia tenerlo pendiente: la tarjeta reaparecia una y otra vez sin
65
+ decir por que. Ahora el fallo **se avisa y se revierte**, y un 404 se
66
+ distingue como "ese permiso ya no aplicaba" en vez de dejarlo esperando.
67
+ - **Los permisos ya no se esconden**: eran estado invisible que solo existia
68
+ dentro del stream SSE de su sesion. Con el celular sin ese stream (o con el
69
+ tunel caido) la IA se quedaba esperando una decision que no aparecia en ningun
70
+ lado, y recargar la pagina dejaba el run bloqueado para siempre. Ahora son
71
+ estado de primera clase y se pueden resolver desde donde estes mirando.
72
+ - **Un permiso ya no desaparece al re-renderizar**: `renderPane` borra el pane
73
+ entero y los permisos no son mensajes, asi que la tarjeta se ve y al primer
74
+ re-render se perdia para siempre. Ahora se repintan.
75
+ - **Los fallos de red se ven**: cada camino era un `.catch(() => null)`, con lo
76
+ que un fallo se veía **exactamente igual** que "no hay nada pendiente" (y por eso
77
+ no se podia diagnosticar nada). Ahora hay backoff, `#sb-sync` con el motivo en
78
+ el tooltip y avisos flotantes.
79
+ - **Un error de la consola ya no se disfraza de corte de red**: si el handler de
80
+ un evento tiraba una excepcion, `streamSse` la propagaba como si fuera una
81
+ caida del transporte y se reintentaba en bucle — el sintoma era un
82
+ "reconectando..." perpetuo que no contaba nada. Ahora se propaga marcado como
83
+ error del handler, no se reintenta y se avisa con el mensaje real.
84
+ - **`tamnora server --stop` podia quedar colgado**: `close()` nunca destruia los
85
+ sockets SSE abiertos, asi que esperaba indefinidamente.
86
+ - **Cierre del server**: la base SQLite se cerraba antes de cortar las
87
+ conexiones, y un run abortado terminaba escribiendo sobre una base cerrada.
88
+ - Se mano `attachRun` sin reconexion: un corte de tunel dejaba el turno en
89
+ "pensando..." para siempre sin error que reintentar.
90
+
91
+ ## [0.8.0] - 2026-10-06
92
+
93
+ ### Documentacion
94
+
95
+ - **Revision de docs y sitio**: se audito cada afirmacion contra el codigo y se
96
+ corrigieron varias inexactas (las API keys **no** se cifran —van en texto plano
97
+ en SQLite—; `--max-steps` no tiene default `8`; "nada se sube a terceros" es
98
+ falso si se elige un proveedor remoto). Se sumaron al sitio y a la guia las
99
+ features de 0.7.1 (tablas markdown, saltos de scroll, agente autonomo y MCP
100
+ con entorno confinado/redaccion). Actualiza `README.md`, `document.md`,
101
+ `AGENTS.md`, `SECURITY.md`, `website/` y la landing.
102
+
8
103
  ## [0.7.1] - 2026-10-06
9
104
 
10
105
  ### Agregado
package/README.md CHANGED
@@ -84,6 +84,10 @@ menu "mas" (los tres puntitos). En el chat:
84
84
  - **Actividad colapsable**: las tools de lectura (`read`, `list`, `glob`, `grep`,
85
85
  `fetch_url`, `git_*`) salen **plegadas** y al hacer click se ve el detalle
86
86
  (args + resultado); el volcado completo se pide bajo demanda.
87
+ - **Markdown con tablas**: las respuestas renderizan tablas (`|`, con alineacion
88
+ y celdas saneadas) y botones flotantes para saltar al inicio/final del turno.
89
+ - **Acciones por mensaje**: copiar, editar, regenerar y borrar se agrupan en un
90
+ menu `⋮`; el indicador de "pensando/trabajando" vive en el pie (`#sb-activity`).
87
91
  - **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
88
92
  volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
89
93
  el boton de detener (o `POST /api/sessions/:id/stop`).
@@ -149,6 +153,11 @@ stdio. Las tools se exponen como `mcp__<servidor>__<tool>` y, si el servidor los
149
153
  anuncia, tambien sus **recursos** y **prompts** (`list_resources`,
150
154
  `read_resource`, `list_prompts`, `get_prompt`).
151
155
 
156
+ Los MCP stdio **no heredan `process.env`**: reciben un baseline minimo mas el
157
+ `env` declarado (`"KEY": "env:VAR"`), y sus resultados pasan por `redactSecrets`
158
+ (ver [`SECURITY.md`](SECURITY.md)). Para un servidor de confianza que necesite el
159
+ entorno completo, `"inheritEnv": true`.
160
+
152
161
  ## Tools
153
162
 
154
163
  Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
package/SECURITY.md CHANGED
@@ -15,12 +15,17 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
15
15
  - **Local (loopback)**: quien ya tiene tu sesion de usuario ya puede correr
16
16
  comandos; el server no agrega una barrera adicional mas alla del login.
17
17
  - **Acceso remoto por tunel (tunnelmole)**: la URL publica es accesible desde
18
- Internet. Es obligatorio (a) una passphrase fuerte, (b) login real en toda la
19
- API, (c) limite de intentos y (d) restringir el picker de carpetas a roots
20
- permitidos. Nunca expongas un server sin passphrase.
21
- - **Credenciales**: las API keys de proveedores se guardan en la casa local del
22
- usuario (fuera del repo) y se cifran con una clave derivada de la passphrase.
23
- Nunca se commitean y se devuelven enmascaradas al cliente.
18
+ Internet. Configura un **usuario** (asi toda `/api/*` pide sesion) con una
19
+ **contrasena fuerte**; nunca expongas un server sin login. El tunnel reenvia
20
+ por loopback, de modo que una vez autenticado se alcanza toda la consola
21
+ (incluidos el explorador de archivos y el selector de carpetas): tratalo como
22
+ acceso completo a tu maquina. Cierra el tunnel (`tamnora bridge --stop`) cuando
23
+ no lo uses.
24
+ - **Credenciales**: las API keys de proveedores se guardan en la base SQLite de
25
+ la casa local (fuera del repo) **en texto plano**; protege el archivo con los
26
+ permisos del sistema. Para no persistirlas, usa `"apiKey": "env:VAR"` y
27
+ define la variable en el entorno. Nunca se commitean y se devuelven
28
+ enmascaradas al cliente (`apiKeyHint`).
24
29
  - **Servidores MCP**: tratalos como codigo de terceros. Los MCP stdio **no heredan
25
30
  `process.env`** por defecto: solo reciben un baseline minimo mas el `env` que
26
31
  declares (`"env": { "TOKEN": "env:TOKEN" }`); `inheritEnv: true` es solo para
@@ -70,7 +70,7 @@ Uso:
70
70
  tamnora run "<prompt>" Ejecutar una tarea headless
71
71
  tamnora eval <dataset.jsonl> Correr un dataset de evaluación
72
72
  tamnora mcp Listar servidores y herramientas MCP
73
- tamnora server [--init|--start|--reload|--stop|--status|--logs] Server en segundo plano
73
+ tamnora server [--init|--start|--reload|--stop|--status|--logs] Server en segundo plano (abre el navegador)
74
74
  tamnora user Usuario local (login del server)
75
75
  tamnora bridge [--start|--reload|--stop|--status] Tunel publico (tunnelmole) + QR
76
76
  tamnora autostart install|remove Arranque automatico del server
@@ -1,16 +1,23 @@
1
- import { spawn } from 'node:child_process';
2
- import { readFileSync, writeFileSync, unlinkSync, appendFileSync } from 'node:fs';
3
- import path from 'node:path';
4
1
  import {
5
- setHome,
6
2
  homeDir,
7
3
  ensureHome,
8
4
  ensureApp,
9
- bridgeStatePath,
10
- bridgeLogPath,
5
+ writeApp,
6
+ readApp,
7
+ randomToken,
11
8
  } from '../../../core/src/index.js';
12
9
  import { parse, num } from '../lib/util.js';
13
- import { killPid, pidAlive } from '../lib/daemon.js';
10
+ import {
11
+ applyHomeOverride,
12
+ killPid,
13
+ pidAlive,
14
+ bridgeState,
15
+ createFileLogger,
16
+ spawnDetached,
17
+ waitForState,
18
+ tailLines,
19
+ bridgeLogPath,
20
+ } from '../lib/daemon.js';
14
21
  import { startTunnel } from '../lib/tunnel.js';
15
22
  import { qrTerminal } from '../lib/qr.js';
16
23
 
@@ -21,6 +28,7 @@ const OPTIONS = {
21
28
  status: { type: 'boolean' },
22
29
  foreground: { type: 'boolean' },
23
30
  stream: { type: 'boolean' },
31
+ insecure: { type: 'boolean' },
24
32
  tunnel: { type: 'string' },
25
33
  port: { type: 'string' },
26
34
  home: { type: 'string' },
@@ -28,26 +36,9 @@ const OPTIONS = {
28
36
  help: { type: 'boolean', short: 'h' },
29
37
  };
30
38
 
31
- function readBridgeState() {
32
- try {
33
- return JSON.parse(readFileSync(bridgeStatePath(), 'utf8'));
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
- function writeBridgeState(data) {
40
- ensureHome();
41
- writeFileSync(bridgeStatePath(), `${JSON.stringify(data, null, 2)}\n`);
42
- }
43
-
44
- function clearBridgeState() {
45
- try {
46
- unlinkSync(bridgeStatePath());
47
- } catch {
48
- /* nada */
49
- }
50
- }
39
+ const readBridgeState = () => bridgeState.read();
40
+ const writeBridgeState = (data) => bridgeState.write(data);
41
+ const clearBridgeState = () => bridgeState.clear();
51
42
 
52
43
  export async function bridgeCommand(argv) {
53
44
  const { values } = parse(argv, OPTIONS);
@@ -56,8 +47,7 @@ export async function bridgeCommand(argv) {
56
47
  return 0;
57
48
  }
58
49
  if (values.home) {
59
- setHome(values.home);
60
- process.env.TAMNORA_HOME = path.resolve(values.home);
50
+ applyHomeOverride(values);
61
51
  }
62
52
 
63
53
  if (values.stop) return cmdStop();
@@ -75,9 +65,25 @@ function resolve(values) {
75
65
  return {
76
66
  port: num(values.port, app.port || 4517),
77
67
  provider: String(values.tunnel || app.tunnel?.provider || 'tunnelmole').toLowerCase(),
68
+ app,
78
69
  };
79
70
  }
80
71
 
72
+ /** Asegura la configuracion del relay (url + token) en app.json y la devuelve. */
73
+ function ensureRelayConfig(app) {
74
+ const tunnel = { ...app.tunnel };
75
+ if (!tunnel.relayUrl) {
76
+ tunnel.relayUrl = process.env.TAMNORA_RELAY_URL || 'https://harness.tamnora.com/index.php';
77
+ }
78
+ if (!tunnel.token) {
79
+ tunnel.token = process.env.TAMNORA_RELAY_TOKEN || randomToken(24);
80
+ const current = readApp();
81
+ writeApp({ ...current, tunnel: { ...current.tunnel, token: tunnel.token, relayUrl: tunnel.relayUrl } });
82
+ console.log(`Token de relay nuevo (guardado en app.json): ${tunnel.token}`);
83
+ }
84
+ return tunnel;
85
+ }
86
+
81
87
  async function cmdStart(values) {
82
88
  const { port, provider } = resolve(values);
83
89
  if (provider === 'none') {
@@ -85,6 +91,20 @@ async function cmdStart(values) {
85
91
  return 1;
86
92
  }
87
93
 
94
+ // Exponer el server a Internet sin usuario configurado deja /api/* abierto
95
+ // (incluido /api/chat con tool bash): hay que pedirlo explicito.
96
+ const app = ensureApp();
97
+ if (!app.user?.password && !values.insecure) {
98
+ console.error('No hay usuario configurado: por el tunel cualquiera con la URL tendria acceso total.');
99
+ console.error('Crea uno con: tamnora user --name <usuario> --password <clave>');
100
+ console.error('Si igual queres abrirlo sin proteccion: tamnora bridge --insecure');
101
+ return 1;
102
+ }
103
+ if (provider === 'tunnelmole' || provider === 'tmole') {
104
+ console.log('Aviso: tunnelmole gratuito admite una sola conexion; si abriste el');
105
+ console.log('preview de la consola, cerralo (o el bridge) antes de usar el otro.');
106
+ }
107
+
88
108
  const current = readBridgeState();
89
109
  if (current && pidAlive(current.pid)) {
90
110
  console.log(`Bridge ya esta corriendo (pid ${current.pid}).`);
@@ -96,22 +116,17 @@ async function cmdStart(values) {
96
116
  const args = [bin, 'bridge', '--foreground', '--port', String(port), '--tunnel', provider];
97
117
  if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
98
118
  if (values.verbose) args.push('--verbose');
99
- const child = spawn(process.execPath, args, {
100
- detached: true,
101
- stdio: 'ignore',
102
- windowsHide: true,
103
- env: { ...process.env, TAMNORA_BRIDGE_CHILD: '1' },
104
- });
105
- child.unref();
106
- let dead = false;
107
- child.on('exit', () => {
108
- dead = true;
109
- });
119
+ const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_BRIDGE_CHILD' });
110
120
  console.log(`Abriendo tunel (${provider}) en el puerto ${port}...`);
111
121
 
112
- const state = await waitForBridge(child.pid, () => dead, 90000);
122
+ const state = await waitForState(() => readBridgeState(), {
123
+ childPid: child.pid,
124
+ isDead,
125
+ timeoutMs: 90000,
126
+ tickMs: 500,
127
+ });
113
128
  if (!state) {
114
- const tail = tailBridgeLog(8);
129
+ const tail = tailLines(bridgeLogPath(), 8);
115
130
  if (tail) console.log(tail);
116
131
  console.log('No pude abrir el tunel. Revisa: tamnora bridge --status');
117
132
  return 1;
@@ -120,64 +135,129 @@ async function cmdStart(values) {
120
135
  return 0;
121
136
  }
122
137
 
138
+ /** URL publica para el celular: la consola agregada al relay con el token. */
139
+ function relayConsoleUrl(extra) {
140
+ const base = String(extra.relayUrl ?? '').replace(/\/+$/, '').replace(/index\.php$/, '').replace(/\/$/, '');
141
+ return `${base}/console/?t=${extra.token}`;
142
+ }
143
+
123
144
  async function runForeground(values) {
124
- const { port, provider } = resolve(values);
125
- const log = (line) => {
126
- const text = `[${new Date().toISOString()}] ${line}`;
127
- try {
128
- ensureHome();
129
- appendFileSync(bridgeLogPath(), `${text}\n`);
130
- } catch {
131
- /* nada */
132
- }
133
- console.log(text);
145
+ const { port, provider, app } = resolve(values);
146
+ const log = createFileLogger(bridgeLogPath());
147
+ const extra = { provider };
148
+ if (provider === 'relay') {
149
+ const relay = ensureRelayConfig(app);
150
+ extra.relayUrl = relay.relayUrl;
151
+ extra.token = relay.token;
152
+ }
153
+
154
+ // Senal de apagado: el supervisor de abajo la consulta entre intentos.
155
+ let interrupted = false;
156
+ let notifyInterrupt = null;
157
+ const interruptPromise = new Promise((r) => {
158
+ notifyInterrupt = r;
159
+ });
160
+ const shutdown = () => {
161
+ interrupted = true;
162
+ notifyInterrupt();
134
163
  };
164
+ process.once('SIGINT', shutdown);
165
+ process.once('SIGTERM', shutdown);
166
+ const sleep = (ms) =>
167
+ new Promise((r) => {
168
+ const t = setTimeout(r, ms);
169
+ interruptPromise.then(() => {
170
+ clearTimeout(t);
171
+ r();
172
+ });
173
+ });
135
174
 
175
+ // Supervision: si el proceso del tunel muere (el servicio gratuito lo hace a
176
+ // menudo), reconecta con backoff y reescribe el estado con la URL nueva.
177
+ let attempt = 0;
136
178
  let tun;
137
- try {
138
- tun = await startTunnel(port, provider, { log, verbose: values.verbose });
139
- } catch (err) {
140
- log(`ERROR: ${err.message}`);
141
- return 1;
142
- }
143
- if (!tun.url) {
144
- log('ERROR: el tunel no devolvio URL.');
145
- return 1;
146
- }
179
+ for (;;) {
180
+ try {
181
+ tun = await startTunnel(port, provider, { log, verbose: values.verbose, ...extra });
182
+ } catch (err) {
183
+ if (interrupted) break;
184
+ log(`ERROR: ${err.message}`);
185
+ return 1;
186
+ }
187
+ if (!tun.url) {
188
+ log('ERROR: el tunel no devolvio URL.');
189
+ return 1;
190
+ }
147
191
 
148
- writeBridgeState({
149
- pid: process.pid,
150
- url: tun.url,
151
- provider: tun.provider,
152
- port,
153
- childPid: tun.pid,
154
- startedAt: new Date().toISOString(),
155
- });
156
- printStatus(readBridgeState(), true);
157
- log(`Tunel publico: ${tun.url}`);
192
+ writeBridgeState({
193
+ pid: process.pid,
194
+ url: tun.url,
195
+ publicUrl: provider === 'relay' ? relayConsoleUrl(extra) : undefined,
196
+ provider: tun.provider,
197
+ port,
198
+ childPid: tun.pid,
199
+ startedAt: new Date().toISOString(),
200
+ });
201
+ printStatus(readBridgeState(), true);
202
+ log(`Tunel publico: ${tun.url}`);
203
+ attempt = 0;
158
204
 
159
- return new Promise((resolve) => {
160
- let closing = false;
161
- const shutdown = () => {
162
- if (closing) return;
163
- closing = true;
164
- try {
165
- tun.stop();
166
- } catch {
167
- /* nada */
205
+ const exitCode = await new Promise((r) => {
206
+ // Health-check cada 60 s: si la URL publica deja de responder dos veces
207
+ // seguidas, matamos el tunel y el supervisor abre otro.
208
+ let fails = 0;
209
+ const probe = async () => {
210
+ const probeUrl = tun.probeUrl ?? `${tun.url}/api/health`;
211
+ try {
212
+ const res = await fetch(probeUrl, { signal: AbortSignal.timeout(8000) });
213
+ if (res.ok) fails = 0;
214
+ else fails += 1;
215
+ } catch {
216
+ fails += 1;
217
+ }
218
+ if (fails >= 2) {
219
+ log(`La URL publica no responde (${tun.url}); reiniciando el tunel...`);
220
+ tun.stop();
221
+ }
222
+ };
223
+ const health = setInterval(probe, 60000);
224
+ health.unref?.();
225
+ tun.child?.once('exit', (code) => {
226
+ clearInterval(health);
227
+ r(code ?? 1);
228
+ });
229
+ if (!tun.child) {
230
+ clearInterval(health);
231
+ r(0);
168
232
  }
169
- clearBridgeState();
170
- resolve(0);
171
- };
172
- process.once('SIGINT', shutdown);
173
- process.once('SIGTERM', shutdown);
174
- });
233
+ interruptPromise.then(() => {
234
+ clearInterval(health);
235
+ r('signal');
236
+ });
237
+ });
238
+
239
+ if (interrupted || exitCode === 'signal') break;
240
+ attempt += 1;
241
+ const wait = Math.min(30000, 2000 * attempt);
242
+ log(`El tunel termino (codigo ${exitCode}); reconectando en ${Math.round(wait / 1000)} s...`);
243
+ await sleep(wait);
244
+ }
245
+
246
+ try {
247
+ tun?.stop?.();
248
+ } catch {
249
+ /* nada */
250
+ }
251
+ clearBridgeState();
252
+ return 0;
175
253
  }
176
254
 
177
255
  async function cmdReload(values) {
256
+ // Leer el estado ANTES de parar: clearBridgeState borra el archivo y despues
257
+ // no habria nada que esperar (el tunel viejo seguia muriendo).
258
+ const previous = readBridgeState();
178
259
  await cmdStop();
179
- const current = readBridgeState();
180
- for (let i = 0; i < 25 && current && pidAlive(current.pid); i++) {
260
+ for (let i = 0; i < 25 && previous?.pid && pidAlive(previous.pid); i++) {
181
261
  await new Promise((r) => setTimeout(r, 200));
182
262
  }
183
263
  return cmdStart(values);
@@ -216,45 +296,21 @@ async function cmdStatus() {
216
296
  }
217
297
 
218
298
  function printStatus(state, withQr = false) {
299
+ const shown = state.publicUrl || state.url;
219
300
  console.log(`Bridge corriendo (pid ${state.pid})`);
220
- console.log(` url : ${state.url}`);
301
+ console.log(` url : ${shown}`);
221
302
  console.log(` proveedor: ${state.provider} puerto local: ${state.port}`);
222
303
  console.log(` casa : ${homeDir()}`);
223
304
  if (withQr && state.url) {
224
- const art = qrTerminal(state.url, 2);
305
+ const art = qrTerminal(shown, 2);
225
306
  if (art) {
226
307
  console.log('');
227
308
  console.log(art);
228
- console.log(` escanea para abrir ${state.url}`);
309
+ console.log(` escanea para abrir ${shown}`);
229
310
  }
230
311
  }
231
312
  }
232
313
 
233
- function tailBridgeLog(n) {
234
- try {
235
- const lines = readFileSync(bridgeLogPath(), 'utf8')
236
- .split(/\r?\n/)
237
- .filter((l) => l.trim() !== '');
238
- return lines.slice(-n).join('\n');
239
- } catch {
240
- return '';
241
- }
242
- }
243
-
244
- function waitForBridge(childPid, isDead, timeoutMs) {
245
- return new Promise((resolve) => {
246
- const start = Date.now();
247
- const tick = () => {
248
- if (isDead()) return resolve(null);
249
- const state = readBridgeState();
250
- if (state && state.pid && pidAlive(state.pid) && (!childPid || state.pid === childPid)) return resolve(state);
251
- if (Date.now() - start > timeoutMs) return resolve(null);
252
- setTimeout(tick, 500);
253
- };
254
- tick();
255
- });
256
- }
257
-
258
314
  function printHelp() {
259
315
  console.log(`Uso: tamnora bridge [accion] [opciones]
260
316
 
@@ -268,7 +324,12 @@ Acciones:
268
324
  --foreground Corre en primer plano (debug)
269
325
 
270
326
  Opciones:
271
- --tunnel <prov> Proveedor (default: el de app.json; hoy: tunnelmole)
327
+ --tunnel <prov> Proveedor (default: el de app.json; hoy: tunnelmole o relay)
272
328
  --port <n> Puerto local a exponer (default: el del server)
273
- --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)`);
329
+ --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
330
+ --insecure Permitir el tunel sin usuario configurado (peligroso)
331
+
332
+ Relay propio (app.json -> tunnel: { provider: "relay", relayUrl, token }): URLs
333
+ estables via https://harness.tamnora.com; el QR abre la consola agregada con el
334
+ token ya puesto.`);
274
335
  }
@@ -160,7 +160,7 @@ Opciones:
160
160
  --session <id> Continuar una sesión existente
161
161
  -c, --continue Continuar la última sesión
162
162
  --system <texto> Instrucciones extra de sistema
163
- --max-steps <n> Máximo de rondas de herramientas (default: 8)
163
+ --max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
164
164
  --tools <lista> Tools habilitadas (coma), ej. read,list,grep
165
165
  --no-tools Deshabilitar todas las tools
166
166
  -y, --yes Aprobar automáticamente los permisos "ask"