xonecode 0.35.0 → 0.37.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 (89) hide show
  1. package/THIRD_PARTY_NOTICES.md +30 -0
  2. package/apps/web/dist/assets/{EditorDeFichero-CGZ6uZPa.js → EditorDeFichero-DoFEF88O.js} +1 -1
  3. package/apps/web/dist/assets/index-DRNhZyYF.css +1 -0
  4. package/apps/web/dist/assets/index-WcouJw-n.js +506 -0
  5. package/apps/web/dist/assets/{php-DTPkbJvq.js → php-D_qKPndv.js} +1 -1
  6. package/apps/web/dist/assets/{ruby-D1wBOK7G.js → ruby-Dbo7pTDa.js} +1 -1
  7. package/apps/web/dist/index.html +2 -2
  8. package/dist/agent/config/settingsEnDisco.js +30 -0
  9. package/dist/agent/config/settingsEnDisco.js.map +1 -1
  10. package/dist/agent/motores/trueforge/sesionTrueforge.js +39 -1
  11. package/dist/agent/motores/trueforge/sesionTrueforge.js.map +1 -1
  12. package/dist/agent/remoto/ngrokEnMaquina.js +103 -0
  13. package/dist/agent/remoto/ngrokEnMaquina.js.map +1 -0
  14. package/dist/agent/remoto/puenteWebSocket.js +175 -0
  15. package/dist/agent/remoto/puenteWebSocket.js.map +1 -0
  16. package/dist/agent/remoto/tunelNgrok.js +158 -0
  17. package/dist/agent/remoto/tunelNgrok.js.map +1 -0
  18. package/dist/core/idiomaDeLaRespuesta.js +38 -0
  19. package/dist/core/idiomaDeLaRespuesta.js.map +1 -0
  20. package/dist/core/panorama.js +93 -0
  21. package/dist/core/panorama.js.map +1 -0
  22. package/dist/core/ports.js +100 -0
  23. package/dist/core/ports.js.map +1 -1
  24. package/dist/core/remoto.js +117 -0
  25. package/dist/core/remoto.js.map +1 -0
  26. package/dist/core/settings.js +83 -0
  27. package/dist/core/settings.js.map +1 -1
  28. package/dist/core/tunel.js +101 -0
  29. package/dist/core/tunel.js.map +1 -0
  30. package/dist/web/servidor/arranque.js +587 -71
  31. package/dist/web/servidor/arranque.js.map +1 -1
  32. package/dist/web/servidor/consolaWeb.js +52 -2
  33. package/dist/web/servidor/consolaWeb.js.map +1 -1
  34. package/dist/web/servidor/puenteRemoto.js +272 -0
  35. package/dist/web/servidor/puenteRemoto.js.map +1 -0
  36. package/dist/web/servidor/transporte.js.map +1 -1
  37. package/dist/web/servidor/vestibulo.js +39 -1
  38. package/dist/web/servidor/vestibulo.js.map +1 -1
  39. package/node_modules/@xone/xonecode-remoto/dist/base64url.d.ts +2 -0
  40. package/node_modules/@xone/xonecode-remoto/dist/base64url.js +19 -0
  41. package/node_modules/@xone/xonecode-remoto/dist/cifrado.d.ts +25 -0
  42. package/node_modules/@xone/xonecode-remoto/dist/cifrado.js +75 -0
  43. package/node_modules/@xone/xonecode-remoto/dist/contenido.d.ts +121 -0
  44. package/node_modules/@xone/xonecode-remoto/dist/contenido.js +191 -0
  45. package/node_modules/@xone/xonecode-remoto/dist/index.d.ts +6 -0
  46. package/node_modules/@xone/xonecode-remoto/dist/index.js +6 -0
  47. package/node_modules/@xone/xonecode-remoto/dist/servidor/salas.d.ts +46 -0
  48. package/node_modules/@xone/xonecode-remoto/dist/servidor/salas.js +128 -0
  49. package/node_modules/@xone/xonecode-remoto/dist/servidor/servidor.d.ts +31 -0
  50. package/node_modules/@xone/xonecode-remoto/dist/servidor/servidor.js +198 -0
  51. package/node_modules/@xone/xonecode-remoto/dist/sobre.d.ts +53 -0
  52. package/node_modules/@xone/xonecode-remoto/dist/sobre.js +69 -0
  53. package/node_modules/@xone/xonecode-remoto/dist/url.d.ts +12 -0
  54. package/node_modules/@xone/xonecode-remoto/dist/url.js +35 -0
  55. package/node_modules/@xone/xonecode-remoto/dist/version.d.ts +2 -0
  56. package/node_modules/@xone/xonecode-remoto/dist/version.js +2 -0
  57. package/node_modules/@xone/xonecode-remoto/package.json +41 -0
  58. package/node_modules/@xone/xonecode-remoto/web/assets/index-Bfa2gK3J.css +1 -0
  59. package/node_modules/@xone/xonecode-remoto/web/assets/index-qBo9eRDN.js +77 -0
  60. package/node_modules/@xone/xonecode-remoto/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  61. package/node_modules/@xone/xonecode-remoto/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  62. package/node_modules/@xone/xonecode-remoto/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  63. package/node_modules/@xone/xonecode-remoto/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  64. package/node_modules/@xone/xonecode-remoto/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  65. package/node_modules/@xone/xonecode-remoto/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  66. package/node_modules/@xone/xonecode-remoto/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  67. package/node_modules/@xone/xonecode-remoto/web/index.html +14 -0
  68. package/node_modules/ws/LICENSE +20 -0
  69. package/node_modules/ws/README.md +548 -0
  70. package/node_modules/ws/browser.js +8 -0
  71. package/node_modules/ws/index.js +22 -0
  72. package/node_modules/ws/lib/buffer-util.js +131 -0
  73. package/node_modules/ws/lib/constants.js +19 -0
  74. package/node_modules/ws/lib/event-target.js +292 -0
  75. package/node_modules/ws/lib/extension.js +203 -0
  76. package/node_modules/ws/lib/limiter.js +55 -0
  77. package/node_modules/ws/lib/permessage-deflate.js +530 -0
  78. package/node_modules/ws/lib/receiver.js +743 -0
  79. package/node_modules/ws/lib/sender.js +607 -0
  80. package/node_modules/ws/lib/stream.js +161 -0
  81. package/node_modules/ws/lib/subprotocol.js +62 -0
  82. package/node_modules/ws/lib/validation.js +152 -0
  83. package/node_modules/ws/lib/websocket-server.js +562 -0
  84. package/node_modules/ws/lib/websocket.js +1407 -0
  85. package/node_modules/ws/package.json +74 -0
  86. package/node_modules/ws/wrapper.mjs +21 -0
  87. package/package.json +7 -1
  88. package/apps/web/dist/assets/index-BBQH-l_3.css +0 -1
  89. package/apps/web/dist/assets/index-DqYXrrxo.js +0 -499
@@ -52,7 +52,8 @@ import { detectarDispositivos, frameworkEnDispositivo } from "../../agent/dispos
52
52
  */
53
53
  import { lanzarEnDispositivo, } from "../../agent/dispositivos/lanzamientoEnMaquina.js";
54
54
  import { motivoDeBloqueo, puedeLanzarse, } from "../../core/puedeLanzarse.js";
55
- import { PLATAFORMAS_DE_DISPOSITIVO } from "../../core/settings.js";
55
+ import { PLATAFORMAS_DE_DISPOSITIVO, remotoEnVigor } from "../../core/settings.js";
56
+ import { motivoDeDominioInaceptable } from "../../core/tunel.js";
56
57
  import { instalarHerramientaDeDispositivos, verificarDispositivo } from "../../agent/dispositivos/dispositivosEnMaquina.js";
57
58
  import { arrancarEmulador, pararEmulador } from "../../agent/dispositivos/arranqueDeEmulador.js";
58
59
  import { borrarAvd, clonarAvd, correrPasoDeReceta, crearAvd } from "../../agent/dispositivos/instalacionEnMaquina.js";
@@ -72,7 +73,7 @@ import { borrarCredencial, guardarCredencial } from "../../agent/config/authEnDi
72
73
  import { borrarProveedorPersonalizado, guardarProveedorPersonalizado, proveedoresPersonalizados, } from "../../agent/config/configEnDisco.js";
73
74
  import { crearCheckpointerDeProyecto, mantenimientoDelCheckpointer, } from "../../agent/sesiones/checkpointer.js";
74
75
  import { resumenDePoda } from "../../core/podaDeCheckpoint.js";
75
- import { cargarSettings, guardarConcurrenciaDeTareas, guardarDepurar, guardarModoPorDefecto, guardarWorkspace as guardarWorkspaceEnDisco, guardarDispositivos, guardarAjusteDeAvd, quitarAjustesDeAvd, guardarPuertosAsignados, guardarEntorno as guardarEntornoEnDisco, olvidarEntornoDeSettings, copiasDeEntorno, borrarCopiasDeEntorno, } from "../../agent/config/settingsEnDisco.js";
76
+ import { cargarSettings, guardarConcurrenciaDeTareas, guardarDepurar, guardarModoPorDefecto, guardarAjustesDeRemoto as guardarAjustesDeRemotoEnDisco, guardarWorkspace as guardarWorkspaceEnDisco, guardarDispositivos, guardarAjusteDeAvd, quitarAjustesDeAvd, guardarPuertosAsignados, guardarEntorno as guardarEntornoEnDisco, olvidarEntornoDeSettings, copiasDeEntorno, borrarCopiasDeEntorno, } from "../../agent/config/settingsEnDisco.js";
76
77
  import { dentroDelWorkspace, depuracionActiva, expandirConCasa, mismaRuta, motivoParaNoOlvidarEntorno, motivoDeNombreDeEntornoInaceptable, motivoDeWorkspaceInaceptable, } from "../../core/settings.js";
77
78
  import { esModoDeEscritura, MODO_POR_OMISION, } from "../../core/modoDeEscritura.js";
78
79
  import { megasDeLasMudanzas, mudarWorkspaceLegado } from "../../agent/config/mudanzaEnDisco.js";
@@ -115,6 +116,8 @@ import { baseDeWorkspaceLegada, baseDeWorkspacePorOmision, conexionDeVestibulo,
115
116
  // documentado cinco veces: una composición de producción dentro de algo que los tests
116
117
  // doblan.
117
118
  import { filaDeTarea } from "./transporte.js";
119
+ import { crearSesionRemota, motivoFijoDelTunel } from "./puenteRemoto.js";
120
+ import { componerPanorama, INTERVALO_DE_PANORAMA_MS } from "../../core/panorama.js";
118
121
  import { esEsfuerzo, nivelesDeEsfuerzo } from "../../core/esfuerzo.js";
119
122
  import { CATALOGO_DE_CONECTORES, conectoresParaElAgente, definicionDelCable, RUTA_CALLBACK_MCP } from "../../core/conectores.js";
120
123
  import { servicioDeConectoresCableado } from "../../agent/conectores/servicioDeConectores.js";
@@ -351,6 +354,43 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
351
354
  * Emitir es escribirle a todos; el transporte hace lo mismo por su lado.
352
355
  */
353
356
  const clientes = new Set();
357
+ /**
358
+ * La sesión remota, si esta ejecución la monta. Se ASIGNA más abajo, en cuanto existen
359
+ * `engancharCliente`/`soltarCliente`/`cancelarTurno`/`recibirEnLaConsola`, que son su puerta y
360
+ * su despacho; se declara aquí porque la ráfaga (`adjuntar`) también la lee.
361
+ */
362
+ let sesionRemota;
363
+ const remotoHabilitado = () => opciones.remoto?.ajustes().habilitado === true;
364
+ /** La última foto de ngrok (`OpcionesDeMontaje.remoto.medirNgrok`); `undefined` = aún no medida. */
365
+ let fotoDeNgrok;
366
+ /** Por qué falló la última acción de ngrok: SOLO un texto fijo nuestro. Viaja en la foto. */
367
+ let motivoDeNgrok;
368
+ const mensajeDeRemoto = (e) => {
369
+ const ajustes = opciones.remoto?.ajustes();
370
+ return {
371
+ clase: "remoto",
372
+ estado: e.estado,
373
+ // El interruptor, para Ajustes: la sección se pinta siempre y el botón del chat solo encendido.
374
+ habilitado: remotoHabilitado(),
375
+ ...(ajustes?.porEntorno === true ? { porEntorno: true } : {}),
376
+ ...("url" in e ? { url: e.url } : {}),
377
+ ...("moviles" in e ? { moviles: e.moviles } : {}),
378
+ ...("motivo" in e ? { motivo: e.motivo } : {}),
379
+ // La vía EN VIGOR, siempre (`remotoEnVigor` ya resolvió la omisión): el cliente toma una
380
+ // ausente por ngrok, así que callarla con «servidor» lo pintaría como ngrok.
381
+ ...(ajustes?.via !== undefined ? { via: ajustes.via } : {}),
382
+ ...(fotoDeNgrok === undefined
383
+ ? {}
384
+ : {
385
+ ngrok: {
386
+ instalado: fotoDeNgrok.instalado,
387
+ cuenta: fotoDeNgrok.cuenta,
388
+ ...(ajustes?.dominio === undefined ? {} : { dominio: ajustes.dominio }),
389
+ ...(motivoDeNgrok === undefined ? {} : { motivo: motivoDeNgrok }),
390
+ },
391
+ }),
392
+ };
393
+ };
354
394
  /**
355
395
  * Los clientes por su IDENTIFICADOR, y qué tareas está mirando cada uno.
356
396
  *
@@ -439,6 +479,101 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
439
479
  for (const cliente of clientes)
440
480
  cliente(mensaje);
441
481
  };
482
+ /**
483
+ * El PANEL de la sesión remota (`core/panorama.ts`): las consolas de persona abiertas y la cola
484
+ * de tareas, compuesto AQUÍ y mandado SOLO a los móviles (`SesionRemota.difundir`), nunca a las
485
+ * pestañas. Cada consola cruza con un id OPACO por encendido de la sesión remota —raíz → id, que
486
+ * se olvida al apagar—: la raíz es una ruta de la máquina (`sinRutas`) y el proyecto viaja como
487
+ * su `basename`. **Límite declarado**: el mismo nombre de proyecto en dos entornos se confunde.
488
+ */
489
+ const idsDeConsola = new Map();
490
+ const idDeConsola = (raiz) => {
491
+ let id = idsDeConsola.get(raiz);
492
+ if (id === undefined) {
493
+ id = randomUUID();
494
+ idsDeConsola.set(raiz, id);
495
+ }
496
+ return id;
497
+ };
498
+ /** La consola de persona ABIERTA con ese id, o nada: un id de otro encendido, o de una consola
499
+ * que ya se cerró, no resuelve a ninguna. */
500
+ const consolaDelPanel = (id) => {
501
+ for (const [raiz, suyo] of idsDeConsola) {
502
+ if (suyo === id)
503
+ return vestibulo.proyectosAbiertos().find((c) => c.raiz === raiz);
504
+ }
505
+ return undefined;
506
+ };
507
+ const aprobacionEnVueloDe = (c) => {
508
+ for (const m of c.mensajesDeAprobacion())
509
+ if (m.clase === "aprobacion")
510
+ return m;
511
+ return undefined;
512
+ };
513
+ const panoramaAhora = () => {
514
+ const foco = vestibulo.proyectoAbierto();
515
+ const consolas = vestibulo.proyectosAbiertos().map((c) => {
516
+ const fila = c.sesion === undefined ? undefined : vestibulo.sesionesDe(c.raiz).find((s) => s.id === c.sesion);
517
+ const aprobacion = aprobacionEnVueloDe(c);
518
+ return {
519
+ id: idDeConsola(c.raiz),
520
+ proyecto: basename(c.raiz),
521
+ ...(fila === undefined || fila.titulo === "" ? {} : { sesion: fila.titulo }),
522
+ foco: c === foco,
523
+ turnoEnVuelo: c.turnoEnVuelo,
524
+ ...(aprobacion === undefined
525
+ ? {}
526
+ : { aprobacion: { pendientes: aprobacion.pendientes, ficheros: aprobacion.ficheros, diffs: aprobacion.diffs } }),
527
+ preguntaPendiente: c.preguntaPendiente,
528
+ ...(fila?.ultimoTurno === undefined ? {} : { ultimaActividad: fila.ultimoTurno }),
529
+ };
530
+ });
531
+ const tareas = (opciones.colaDeTareas?.listar() ?? []).map((t) => ({
532
+ id: t.id,
533
+ titulo: t.titulo,
534
+ // El NOMBRE, como en `filaDeTarea`: la raíz de la tarea no viaja.
535
+ proyecto: t.proyecto.nombre,
536
+ estado: t.estado,
537
+ creada: t.creada,
538
+ ...(t.tras === undefined ? {} : { tras: t.tras }),
539
+ ...(t.motivo === undefined ? {} : { motivo: t.motivo }),
540
+ ...(t.acabada === undefined ? {} : { acabada: t.acabada }),
541
+ }));
542
+ return componerPanorama({ consolas, tareas });
543
+ };
544
+ /**
545
+ * Un panorama nuevo a todos los móviles, como mucho uno por `INTERVALO_DE_PANORAMA_MS` y
546
+ * siempre DESPUÉS del último cambio. Los gatillos (`alCambiarEsperas`, `emitirTareas`) saltan a
547
+ * menudo y desde dentro de una resolución (una aprobación que se contesta): aquí solo se
548
+ * PROGRAMA, y se compone al vencer el reloj, con el estado de ese instante.
549
+ */
550
+ let relojDePanorama;
551
+ let ultimoPanorama = Number.NEGATIVE_INFINITY;
552
+ const programarPanorama = () => {
553
+ if (relojDePanorama !== undefined || sesionRemota === undefined || !sesionRemota.hayMoviles())
554
+ return;
555
+ const espera = Math.max(0, ultimoPanorama + INTERVALO_DE_PANORAMA_MS - Date.now());
556
+ relojDePanorama = setTimeout(() => {
557
+ relojDePanorama = undefined;
558
+ if (sesionRemota === undefined || !sesionRemota.hayMoviles())
559
+ return;
560
+ ultimoPanorama = Date.now();
561
+ try {
562
+ sesionRemota.difundir(panoramaAhora());
563
+ }
564
+ catch (error) {
565
+ contar(error);
566
+ }
567
+ }, espera);
568
+ relojDePanorama.unref?.();
569
+ };
570
+ const olvidarPanorama = () => {
571
+ if (relojDePanorama !== undefined)
572
+ clearTimeout(relojDePanorama);
573
+ relojDePanorama = undefined;
574
+ idsDeConsola.clear();
575
+ };
576
+ vestibulo.alCambiarEsperas(programarPanorama);
442
577
  /**
443
578
  * Lo que valga esa promesa, o nada si tarda demasiado. **No la cancela**: sigue viva y su
444
579
  * valor se usará en el siguiente anuncio — lo que se acota es la ESPERA, no el trabajo.
@@ -507,7 +642,7 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
507
642
  * hace que se elija TAMBIÉN el modelo. El wizard sigue sabiendo pintarlo por si otra
508
643
  * piel se lo manda; esta no.
509
644
  */
510
- const anunciarAlta = async () => {
645
+ const componerAlta = async () => {
511
646
  const abierto = vestibulo.proyectoAbierto();
512
647
  const proyectoAbierto = abierto !== undefined;
513
648
  // Se lee del disco EN CADA anuncio y no se cachea al abrir: `configurarModoInicial`
@@ -570,7 +705,7 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
570
705
  conIcono.add(p.id);
571
706
  }));
572
707
  }
573
- emitir({
708
+ return {
574
709
  clase: "alta",
575
710
  pasos,
576
711
  proveedores: PROVEEDORES.map((p) => ({ id: p, nombre: nombreDeProveedor(p) })),
@@ -680,7 +815,12 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
680
815
  // en cada anuncio en vez de pasarse por parámetro: los anuncios de los flancos del
681
816
  // turno no preparan nada y tienen que decir lo que hay, no lo que había.
682
817
  ...(preparando === undefined ? {} : { preparando }),
683
- });
818
+ };
819
+ };
820
+ /** El alta a TODOS los clientes vivos. Compuesto aparte (`componerAlta`) para poder dárselo a
821
+ * UNO solo: el móvil que entra por la sesión remota (ver `engancharMovil`). */
822
+ const anunciarAlta = async () => {
823
+ emitir(await componerAlta());
684
824
  };
685
825
  /**
686
826
  * Los catálogos ya consultados en ESTE proceso, por proveedor. Se guardan porque cada
@@ -1169,6 +1309,16 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
1169
1309
  cliente(depuracion);
1170
1310
  if (modoPorDefecto !== undefined)
1171
1311
  cliente(modoPorDefecto);
1312
+ // La sesión remota, SIEMPRE que esta ejecución la monta: con `habilitado`, que es lo que
1313
+ // pinta el interruptor de Ajustes (y, apagado, no hay botón en el chat). Un puente abierto
1314
+ // con el interruptor quitado a mano en `settings.json` sale igual, para poder apagarlo.
1315
+ // A un móvil no le llega: `remoto` no pasa `filtrarSalida` (lleva el secreto en la `url`).
1316
+ if (sesionRemota !== undefined)
1317
+ cliente(mensajeDeRemoto(sesionRemota.estado()));
1318
+ // La foto de ngrok, la PRIMERA vez que alguien mira: lanza `ngrok version`, que no se hace
1319
+ // con el interruptor apagado ni en sondeo.
1320
+ if (sesionRemota !== undefined && fotoDeNgrok === undefined && midiendoNgrok === undefined && remotoHabilitado())
1321
+ void medirNgrokYAnunciar().catch(contar);
1172
1322
  if (conectores !== undefined)
1173
1323
  cliente(conectores);
1174
1324
  if (consumoDeLaSesion !== undefined) {
@@ -1436,6 +1586,7 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
1436
1586
  const m = mensajeDeTareas();
1437
1587
  if (m !== undefined)
1438
1588
  emitir(m);
1589
+ programarPanorama();
1439
1590
  };
1440
1591
  /**
1441
1592
  * Un id de proyecto a su tripleta completa, o nada si no se puede resolver.
@@ -1799,6 +1950,24 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
1799
1950
  informar(`no se pudo añadir el feedback: ${resultado.motivo ?? "motivo desconocido"}`);
1800
1951
  }
1801
1952
  };
1953
+ /**
1954
+ * El feedback y «Continuar» de una tarea aparcada: los MISMOS para los botones del escritorio
1955
+ * (`POST /accion`) y para el panel del móvil, que no es un segundo camino con su propia regla.
1956
+ */
1957
+ const tareaEsperandoFeedback = (id) => opciones.colaDeTareas?.listar().find((t) => t.id === id)?.estado === "requiere-atencion";
1958
+ const darFeedbackATarea = (id, texto) => {
1959
+ atenderFeedbackDeTarea(id, texto);
1960
+ opciones.revisarTareas?.();
1961
+ emitirTareas();
1962
+ };
1963
+ /** Lo mismo que un feedback, con el texto de «sigue» del núcleo: entra en SU hilo, no repite el
1964
+ * encargo. Devuelve por qué no, o nada si se hizo. */
1965
+ const continuarTarea = (id) => {
1966
+ if (!tareaEsperandoFeedback(id))
1967
+ return "esa tarea ya no está esperando feedback";
1968
+ darFeedbackATarea(id, TEXTO_DE_CONTINUAR);
1969
+ return undefined;
1970
+ };
1802
1971
  /** Los ajustes de destino, o `{}` —que es «se miran todos»— si esta ejecución no los lee. */
1803
1972
  const ajustesDeDispositivos = () => opciones.ajustesDeDispositivos?.() ?? {};
1804
1973
  /**
@@ -4646,6 +4815,53 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
4646
4815
  .then(() => anunciarAlta())
4647
4816
  .catch(contar);
4648
4817
  });
4818
+ /**
4819
+ * Engancha un cliente del cable: una pestaña SSE o un MÓVIL de la sesión remota
4820
+ * (`puenteRemoto.ts`). Es la misma puerta para los dos a propósito: así el móvil recibe la
4821
+ * misma ráfaga, se muda con el foco y cuenta como «hay alguien delante» igual que una pestaña.
4822
+ */
4823
+ const engancharCliente = (sumidero) => {
4824
+ clientes.add(sumidero);
4825
+ adjuntar(sumidero);
4826
+ // ANTES de `conducirCuenta()`, no después: el nombre ya está resuelto (es local, no
4827
+ // depende de ninguna cuenta) y el paso de cuenta puede tardar lo que tarde un humano
4828
+ // en elegir modelo y teclear una clave. Mandarlo solo dentro de `alta` —al final de
4829
+ // TODO esto— dejaba el saludo en «Hola» a secas mientras tanto (`transporte.ts`
4830
+ // documenta la medida).
4831
+ // Solo al recién llegado: los demás ya recibieron su saludo al conectar.
4832
+ sumidero({ clase: "bienvenida", ...(vestibulo.nombre === undefined ? {} : { nombre: vestibulo.nombre }) });
4833
+ };
4834
+ /**
4835
+ * Lo contrario, con la regla de «el ÚLTIMO en irse deja la consola sola» (ver el `close` de la
4836
+ * ruta SSE).
4837
+ *
4838
+ * Se va ESTE cliente, no «el cliente». La guarda de antes (`enviar !== sumidero`)
4839
+ * existía porque el `close` de una pestaña recargada puede llegar DESPUÉS de que el
4840
+ * SSE nuevo se enganche, y con una sola ranura eso desconectaba al recién llegado;
4841
+ * con un conjunto, quitar el suyo es exacto y esa carrera desaparece.
4842
+ */
4843
+ const soltarCliente = (sumidero) => {
4844
+ clientes.delete(sumidero);
4845
+ // La consola solo se da por sola cuando se va el ÚLTIMO: el transporte lo decide
4846
+ // mirando sus sumideros. Cortar a la primera baja rechazaría la aprobación que otra
4847
+ // pestaña todavía tiene delante.
4848
+ adjunto?.desconectar(sumidero);
4849
+ if (clientes.size === 0) {
4850
+ /**
4851
+ * Y las de SEGUNDO PLANO también, que es lo que `soltar` deja pendiente a
4852
+ * propósito: mudarse de consola no da por ido al humano, pero cerrarse el último
4853
+ * SSE sí. Sin esto, una consola que se quedó detrás con un turno en marcha seguiría
4854
+ * creyendo que hay alguien a quien preguntar y su aprobación esperaría el plazo
4855
+ * entero antes de rechazarse. Se les dice a todas menos a la que ya se acaba de
4856
+ * cortar arriba.
4857
+ */
4858
+ for (const consola of vestibulo.proyectosAbiertos()) {
4859
+ if (consola !== adjunto)
4860
+ consola.desconectar();
4861
+ }
4862
+ adjunto = undefined;
4863
+ }
4864
+ };
4649
4865
  servidor.registrarRuta("GET", RUTA_EVENTOS, (peticion, respuesta) => {
4650
4866
  respuesta.writeHead(200, {
4651
4867
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -4664,7 +4880,6 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
4664
4880
  /* el cliente se fue; el `close` de abajo ya desconecta */
4665
4881
  }
4666
4882
  };
4667
- clientes.add(sumidero);
4668
4883
  /**
4669
4884
  * El identificador que el navegador eligió para ESTA conexión, si lo mandó. Es lo que
4670
4885
  * después le permite decir «engánchame a la tarea t1» por `POST /accion`, que es otra
@@ -4700,14 +4915,7 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
4700
4915
  // Un comentario SSE abre el stream de verdad: sin nada escrito, algunos navegadores no
4701
4916
  // disparan `onopen` hasta el primer dato.
4702
4917
  respuesta.write(": xonecode\n\n");
4703
- adjuntar(sumidero);
4704
- // ANTES de `conducirCuenta()`, no después: el nombre ya está resuelto (es local, no
4705
- // depende de ninguna cuenta) y el paso de cuenta puede tardar lo que tarde un humano
4706
- // en elegir modelo y teclear una clave. Mandarlo solo dentro de `alta` —al final de
4707
- // TODO esto— dejaba el saludo en «Hola» a secas mientras tanto (`transporte.ts`
4708
- // documenta la medida).
4709
- // Solo al recién llegado: los demás ya recibieron su saludo al conectar.
4710
- sumidero({ clase: "bienvenida", ...(vestibulo.nombre === undefined ? {} : { nombre: vestibulo.nombre }) });
4918
+ engancharCliente(sumidero);
4711
4919
  /**
4712
4920
  * **Dos anuncios, y el primero va ANTES de preguntar a CloudStudio.**
4713
4921
  *
@@ -4751,11 +4959,6 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
4751
4959
  void anunciarAlta().catch(contar);
4752
4960
  });
4753
4961
  peticion.on("close", () => {
4754
- // Se va ESTE cliente, no «el cliente». La guarda de antes (`enviar !== sumidero`)
4755
- // existía porque el `close` de una pestaña recargada puede llegar DESPUÉS de que el
4756
- // SSE nuevo se enganche, y con una sola ranura eso desconectaba al recién llegado;
4757
- // con un conjunto, quitar el suyo es exacto y esa carrera desaparece.
4758
- clientes.delete(sumidero);
4759
4962
  /**
4760
4963
  * Y se desenganchan sus MIRADAS. Sin esto, el envoltorio de una pestaña cerrada se
4761
4964
  * queda enganchado al turno de la tarea para siempre, escribiendo en un socket que ya
@@ -4773,25 +4976,7 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
4773
4976
  porIdDeCliente.delete(idDeCliente);
4774
4977
  }
4775
4978
  }
4776
- // Y la consola solo se da por sola cuando se va el ÚLTIMO: el transporte lo decide
4777
- // mirando sus sumideros. Cortar a la primera baja rechazaría la aprobación que otra
4778
- // pestaña todavía tiene delante.
4779
- adjunto?.desconectar(sumidero);
4780
- if (clientes.size === 0) {
4781
- /**
4782
- * Y las de SEGUNDO PLANO también, que es lo que `soltar` deja pendiente a
4783
- * propósito: mudarse de consola no da por ido al humano, pero cerrarse el último
4784
- * SSE sí. Sin esto, una consola que se quedó detrás con un turno en marcha seguiría
4785
- * creyendo que hay alguien a quien preguntar y su aprobación esperaría el plazo
4786
- * entero antes de rechazarse. Se les dice a todas menos a la que ya se acaba de
4787
- * cortar arriba.
4788
- */
4789
- for (const consola of vestibulo.proyectosAbiertos()) {
4790
- if (consola !== adjunto)
4791
- consola.desconectar();
4792
- }
4793
- adjunto = undefined;
4794
- }
4979
+ soltarCliente(sumidero);
4795
4980
  });
4796
4981
  });
4797
4982
  /**
@@ -5315,6 +5500,306 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
5315
5500
  respuesta.writeHead(204);
5316
5501
  respuesta.end();
5317
5502
  });
5503
+ /**
5504
+ * Parar ESTE turno, no cerrar la conversación. Sin proyecto abierto no hay turno que
5505
+ * parar y se dice: un botón que no puede cumplir no puede callar. Lo usan `POST /accion`
5506
+ * y el móvil.
5507
+ */
5508
+ const cancelarTurno = () => {
5509
+ const abierto = vestibulo.proyectoAbierto();
5510
+ if (abierto === undefined || !abierto.cancelarTurno()) {
5511
+ informar("no hay ningún turno en vuelo que parar");
5512
+ }
5513
+ };
5514
+ /**
5515
+ * Lo que va a la consola en foco: prosa, respuestas, decisiones… Lo usan `POST /accion` y
5516
+ * el móvil.
5517
+ *
5518
+ * Y si fue una PROSA, se reanuncia el alta: la sesión acaba de darse de alta en el
5519
+ * índice (`ConsolaDeProyecto.recibir`), así que hay una fila nueva que la barra tiene
5520
+ * que enseñar YA — con su título y marcada como la activa.
5521
+ *
5522
+ * Diferido por lo mismo que el reanuncio de los flancos: el lazo coge la línea en una
5523
+ * microtarea, y anunciar en el acto contaría el turno como no empezado. No sustituye a
5524
+ * los flancos —de ellos sale la marca de «trabajando»—, cubre el caso en que no hay
5525
+ * flanco ninguno: un `/comando`, que no corre turno.
5526
+ */
5527
+ const recibirEnLaConsola = (mensaje) => {
5528
+ destinoActual().recibir(mensaje);
5529
+ if (mensaje.clase === "prosa") {
5530
+ void Promise.resolve()
5531
+ .then(() => anunciarAlta())
5532
+ .catch(contar);
5533
+ }
5534
+ };
5535
+ /**
5536
+ * La puerta de un MÓVIL: la de una pestaña y, detrás, el `alta` SOLO para él. Una pestaña lo
5537
+ * recibe porque la ruta SSE anuncia después de enganchar (`conducirCuenta().then(anunciarAlta)`);
5538
+ * un móvil no pasa por esa ruta, y sin esto su cabecera decía «sin sesión» hasta el siguiente
5539
+ * `alta` difundido. No va en `adjuntar` porque las pestañas recibirían uno de más, ni se difunde
5540
+ * a todos por lo mismo. Sale por su sumidero, así que cruza transformado por `filtrarSalida`
5541
+ * (`remoto.estado`: proyecto y título de la sesión). Se compone asíncrono: si el móvil se fue
5542
+ * entre medias, no se le escribe.
5543
+ */
5544
+ const engancharMovil = (sumidero) => {
5545
+ engancharCliente(sumidero);
5546
+ void componerAlta()
5547
+ .then((alta) => {
5548
+ if (clientes.has(sumidero))
5549
+ sumidero(alta);
5550
+ })
5551
+ .catch(contar);
5552
+ };
5553
+ /**
5554
+ * Una decisión del PANEL del móvil: va a la consola de ESE id y nunca a la del foco. Si el id no
5555
+ * resuelve, o esa consola no tiene aprobación en vuelo, o la decisión nombra ids que no son los
5556
+ * de la aprobación que hay AHORA (el panel pudo quedarse viejo), se descarta y se dice: en
5557
+ * `consolaWeb#recibir` una decisión con ids que no casan RECHAZA lo pendiente, y aquí no se
5558
+ * rechaza nada que nadie haya visto.
5559
+ */
5560
+ const decidirDesdeElPanel = (id, decisiones) => {
5561
+ const consola = consolaDelPanel(id);
5562
+ const enVuelo = consola === undefined ? undefined : aprobacionEnVueloDe(consola);
5563
+ const ids = Object.keys(decisiones);
5564
+ if (consola === undefined ||
5565
+ enVuelo === undefined ||
5566
+ ids.length === 0 ||
5567
+ !ids.every((nombrado) => enVuelo.pendientes.some((p) => p.id === nombrado))) {
5568
+ informar("sesión remota: una decisión del panel no tenía aprobación a la que contestar");
5569
+ return;
5570
+ }
5571
+ consola.recibir({ clase: "decision", decisiones });
5572
+ };
5573
+ /**
5574
+ * El móvil entra por la MISMA puerta que una pestaña (`engancharCliente`) y lo que manda va a
5575
+ * la consola por el MISMO despacho que `POST /accion` (`cancelarTurno`/`recibirEnLaConsola`):
5576
+ * un segundo camino sería una segunda regla de qué se puede hacer desde fuera.
5577
+ */
5578
+ sesionRemota =
5579
+ opciones.remoto === undefined
5580
+ ? undefined
5581
+ : crearSesionRemota({
5582
+ puerto: opciones.remoto.puerto,
5583
+ servidor: () => opciones.remoto.ajustes().servidor,
5584
+ // La vía se lee en CADA encendido (vive en `settings.json`), y el dominio con ella.
5585
+ tunel: () => {
5586
+ const { via, dominio } = opciones.remoto.ajustes();
5587
+ if (via !== "ngrok")
5588
+ return undefined;
5589
+ const tunel = opciones.remoto.tunel;
5590
+ return {
5591
+ abrir: () => tunel === undefined
5592
+ ? Promise.reject(new Error("esta ejecución no tiene túnel"))
5593
+ : tunel.abrir(dominio === undefined ? {} : { dominio }),
5594
+ };
5595
+ },
5596
+ engancharCliente: engancharMovil,
5597
+ soltarCliente,
5598
+ // La `respuesta` es la excepción, y por SEGURIDAD: por `recibir` contestaría también la
5599
+ // subida con casillas o «¿Vaciar la copia…?» del escritorio, que el móvil nunca ve.
5600
+ despachar: (m) => {
5601
+ if (m.clase === "cancelar")
5602
+ cancelarTurno();
5603
+ else if (m.clase === "respuesta") {
5604
+ if (!destinoActual().responderDesdeElMovil(m.texto))
5605
+ informar("sesión remota: una respuesta del móvil no tenía pregunta a la que contestar");
5606
+ }
5607
+ else if (m.clase === "decision" && m.consola !== undefined)
5608
+ decidirDesdeElPanel(m.consola, m.decisiones);
5609
+ else if (m.clase === "feedback" || m.clase === "continuar") {
5610
+ // Solo a una tarea que ESPERA feedback: el panel la enseñó así, pero pudo cambiar entre medias.
5611
+ if (!tareaEsperandoFeedback(m.tarea))
5612
+ informar("sesión remota: el panel mandó algo a una tarea que ya no espera feedback");
5613
+ else if (m.clase === "continuar")
5614
+ void continuarTarea(m.tarea);
5615
+ else
5616
+ darFeedbackATarea(m.tarea, m.texto);
5617
+ }
5618
+ else
5619
+ recibirEnLaConsola(m);
5620
+ },
5621
+ alEntrarMovil: (enviar) => {
5622
+ try {
5623
+ enviar(panoramaAhora());
5624
+ }
5625
+ catch (error) {
5626
+ contar(error);
5627
+ }
5628
+ },
5629
+ informar,
5630
+ // A las pestañas: el diálogo de la consola. `clientes` contiene también los sumideros
5631
+ // de los móviles, y no pasa nada: su filtro descarta `remoto`.
5632
+ alCambiar: (e) => {
5633
+ // Los ids del panel son de UN encendido (revocar incluido): al abrir, apagar o caer se olvidan.
5634
+ if (e.estado === "apagada" || e.estado === "error" || e.estado === "abriendo")
5635
+ olvidarPanorama();
5636
+ for (const cliente of clientes)
5637
+ cliente(mensajeDeRemoto(e));
5638
+ },
5639
+ });
5640
+ /**
5641
+ * Apagar solo si hay algo ENCENDIDO. `apagar()` anuncia `{estado:"apagada"}` a todas las
5642
+ * pestañas, y `arrancarConsolaWeb` monta la opción SIEMPRE: con el interruptor apagado, un
5643
+ * apagado a ciegas en el cierre emitiría un mensaje `remoto` que no debe existir. Y no mira el
5644
+ * interruptor: si alguien lo apaga en `settings.json` con el puente abierto, cerrarlo sigue
5645
+ * siendo posible (y es lo único seguro).
5646
+ */
5647
+ const apagarRemoto = () => {
5648
+ if (sesionRemota !== undefined && sesionRemota.estado().estado !== "apagada")
5649
+ return sesionRemota.apagar();
5650
+ return Promise.resolve();
5651
+ };
5652
+ /** El estado de la sesión remota, otra vez, a cada pestaña: tras cambiar algo que el mensaje lleva. */
5653
+ const anunciarRemoto = () => {
5654
+ if (sesionRemota === undefined)
5655
+ return;
5656
+ const mensaje = mensajeDeRemoto(sesionRemota.estado());
5657
+ for (const cliente of clientes)
5658
+ cliente(mensaje);
5659
+ };
5660
+ /**
5661
+ * Una medida a la vez, y la pedida con otra en vuelo se ENCADENA detrás (una sola, compartida
5662
+ * por quien la pida mientras espera): la que está en vuelo pudo empezar ANTES de guardar el
5663
+ * authtoken o de acabar la receta, y reutilizarla dejaría una foto vieja.
5664
+ */
5665
+ let midiendoNgrok;
5666
+ let siguienteMedidaDeNgrok;
5667
+ const medirNgrokAhora = (medir) => {
5668
+ const esta = medir()
5669
+ .then((foto) => {
5670
+ fotoDeNgrok = { instalado: foto.instalado, cuenta: foto.cuenta };
5671
+ anunciarRemoto();
5672
+ })
5673
+ .finally(() => {
5674
+ if (midiendoNgrok === esta)
5675
+ midiendoNgrok = undefined;
5676
+ });
5677
+ midiendoNgrok = esta;
5678
+ return esta;
5679
+ };
5680
+ const medirNgrokYAnunciar = () => {
5681
+ const medir = opciones.remoto?.medirNgrok;
5682
+ if (medir === undefined || !remotoHabilitado())
5683
+ return Promise.resolve();
5684
+ const enVuelo = midiendoNgrok;
5685
+ if (enVuelo === undefined)
5686
+ return medirNgrokAhora(medir);
5687
+ siguienteMedidaDeNgrok ??= enVuelo
5688
+ .catch(() => { })
5689
+ .then(() => {
5690
+ siguienteMedidaDeNgrok = undefined;
5691
+ return medirNgrokAhora(medir);
5692
+ });
5693
+ return siguienteMedidaDeNgrok;
5694
+ };
5695
+ /**
5696
+ * Las acciones de la vía ngrok. Lo que se dice de vuelta es SIEMPRE texto nuestro: ni lo que
5697
+ * llegó por el cable, ni lo que contestó ngrok, ni —sobre todo— el authtoken.
5698
+ */
5699
+ const atenderViaRemota = (via) => {
5700
+ if (via !== "servidor" && via !== "ngrok")
5701
+ return;
5702
+ const estado = sesionRemota?.estado().estado;
5703
+ // La vía se lee al encender: cambiarla encendida no haría nada hasta apagar, y un revocar
5704
+ // la aplicaría a medias. El diálogo solo la ofrece apagada o en error.
5705
+ if (estado !== "apagada" && estado !== "error") {
5706
+ informar("sesión remota: la vía se cambia con la sesión apagada");
5707
+ return;
5708
+ }
5709
+ if (!guardarAjustesDeRemoto({ via }))
5710
+ return;
5711
+ motivoDeNgrok = undefined;
5712
+ anunciarRemoto();
5713
+ void medirNgrokYAnunciar().catch(contar);
5714
+ };
5715
+ const atenderDominioRemoto = (bruto) => {
5716
+ if (typeof bruto !== "string")
5717
+ return;
5718
+ const dominio = bruto.trim();
5719
+ if (dominio !== "") {
5720
+ const motivo = motivoDeDominioInaceptable(dominio);
5721
+ if (motivo !== undefined) {
5722
+ // El texto del rechazo es fijo (`core/tunel.ts`): no repite lo tecleado.
5723
+ informar(`sesión remota: ${motivo}`);
5724
+ motivoDeNgrok = motivo;
5725
+ anunciarRemoto();
5726
+ if (fotoDeNgrok === undefined)
5727
+ void medirNgrokYAnunciar().catch(contar);
5728
+ return;
5729
+ }
5730
+ }
5731
+ // Vacío QUITA el dominio: sin esto, uno puesto no se podría borrar desde el diálogo.
5732
+ if (!guardarAjustesDeRemoto({ dominio: dominio === "" ? null : dominio }))
5733
+ return;
5734
+ motivoDeNgrok = undefined;
5735
+ anunciarRemoto();
5736
+ void medirNgrokYAnunciar().catch(contar);
5737
+ };
5738
+ /** `true` si quedó escrito. El error de disco lleva la ruta de `settings.json`: solo su `code`. */
5739
+ const guardarAjustesDeRemoto = (cambio) => {
5740
+ const guardar = opciones.remoto?.guardarAjustes;
5741
+ if (guardar === undefined) {
5742
+ informar("sesión remota: esta ejecución no puede guardar sus ajustes");
5743
+ return false;
5744
+ }
5745
+ try {
5746
+ guardar(cambio);
5747
+ return true;
5748
+ }
5749
+ catch (error) {
5750
+ informar(`sesión remota: no se pudo guardar en settings.json (${codigoDe(error)})`);
5751
+ return false;
5752
+ }
5753
+ };
5754
+ /**
5755
+ * El interruptor de Ajustes → Sesión remota. Desactivar APAGA antes lo que haya encendido: un
5756
+ * puente abierto con el interruptor quitado seguiría dando la consola a quien tenga la URL.
5757
+ * Activar no enciende nada: solo hace aparecer el botón del chat y mide ngrok.
5758
+ */
5759
+ const atenderHabilitarRemoto = async (valor) => {
5760
+ if (typeof valor !== "boolean")
5761
+ return;
5762
+ if (!valor)
5763
+ await apagarRemoto();
5764
+ if (!guardarAjustesDeRemoto({ habilitado: valor }))
5765
+ return;
5766
+ if (!valor && opciones.remoto?.ajustes().porEntorno === true) {
5767
+ informar("sesión remota: XONECODE_REMOTO=1 la mantiene activa en este arranque");
5768
+ }
5769
+ anunciarRemoto();
5770
+ if (valor)
5771
+ await medirNgrokYAnunciar();
5772
+ };
5773
+ /**
5774
+ * El authtoken entra por `leerSecreto` —la MISMA puerta que una clave de API— y sale solo hacia
5775
+ * `ngrok config add-authtoken`. Vacío es cancelar: no se llama a nadie ni se dice nada.
5776
+ */
5777
+ const pedirAuthtoken = async () => {
5778
+ const guardar = opciones.remoto?.guardarAuthtoken;
5779
+ if (guardar === undefined) {
5780
+ informar("sesión remota: esta ejecución no puede guardar el authtoken de ngrok");
5781
+ return;
5782
+ }
5783
+ const consola = vestibulo.proyectoAbierto()?.consola.consola ?? vestibulo.consola.consola;
5784
+ const token = (await consola.leerSecreto("authtoken de ngrok: ")).trim();
5785
+ if (token === "")
5786
+ return;
5787
+ let resultado;
5788
+ try {
5789
+ resultado = await guardar(token);
5790
+ }
5791
+ catch {
5792
+ resultado = { ok: false, motivo: "" };
5793
+ }
5794
+ if (resultado.ok)
5795
+ motivoDeNgrok = undefined;
5796
+ else {
5797
+ motivoDeNgrok = motivoFijoDelTunel(resultado.motivo);
5798
+ informar(`sesión remota: ${motivoDeNgrok}`);
5799
+ }
5800
+ // La medida dice si quedó con cuenta, no lo que contestó el comando.
5801
+ await medirNgrokYAnunciar();
5802
+ };
5318
5803
  servidor.registrarRuta("POST", RUTA_ACCION, async (peticion, respuesta) => {
5319
5804
  let mensaje;
5320
5805
  try {
@@ -5795,16 +6280,12 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
5795
6280
  return;
5796
6281
  }
5797
6282
  if (mensaje.accion === "continuar" && opciones.colaDeTareas !== undefined) {
5798
- // Lo mismo que un feedback, con el texto de «sigue» del núcleo: entra en SU hilo, no repite el encargo.
5799
- const actual = opciones.colaDeTareas.listar().find((t) => t.id === mensaje.id);
5800
- if (actual === undefined || actual.estado !== "requiere-atencion") {
6283
+ const motivo = continuarTarea(mensaje.id);
6284
+ if (motivo !== undefined) {
5801
6285
  respuesta.writeHead(409, { "content-type": "application/json" });
5802
- respuesta.end(JSON.stringify({ motivo: "esa tarea ya no está esperando feedback" }));
6286
+ respuesta.end(JSON.stringify({ motivo }));
5803
6287
  return;
5804
6288
  }
5805
- atenderFeedbackDeTarea(mensaje.id, TEXTO_DE_CONTINUAR);
5806
- opciones.revisarTareas?.();
5807
- emitirTareas();
5808
6289
  respuesta.writeHead(204);
5809
6290
  respuesta.end();
5810
6291
  return;
@@ -5874,9 +6355,7 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
5874
6355
  void atenderAugmentar(opciones.augmentar, mensaje.proyecto, mensaje.peticion, mensaje.borrador);
5875
6356
  }
5876
6357
  else if (mensaje.accion === "feedback" && opciones.colaDeTareas !== undefined) {
5877
- atenderFeedbackDeTarea(mensaje.id, mensaje.texto);
5878
- opciones.revisarTareas?.();
5879
- emitirTareas();
6358
+ darFeedbackATarea(mensaje.id, mensaje.texto);
5880
6359
  }
5881
6360
  else if (mensaje.accion !== "crear" &&
5882
6361
  mensaje.accion !== "augmentar" &&
@@ -6137,17 +6616,35 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
6137
6616
  respuesta.end();
6138
6617
  return;
6139
6618
  }
6140
- if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "cancelar") {
6141
- // Parar ESTE turno, no cerrar la conversación. Sin proyecto abierto no hay turno que
6142
- // parar y se dice: un botón que no puede cumplir no puede callar.
6143
- const abierto = vestibulo.proyectoAbierto();
6144
- if (abierto === undefined || !abierto.cancelarTurno()) {
6145
- informar("no hay ningún turno en vuelo que parar");
6619
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "remoto") {
6620
+ // Con el interruptor apagado se contesta igual y no se ENCIENDE nada: ni se carga el
6621
+ // paquete. Apagar sí se atiende siempre, pero solo actúa si hay algo encendido.
6622
+ if (mensaje.accion === "apagar")
6623
+ void apagarRemoto().catch(contar);
6624
+ else if (mensaje.accion === "habilitar")
6625
+ void atenderHabilitarRemoto(mensaje.valor).catch(contar);
6626
+ else if (sesionRemota !== undefined && remotoHabilitado()) {
6627
+ if (mensaje.accion === "encender")
6628
+ void sesionRemota.encender().catch(contar);
6629
+ else if (mensaje.accion === "revocar")
6630
+ void sesionRemota.revocar().catch(contar);
6631
+ else if (mensaje.accion === "via")
6632
+ atenderViaRemota(mensaje.via);
6633
+ else if (mensaje.accion === "dominio")
6634
+ atenderDominioRemoto(mensaje.dominio);
6635
+ else if (mensaje.accion === "authtoken")
6636
+ void pedirAuthtoken().catch(contar);
6146
6637
  }
6147
6638
  respuesta.writeHead(204);
6148
6639
  respuesta.end();
6149
6640
  return;
6150
6641
  }
6642
+ if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "cancelar") {
6643
+ cancelarTurno();
6644
+ respuesta.writeHead(204);
6645
+ respuesta.end();
6646
+ return;
6647
+ }
6151
6648
  if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "sesion") {
6152
6649
  // Suelto: abrir un proyecto arranca una consola entera y el `POST` no se queda
6153
6650
  // esperando. Lo que pase se cuenta por el cable.
@@ -6387,27 +6884,12 @@ export function montarRutas(servidor, vestibulo, opciones = {}) {
6387
6884
  void atenderAlta(mensaje);
6388
6885
  }
6389
6886
  else {
6390
- destinoActual().recibir(mensaje);
6391
- /**
6392
- * Y si fue una PROSA, se reanuncia el alta: la sesión acaba de darse de alta en el
6393
- * índice (`ConsolaDeProyecto.recibir`), así que hay una fila nueva que la barra tiene
6394
- * que enseñar YA — con su título y marcada como la activa.
6395
- *
6396
- * Diferido por lo mismo que el reanuncio de los flancos: el lazo coge la línea en una
6397
- * microtarea, y anunciar en el acto contaría el turno como no empezado. No sustituye a
6398
- * los flancos —de ellos sale la marca de «trabajando»—, cubre el caso en que no hay
6399
- * flanco ninguno: un `/comando`, que no corre turno.
6400
- */
6401
- if (mensaje.clase === "prosa") {
6402
- void Promise.resolve()
6403
- .then(() => anunciarAlta())
6404
- .catch(contar);
6405
- }
6887
+ recibirEnLaConsola(mensaje);
6406
6888
  }
6407
6889
  respuesta.writeHead(204);
6408
6890
  respuesta.end();
6409
6891
  });
6410
- return { emitirTareas };
6892
+ return { emitirTareas, cerrarRemoto: apagarRemoto };
6411
6893
  }
6412
6894
  /**
6413
6895
  * Construye el corredor de tareas y la costura que lo conecta al cable.
@@ -7341,6 +7823,35 @@ export async function arrancarConsolaWeb(opciones) {
7341
7823
  }
7342
7824
  const cable = montarRutas(servidor, vestibulo, {
7343
7825
  informar,
7826
+ // El interruptor se lee de `settings.json` (o `XONECODE_REMOTO=1`) en cada uso. Sin el
7827
+ // paquete del puente instalado, encender dice que no está disponible.
7828
+ remoto: {
7829
+ ajustes: () => remotoEnVigor(cargarSettings().settings.remoto, process.env),
7830
+ puerto: opciones.puertoRemoto ??
7831
+ (async () => {
7832
+ // `import()` dinámico también aquí: con el interruptor apagado ni se carga el adaptador.
7833
+ const { cargarModuloRemoto, crearPuenteWebSocket } = await import("../../agent/remoto/puenteWebSocket.js");
7834
+ const modulo = await cargarModuloRemoto();
7835
+ return modulo === undefined ? undefined : crearPuenteWebSocket(modulo);
7836
+ }),
7837
+ // La vía ngrok, también por `import()` dinámico: ni el túnel ni la medida se cargan hasta
7838
+ // que alguien los usa, y los dos solo se usan con el interruptor encendido.
7839
+ tunel: opciones.tunelRemoto ?? {
7840
+ abrir: async (o) => {
7841
+ const { crearTunelNgrok } = await import("../../agent/remoto/tunelNgrok.js");
7842
+ return crearTunelNgrok().abrir(o);
7843
+ },
7844
+ },
7845
+ medirNgrok: opciones.medirNgrok ??
7846
+ (async () => {
7847
+ // ngrok viene con xonecode (el SDK): no hay receta de instalación que medir.
7848
+ const ngrok = await import("../../agent/remoto/ngrokEnMaquina.js");
7849
+ return ngrok.medirNgrok();
7850
+ }),
7851
+ guardarAuthtoken: opciones.guardarAuthtoken ??
7852
+ (async (token) => (await import("../../agent/remoto/ngrokEnMaquina.js")).guardarAuthtoken(token)),
7853
+ guardarAjustes: (cambio) => void guardarAjustesDeRemotoEnDisco(undefined, cambio),
7854
+ },
7344
7855
  // El tope de ventana, tal cual llega: es la misma función que la barra del terminal, y
7345
7856
  // dos resoluciones serían dos porcentajes distintos para el mismo modelo.
7346
7857
  ...(opciones.topeDeContexto === undefined ? {} : { topeDeContexto: opciones.topeDeContexto }),
@@ -7559,6 +8070,11 @@ export async function arrancarConsolaWeb(opciones) {
7559
8070
  * señal repetida no puede matar el cierre que sigue: ver `interrupcion.ts`.
7560
8071
  */
7561
8072
  await (opciones.esperarCierre ?? (() => esperarInterrupcion({ avisar: escribir })))();
8073
+ // La sesión remota antes que nada: un móvil no puede seguir mandando prosa a una consola que
8074
+ // se está cerrando, y el socket del puente no puede quedarse abierto tras el proceso.
8075
+ // Se ESPERA: con la vía ngrok, cerrar mata ngrok y el puente local, y sin esperar el proceso
8076
+ // saldría antes y dejaría a ngrok huérfano del `SIGKILL` de rescate.
8077
+ await cable.cerrarRemoto().catch(() => { });
7562
8078
  /**
7563
8079
  * El corredor PRIMERO, y el orden es load-bearing: `parar()` corta los turnos en vuelo y
7564
8080
  * los deja aparcados diciendo que la consola se cerró a mitad. Al revés, el