@getparallel/parx 0.6.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ changes). Entries are generated by `npm run release` from the git history of `ap
6
6
 
7
7
  <!-- Notes written under «## Unreleased» are used as the next entry instead of the git log. -->
8
8
 
9
+ ## 0.7.0 (2026-10-18)
10
+
11
+ - Un paso corto ya no tumba el plan; «Llévalo a tu negocio» lleva a la misión en marcha y sin palabras que disparen la plantilla de clientes
12
+ - Del código al negocio (x03): «Llévalo a tu negocio» en Sesiones y /mission con el enlace de la web
13
+ - Ver en el móvil la web que construye (x01)
14
+ - El Mac siempre listo (x07): parx service on/off/add/remove/status, tareas abiertas desde el móvil
15
+
9
16
  ## 0.6.0 (2026-10-18)
10
17
 
11
18
  - parx updates itself. Every few hours it checks npm in the background; a new version is downloaded, verified (npm's registry signature against npm's public key pinned in parx, plus the package's SHA-512) and start-tested, then kept ready in `~/.config/parx/versiones`. It starts with your next session, never mid-session, with one line: «parx 0.5.1 updated · what's new». If a new version fails to start, parx goes back to the previous one on its own. `/autoupdate off` (or `PARX_NO_AUTO_UPDATE=1`) keeps the old behaviour: a line when there is a new version, and `parx update` installs it.
package/dist/api.js CHANGED
@@ -128,6 +128,9 @@ export function latidoRemoto(servidor, token, id, cuerpo, señal) {
128
128
  export function ordenesRemotas(servidor, token, id, esperaMs, señal) {
129
129
  return llamar(servidor, `/api/terminal/remoto/${encodeURIComponent(id)}/ordenes?espera=${esperaMs}`, { token, señal, plazoMs: esperaMs + 15_000 });
130
130
  }
131
+ export function subirVistaRemota(servidor, token, id, cuerpo) {
132
+ return llamar(servidor, `/api/terminal/remoto/${encodeURIComponent(id)}/vista`, { token, cuerpo, plazoMs: 60_000 });
133
+ }
131
134
  export function cerrarRemoto(servidor, token, id, plazoMs = 3000) {
132
135
  return llamar(servidor, `/api/terminal/remoto/${encodeURIComponent(id)}`, { token, metodo: 'DELETE', plazoMs });
133
136
  }
package/dist/cli.js CHANGED
@@ -17,6 +17,8 @@ import { deshacerUltimo, permitirCarpeta } from "./herramientas.js";
17
17
  import { Registro } from "./registro.js";
18
18
  import { Pantalla } from "./ui.js";
19
19
  import { Remoto } from "./remoto.js";
20
+ import { VistaPrevia } from "./vista.js";
21
+ import { anadirCarpeta, carpetasDelServicio, instalarServicio, quitarCarpeta, quitarServicio, reiniciarServicio, rutaDelRegistro, servicioEnMarcha, servicioInstalado, servir } from "./servicio.js";
20
22
  import { qrEnTexto } from "./qr.js";
21
23
  import { acciones, bloqueDeBienvenida, bloqueDeLaPersona, bloqueDeTabla, duracion, Superficie } from "./tui.js";
22
24
  import { CONSEJOS, cambiarConsejos, consejo, elegirConsejos, ordenDeConsejos } from "./consejos.js";
@@ -49,6 +51,9 @@ function opciones() {
49
51
  'no-animation': { type: 'boolean' },
50
52
  animation: { type: 'boolean' },
51
53
  remote: { type: 'boolean' },
54
+ serve: { type: 'boolean' },
55
+ 'remote-child': { type: 'boolean' },
56
+ origen: { type: 'string' },
52
57
  version: { type: 'boolean', short: 'v' },
53
58
  help: { type: 'boolean', short: 'h' },
54
59
  browser: { type: 'boolean' },
@@ -222,6 +227,62 @@ async function principal() {
222
227
  pantalla.escribir();
223
228
  return 0;
224
229
  }
230
+ if (v.serve) {
231
+ const c0 = credencialesActivas();
232
+ if (!c0) {
233
+ console.log(t('cuenta.sin_sesion'));
234
+ return 1;
235
+ }
236
+ return servir(c0);
237
+ }
238
+ if (primero === 'service' || primero === 'servicio') {
239
+ const accion = (positionals[1] ?? 'status').toLowerCase();
240
+ const ruta = positionals.slice(2).join(' ') || process.cwd();
241
+ const lista = () => carpetasDelServicio().map((x) => x.nombre).join(', ') || t('servicio.ninguna');
242
+ if (accion === 'add') {
243
+ const r = anadirCarpeta(ruta);
244
+ pantalla.escribir(r ? t('servicio.anadida', { ruta: rutaCorta(r, 60) }) : t('servicio.no_anadida', { ruta }));
245
+ if (r)
246
+ reiniciarServicio();
247
+ return r ? 0 : 1;
248
+ }
249
+ if (accion === 'remove') {
250
+ const ok = quitarCarpeta(positionals.slice(2).join(' '));
251
+ pantalla.escribir(ok ? t('servicio.quitada', { ruta: positionals.slice(2).join(' ') }) : t('servicio.no_quitada', { ruta: positionals.slice(2).join(' ') }));
252
+ if (ok)
253
+ reiniciarServicio();
254
+ return ok ? 0 : 1;
255
+ }
256
+ if (accion === 'on') {
257
+ if (process.platform !== 'darwin') {
258
+ pantalla.error(t('servicio.solo_mac'));
259
+ return 1;
260
+ }
261
+ if (!credencialesActivas()) {
262
+ pantalla.escribir(t('cuenta.sin_sesion'));
263
+ return 1;
264
+ }
265
+ if (!carpetasDelServicio().length && !anadirCarpeta(process.cwd())) {
266
+ pantalla.escribir(t('servicio.sin_carpetas'));
267
+ return 1;
268
+ }
269
+ instalarServicio();
270
+ pantalla.escribir(t('servicio.on', { carpetas: lista() }));
271
+ pantalla.escribir(c.gris(t('servicio.registro', { ruta: rutaDelRegistro() })));
272
+ return 0;
273
+ }
274
+ if (accion === 'off') {
275
+ quitarServicio();
276
+ pantalla.escribir(t('servicio.off'));
277
+ return 0;
278
+ }
279
+ if (accion === 'status') {
280
+ pantalla.escribir(t('servicio.estado', { estado: servicioInstalado() && servicioEnMarcha() ? t('servicio.en_marcha') : t('servicio.parado'), lista: lista() }));
281
+ return 0;
282
+ }
283
+ pantalla.escribir(t('servicio.uso'));
284
+ return 2;
285
+ }
225
286
  const modoPedido = (v.mode ?? v.modo);
226
287
  const pedido = modoDe(modoPedido);
227
288
  if (pedido === 'bypass') {
@@ -302,6 +363,24 @@ async function principal() {
302
363
  let superficie = null;
303
364
  let remoto = null;
304
365
  let deFueraPendientes = 0;
366
+ let sugeridaMision = false;
367
+ let vistaPrevia = new VistaPrevia();
368
+ const actualizarVista = async (forzar = false) => {
369
+ const r0 = remoto;
370
+ if (!r0?.encendido || !r0.id)
371
+ return null;
372
+ try {
373
+ const r = await vistaPrevia.actualizar({ servidor: cred.servidor, token: cred.token, remoto: r0.id, raiz });
374
+ if (!r)
375
+ return null;
376
+ if (r.cambiados > 0 || forzar)
377
+ r0.evento('vista', { url: r.url, version: r.version, archivos: r.cambiados, carpeta: r.carpeta });
378
+ return { url: r.url };
379
+ }
380
+ catch (e) {
381
+ return { error: e instanceof ErrorDeParallel ? e.codigo : 'red' };
382
+ }
383
+ };
305
384
  let bienvenida;
306
385
  let consejosDeEntrada = [];
307
386
  const confirmarNuevaEnMarcha = async () => {
@@ -397,6 +476,12 @@ async function principal() {
397
476
  superficie?.descansar();
398
477
  remoto?.fijarEstado('libre', null);
399
478
  }
479
+ if (remoto?.encendido)
480
+ void actualizarVista();
481
+ if (!sugeridaMision && sesion.diario.some((d) => d.ficheros.length) && superficie) {
482
+ sugeridaMision = true;
483
+ soloAqui(() => pantalla.escribir(c.gris(t('mision.sugerencia'))));
484
+ }
400
485
  quizaAvisar(t('aviso.termino', { tarea: texto.replace(/\s+/g, ' ').slice(0, 80) }), Date.now() - inicio);
401
486
  return seguir;
402
487
  };
@@ -434,6 +519,7 @@ async function principal() {
434
519
  try {
435
520
  const r = await nuevo.abrir({ equipo: equipo().equipo, version: equipo().version, carpeta: basename(raiz), modelo: nombreDeModelo(sesion.modelo), modo: nombreDeModo(sesion.ctx.modo) });
436
521
  remoto = nuevo;
522
+ vistaPrevia = new VistaPrevia();
437
523
  registro.apuntar('remoto', { encendido: true });
438
524
  enseñarEnlace(r.url, enseñarQR);
439
525
  pantalla.espejo = nuevo;
@@ -529,6 +615,63 @@ async function principal() {
529
615
  quizaAvisar(t('carriles.hecho', { n: tareas.length }), ms);
530
616
  return r.ok;
531
617
  };
618
+ if (v['remote-child']) {
619
+ const cola = [];
620
+ let despertar = null;
621
+ let cortada = false;
622
+ const r = new Remoto({
623
+ servidor: cred.servidor,
624
+ token: cred.token,
625
+ alMensaje: (texto) => {
626
+ cola.push(texto);
627
+ despertar?.();
628
+ },
629
+ alParar: () => {
630
+ if (ocupado)
631
+ control.abort();
632
+ },
633
+ alApagarse: () => {
634
+ cortada = true;
635
+ if (ocupado)
636
+ control.abort();
637
+ despertar?.();
638
+ },
639
+ });
640
+ const e = equipo();
641
+ try {
642
+ await r.abrir({ equipo: e.equipo, version: e.version, carpeta: basename(raiz), modelo: nombreDeModelo(sesion.modelo), modo: nombreDeModo(sesion.ctx.modo), ...(typeof v.origen === 'string' ? { origen: v.origen } : {}) });
643
+ }
644
+ catch {
645
+ return 1;
646
+ }
647
+ remoto = r;
648
+ pantalla.espejo = r;
649
+ if (tarea) {
650
+ deFueraPendientes++;
651
+ await hacer(tarea);
652
+ }
653
+ const INACTIVA_MS = 30 * 60 * 1000;
654
+ while (!cortada) {
655
+ const siguiente = cola.shift();
656
+ if (siguiente) {
657
+ deFueraPendientes++;
658
+ await hacer(siguiente);
659
+ continue;
660
+ }
661
+ const hubo = await new Promise((res) => {
662
+ const reloj = setTimeout(() => res(false), INACTIVA_MS);
663
+ despertar = () => {
664
+ clearTimeout(reloj);
665
+ res(true);
666
+ };
667
+ });
668
+ despertar = null;
669
+ if (!hubo)
670
+ break;
671
+ }
672
+ await r.cerrar();
673
+ return 0;
674
+ }
532
675
  if (tarea) {
533
676
  cabeceraCorta([c.gris([nombreDeModo(modo), nombreDeModelo(modelo), cuenta.plan, rutaCorta(raiz)].filter(Boolean).join(sep()))]);
534
677
  if (quiereBypass && (await confirmarBypass()))
@@ -1088,7 +1231,7 @@ async function principal() {
1088
1231
  break;
1089
1232
  }
1090
1233
  case 'mission': {
1091
- const objetivo = objetivoDeMision(resto, sesion.diario);
1234
+ const objetivo = objetivoDeMision(resto, sesion.diario, vistaPrevia.url);
1092
1235
  if (!objetivo) {
1093
1236
  pantalla.escribir(t('mision.nada'));
1094
1237
  break;
@@ -1197,6 +1340,20 @@ async function principal() {
1197
1340
  await encenderRemoto(true);
1198
1341
  break;
1199
1342
  }
1343
+ case 'preview': {
1344
+ if (!remoto?.encendido) {
1345
+ pantalla.escribir(t('vista.sin_remoto'));
1346
+ break;
1347
+ }
1348
+ const r = await actualizarVista(true);
1349
+ if (!r)
1350
+ pantalla.escribir(t('vista.sin_web'));
1351
+ else if ('error' in r)
1352
+ pantalla.error(t('vista.no_subida', { motivo: r.error }));
1353
+ else
1354
+ pantalla.escribir(t('vista.lista', { url: c.marca(r.url) }));
1355
+ break;
1356
+ }
1200
1357
  case 'autoupdate': {
1201
1358
  const si = ALIAS_SI_NO[arg] ?? (autoactualizarApagada() ? 'si' : 'no');
1202
1359
  guardarAjustes({ autoactualizar: si === 'si' });
package/dist/config.js CHANGED
@@ -100,6 +100,8 @@ export function leerAjustes(dir = carpetaDeConfiguracion()) {
100
100
  out.autoactualizar = a.autoactualizar;
101
101
  if (typeof a.remoto === 'boolean')
102
102
  out.remoto = a.remoto;
103
+ if (Array.isArray(a.carpetasRemotas))
104
+ out.carpetasRemotas = a.carpetasRemotas.filter((x) => typeof x === 'string').slice(0, 20);
103
105
  const k = a.consejos;
104
106
  if (k && typeof k === 'object') {
105
107
  const n = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
package/dist/mision.js CHANGED
@@ -1,12 +1,13 @@
1
1
  export const MAX_OBJETIVO = 600;
2
- export function objetivoDeMision(meta, diario) {
2
+ export function objetivoDeMision(meta, diario, vista = null) {
3
3
  const ultima = [...diario].reverse().find((d) => d.pedido.trim());
4
4
  const que = (meta.trim() || ultima?.pedido.trim() || '').replace(/\s+/g, ' ');
5
5
  if (que.length < 4)
6
6
  return null;
7
7
  const ficheros = [...new Set(diario.flatMap((d) => d.ficheros))].slice(0, 6);
8
- const contexto = ultima
9
- ? ` Context from the terminal (parx): ${ficheros.length ? `changed ${ficheros.join(', ')}. ` : ''}${ultima.respuesta ? `Last result: ${ultima.respuesta.replace(/\s+/g, ' ')}` : ''}`
8
+ const enlace = vista ? `preview of the website (temporary link, valid 24 hours): ${vista}. ` : '';
9
+ const contexto = ultima || vista
10
+ ? ` Context from the terminal (parx): ${enlace}${ficheros.length ? `changed ${ficheros.join(', ')}. ` : ''}${ultima?.respuesta ? `Last result: ${ultima.respuesta.replace(/\s+/g, ' ')}` : ''}`
10
11
  : '';
11
12
  const base = que.length > MAX_OBJETIVO ? `${que.slice(0, MAX_OBJETIVO - 1)}…` : que;
12
13
  const sitio = MAX_OBJETIVO - base.length;
package/dist/remoto.js CHANGED
@@ -242,9 +242,20 @@ export class Remoto {
242
242
  return;
243
243
  }
244
244
  if (orden.tipo === 'parar') {
245
+ for (const [id, p] of this.preguntas) {
246
+ this.preguntas.delete(id);
247
+ p.resolver('no');
248
+ }
245
249
  this.o.alParar();
246
250
  return;
247
251
  }
252
+ if (orden.tipo === 'abrir') {
253
+ const carpeta = typeof orden.datos.carpeta === 'string' ? orden.datos.carpeta : '';
254
+ const texto = typeof orden.datos.texto === 'string' ? orden.datos.texto.trim().slice(0, MAX_TEXTO) : '';
255
+ if (carpeta && texto)
256
+ this.o.alAbrir?.({ orden: orden.id, carpeta, texto });
257
+ return;
258
+ }
248
259
  if (orden.tipo === 'respuesta') {
249
260
  const id = String(orden.datos.pregunta ?? '');
250
261
  const valor = orden.datos.valor;
@@ -0,0 +1,191 @@
1
+ import { spawn, execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, openSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { homedir, hostname } from 'node:os';
5
+ import { basename, join, resolve } from 'node:path';
6
+ import { equipo, ErrorDeParallel } from "./api.js";
7
+ import { guardarAjustes, leerAjustes } from "./config.js";
8
+ import { Remoto } from "./remoto.js";
9
+ export const ETIQUETA = 'net.getparallel.parx';
10
+ export const MAX_SESIONES = 3;
11
+ export function idDeCarpeta(ruta) {
12
+ return createHash('sha256').update(ruta).digest('hex').slice(0, 16);
13
+ }
14
+ export function carpetasDelServicio(rutas = leerAjustes().carpetasRemotas ?? []) {
15
+ return rutas
16
+ .filter((r) => {
17
+ try {
18
+ return existsSync(r) && statSync(r).isDirectory();
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ })
24
+ .map((ruta) => ({ id: idDeCarpeta(ruta), nombre: basename(ruta) || ruta, ruta }));
25
+ }
26
+ export function anadirCarpeta(ruta) {
27
+ const abs = resolve(ruta);
28
+ if (!existsSync(abs) || !statSync(abs).isDirectory())
29
+ return null;
30
+ const real = realpathSync(abs);
31
+ if (real === homedir() || real === '/' || real === realpathSync(homedir()))
32
+ return null;
33
+ const lista = leerAjustes().carpetasRemotas ?? [];
34
+ if (!lista.includes(real))
35
+ guardarAjustes({ carpetasRemotas: [...lista, real].slice(-20) });
36
+ return real;
37
+ }
38
+ export function quitarCarpeta(ruta) {
39
+ const lista = leerAjustes().carpetasRemotas ?? [];
40
+ let real = resolve(ruta);
41
+ try {
42
+ real = realpathSync(real);
43
+ }
44
+ catch {
45
+ }
46
+ const queda = lista.filter((x) => x !== real && basename(x) !== ruta);
47
+ guardarAjustes({ carpetasRemotas: queda });
48
+ return queda.length !== lista.length;
49
+ }
50
+ export const rutaDelPlist = () => join(homedir(), 'Library', 'LaunchAgents', `${ETIQUETA}.plist`);
51
+ export const rutaDelRegistro = () => join(homedir(), 'Library', 'Logs', 'parx-service.log');
52
+ const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
53
+ export function plistDelServicio(o) {
54
+ const args = [o.node, ...o.argumentos, '--serve'].map((a) => ` <string>${xml(a)}</string>`).join('\n');
55
+ return `<?xml version="1.0" encoding="UTF-8"?>
56
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
57
+ <plist version="1.0">
58
+ <dict>
59
+ <key>Label</key>
60
+ <string>${ETIQUETA}</string>
61
+ <key>ProgramArguments</key>
62
+ <array>
63
+ ${args}
64
+ </array>
65
+ <key>RunAtLoad</key>
66
+ <true/>
67
+ <key>KeepAlive</key>
68
+ <dict>
69
+ <key>SuccessfulExit</key>
70
+ <false/>
71
+ </dict>
72
+ <key>ThrottleInterval</key>
73
+ <integer>30</integer>
74
+ <key>EnvironmentVariables</key>
75
+ <dict>
76
+ <key>PATH</key>
77
+ <string>${xml(o.path)}</string>
78
+ </dict>
79
+ <key>StandardOutPath</key>
80
+ <string>${xml(o.registro)}</string>
81
+ <key>StandardErrorPath</key>
82
+ <string>${xml(o.registro)}</string>
83
+ </dict>
84
+ </plist>
85
+ `;
86
+ }
87
+ const dominio = () => `gui/${process.getuid?.() ?? 501}`;
88
+ export function servicioInstalado() {
89
+ return existsSync(rutaDelPlist());
90
+ }
91
+ export function instalarServicio() {
92
+ if (process.platform !== 'darwin')
93
+ throw new Error('solo_mac');
94
+ const plist = rutaDelPlist();
95
+ mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
96
+ mkdirSync(join(homedir(), 'Library', 'Logs'), { recursive: true });
97
+ writeFileSync(plist, plistDelServicio({ node: process.execPath, argumentos: [...process.execArgv, process.argv[1]], path: process.env.PATH ?? '/usr/bin:/bin', registro: rutaDelRegistro() }), { mode: 0o644 });
98
+ try {
99
+ execFileSync('launchctl', ['bootout', `${dominio()}/${ETIQUETA}`], { stdio: 'ignore' });
100
+ }
101
+ catch {
102
+ }
103
+ execFileSync('launchctl', ['bootstrap', dominio(), plist], { stdio: 'ignore' });
104
+ }
105
+ export function reiniciarServicio() {
106
+ if (!servicioInstalado())
107
+ return;
108
+ try {
109
+ execFileSync('launchctl', ['kickstart', '-k', `${dominio()}/${ETIQUETA}`], { stdio: 'ignore' });
110
+ }
111
+ catch {
112
+ }
113
+ }
114
+ export function quitarServicio() {
115
+ try {
116
+ execFileSync('launchctl', ['bootout', `${dominio()}/${ETIQUETA}`], { stdio: 'ignore' });
117
+ }
118
+ catch {
119
+ }
120
+ rmSync(rutaDelPlist(), { force: true });
121
+ }
122
+ export function servicioEnMarcha() {
123
+ try {
124
+ execFileSync('launchctl', ['print', `${dominio()}/${ETIQUETA}`], { stdio: 'ignore' });
125
+ return true;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ const log = (...a) => console.log(new Date().toISOString(), ...a);
132
+ const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
133
+ export function lanzarSesion(o) {
134
+ const salida = openSync(rutaDelRegistro(), 'a');
135
+ return spawn(process.execPath, [...process.execArgv, process.argv[1], '--remote-child', '--origen', o.orden, '--dir', o.ruta, '--', o.texto], {
136
+ cwd: o.ruta,
137
+ stdio: ['ignore', salida, salida],
138
+ env: { ...process.env, PARX_NO_UPDATE_CHECK: '1' },
139
+ });
140
+ }
141
+ export async function servir(cred) {
142
+ const carpetas = carpetasDelServicio();
143
+ if (!carpetas.length) {
144
+ log('no folders: run `parx service add` in a project folder');
145
+ return 0;
146
+ }
147
+ let vivas = 0;
148
+ let fin = null;
149
+ const terminado = new Promise((r) => (fin = r));
150
+ const remoto = new Remoto({
151
+ servidor: cred.servidor,
152
+ token: cred.token,
153
+ alMensaje: () => { },
154
+ alParar: () => { },
155
+ alAbrir: ({ orden, carpeta, texto }) => {
156
+ const c = carpetasDelServicio().find((x) => x.id === carpeta);
157
+ if (!c)
158
+ return log('open: unknown folder', carpeta);
159
+ if (vivas >= MAX_SESIONES)
160
+ return log('open: too many sessions');
161
+ vivas++;
162
+ log('open', c.nombre);
163
+ const hijo = lanzarSesion({ ruta: c.ruta, orden, texto });
164
+ hijo.on('exit', () => vivas--);
165
+ },
166
+ alApagarse: (motivo) => {
167
+ log('stopped:', motivo);
168
+ fin?.(motivo === 'web' ? 0 : 1);
169
+ },
170
+ });
171
+ const e = equipo();
172
+ for (let pausa = 5000;; pausa = Math.min(300_000, pausa * 2)) {
173
+ try {
174
+ await remoto.abrir({ tipo: 'servicio', carpetas: carpetas.map(({ id, nombre }) => ({ id, nombre })), equipo: e.equipo || hostname(), version: e.version, carpeta: '', modelo: '', modo: '' });
175
+ log('listening:', carpetas.map((c) => c.nombre).join(', '));
176
+ break;
177
+ }
178
+ catch (err) {
179
+ if (err instanceof ErrorDeParallel && (err.codigo === 'sesion' || err.codigo === 'sin_acceso')) {
180
+ log('cannot start:', err.codigo);
181
+ return 1;
182
+ }
183
+ log('no connection, retrying');
184
+ await dormir(pausa);
185
+ }
186
+ }
187
+ const salir = () => void remoto.cerrar().finally(() => fin?.(0));
188
+ process.on('SIGTERM', salir);
189
+ process.on('SIGINT', salir);
190
+ return terminado;
191
+ }
package/dist/textos.js CHANGED
@@ -237,6 +237,25 @@ export const TEXTOS = {
237
237
  'remoto.desde_el_movil': 'from your phone',
238
238
  'remoto.parado': 'Stopped from your phone.',
239
239
  'remoto.solo_interactiva': 'Remote control works in an interactive session: run parx and then /remote.',
240
+ 'mision.sugerencia': 'Next: /mission send it to my client · or announce it, or charge for it. It asks before sending anything.',
241
+ 'vista.lista': 'Preview on your phone (updates after each task): {url}',
242
+ 'vista.sin_remoto': 'Turn on /remote first: the preview opens from your phone.',
243
+ 'vista.sin_web': 'No web to preview: there is no index.html here or in dist/, build/ or out/.',
244
+ 'vista.no_subida': 'The preview could not be uploaded: {motivo}.',
245
+ 'servicio.on': 'Your Mac is ready. From Sessions on your phone you can start a task in: {carpetas}. It starts again every time you log in.',
246
+ 'servicio.off': 'The service is off. Your phone can no longer start tasks on this Mac.',
247
+ 'servicio.anadida': 'Added {ruta}. Your phone can start tasks there.',
248
+ 'servicio.no_anadida': '{ruta} cannot be added: it must be an existing folder, and not your whole home folder.',
249
+ 'servicio.quitada': 'Removed {ruta}.',
250
+ 'servicio.no_quitada': '{ruta} was not in the list.',
251
+ 'servicio.estado': 'Service: {estado}. Folders: {lista}.',
252
+ 'servicio.en_marcha': 'running',
253
+ 'servicio.parado': 'off',
254
+ 'servicio.ninguna': 'none',
255
+ 'servicio.sin_carpetas': 'Add a folder first: go to a project folder and run parx service add.',
256
+ 'servicio.solo_mac': 'The service is only for macOS for now.',
257
+ 'servicio.registro': 'Log: {ruta}',
258
+ 'servicio.uso': 'parx service on | off | add [folder] | remove <folder> | status',
240
259
  'error.sin_saldo': 'You are out of Actions. Top up or upgrade at getparallel.net/settings/billing',
241
260
  'error.sin_acceso': 'Your account does not have parx yet: it is in testing.',
242
261
  'error.sesion': 'Your session expired. Run: parx login',
@@ -325,6 +344,7 @@ export const COMANDOS = [
325
344
  ['folder', 'Let parx work in another folder'],
326
345
  ['lanes', 'Run 2 or 3 tasks at once'],
327
346
  ['remote', 'Control this parx from your phone'],
347
+ ['preview', 'See the web it is building on your phone'],
328
348
  ['mission', 'Keep it running on Parallel, phone alert when done'],
329
349
  ['missions', 'Pick a mission and control it from here'],
330
350
  ['inbox', 'Answer what your missions are waiting for'],
@@ -352,6 +372,7 @@ export const ALIAS_COMANDO = {
352
372
  folder: 'folder', 'add-folder': 'folder', carpeta: 'folder', dir: 'folder',
353
373
  lanes: 'lanes', carriles: 'lanes',
354
374
  remote: 'remote', 'remote-control': 'remote', rc: 'remote', remoto: 'remote', phone: 'remote',
375
+ preview: 'preview', vista: 'preview', previa: 'preview',
355
376
  mission: 'mission', mision: 'mission', misión: 'mission',
356
377
  missions: 'missions', misiones: 'missions',
357
378
  inbox: 'inbox', bandeja: 'inbox', approvals: 'inbox', waiting: 'inbox',
@@ -410,6 +431,9 @@ export const USO = [
410
431
  ' /remote link and QR to control this parx from your phone or a browser',
411
432
  ' /remote always turn it on every time parx opens on this computer',
412
433
  ' /remote off turn it off here',
434
+ ' parx service add let your phone start tasks in this folder (macOS)',
435
+ ' parx service on keep your Mac ready at login, even with parx closed',
436
+ ' parx service off | status stop it, or see how it is',
413
437
  '',
414
438
  'Updates:',
415
439
  ' parx checks npm every few hours, without slowing anything down, and installs new versions',
package/dist/ui.js CHANGED
@@ -308,6 +308,16 @@ export class Pantalla {
308
308
  }
309
309
  this.pausarLinea();
310
310
  if (!this.tty || !this.rl) {
311
+ if (this.espejo) {
312
+ const op = opcionesDePregunta(tipo);
313
+ const remota = this.espejo.preguntaRemota(sinColor(texto), tipo, op.opciones.map((etiqueta, i) => ({ etiqueta, valor: op.valores[i] })));
314
+ if (remota.deFuera) {
315
+ const r = await remota.deFuera;
316
+ remota.contestada(r, 'movil');
317
+ return r;
318
+ }
319
+ remota.contestada('no', 'ordenador');
320
+ }
311
321
  this.aviso(`${texto} → ${t('aviso.rechazado')}`);
312
322
  return 'no';
313
323
  }
package/dist/vista.js ADDED
@@ -0,0 +1,133 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, relative, sep } from 'node:path';
4
+ import { subirVistaRemota } from "./api.js";
5
+ export const EXTENSIONES = ['html', 'htm', 'css', 'js', 'mjs', 'json', 'webmanifest', 'txt', 'xml', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'woff', 'woff2', 'ttf', 'otf', 'mp4', 'webm', 'mp3', 'wav', 'map'];
6
+ const EXT = new Set(EXTENSIONES);
7
+ const SALTAR = new Set(['node_modules', '.git', '.next', '.turbo', '.cache', 'coverage', '__pycache__', '.venv', 'venv', 'target']);
8
+ export const MAX_ARCHIVOS = 300;
9
+ export const MAX_ARCHIVO = 5 * 1024 * 1024;
10
+ export const MAX_TOTAL = 25 * 1024 * 1024;
11
+ const LOTE = 2_300_000;
12
+ export function raizWeb(raiz) {
13
+ for (const c of ['dist', 'build', 'out', '.', 'public', 'docs', 'site']) {
14
+ const abs = c === '.' ? raiz : join(raiz, c);
15
+ try {
16
+ if (existsSync(join(abs, 'index.html')) && statSync(join(abs, 'index.html')).isFile())
17
+ return { abs, nombre: c };
18
+ }
19
+ catch {
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ export function recogerWeb(abs) {
25
+ const out = [];
26
+ let total = 0;
27
+ const recorrer = (dir, profundidad) => {
28
+ if (profundidad > 8 || out.length >= MAX_ARCHIVOS)
29
+ return;
30
+ let entradas;
31
+ try {
32
+ entradas = readdirSync(dir).sort();
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ for (const n of entradas) {
38
+ if (out.length >= MAX_ARCHIVOS)
39
+ return;
40
+ if (n.startsWith('.') || SALTAR.has(n))
41
+ continue;
42
+ const ruta = join(dir, n);
43
+ let st;
44
+ try {
45
+ st = statSync(ruta);
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ if (st.isDirectory()) {
51
+ recorrer(ruta, profundidad + 1);
52
+ continue;
53
+ }
54
+ const ext = (/\.([a-z0-9]+)$/i.exec(n)?.[1] ?? '').toLowerCase();
55
+ if (!st.isFile() || !EXT.has(ext) || st.size > MAX_ARCHIVO || total + st.size > MAX_TOTAL)
56
+ continue;
57
+ const rel = relative(abs, ruta).split(sep).join('/');
58
+ if (!/^[A-Za-z0-9._\-/@+ ()]+$/.test(rel) || rel.length > 200)
59
+ continue;
60
+ const datos = readFileSync(ruta);
61
+ total += st.size;
62
+ out.push({ ruta: rel, abs: ruta, hash: createHash('sha256').update(datos).digest('hex'), tam: st.size });
63
+ }
64
+ };
65
+ recorrer(abs, 0);
66
+ return out;
67
+ }
68
+ export function enTandas(lista, max = LOTE) {
69
+ const tandas = [];
70
+ let actual = [];
71
+ let peso = 0;
72
+ for (const a of lista) {
73
+ if (actual.length && peso + a.tam > max) {
74
+ tandas.push(actual);
75
+ actual = [];
76
+ peso = 0;
77
+ }
78
+ actual.push(a);
79
+ peso += a.tam;
80
+ }
81
+ tandas.push(actual);
82
+ return tandas;
83
+ }
84
+ export class VistaPrevia {
85
+ token = randomBytes(32).toString('base64url');
86
+ subirLote;
87
+ constructor(subirLote = subirVistaRemota) {
88
+ this.subirLote = subirLote;
89
+ }
90
+ subidos = new Map();
91
+ url = null;
92
+ version = 0;
93
+ enMarcha = null;
94
+ async actualizar(o) {
95
+ while (this.enMarcha)
96
+ await this.enMarcha.catch(() => undefined);
97
+ const p = this.subir(o);
98
+ this.enMarcha = p;
99
+ try {
100
+ return await p;
101
+ }
102
+ finally {
103
+ this.enMarcha = null;
104
+ }
105
+ }
106
+ async subir(o) {
107
+ const web = raizWeb(o.raiz);
108
+ if (!web)
109
+ return null;
110
+ const lista = recogerWeb(web.abs);
111
+ if (!lista.some((a) => a.ruta === 'index.html'))
112
+ return null;
113
+ const cambiados = lista.filter((a) => this.subidos.get(a.ruta) !== a.hash);
114
+ const iguales = cambiados.length === 0 && lista.length === this.subidos.size;
115
+ if (iguales && this.url)
116
+ return { url: this.url, version: this.version, cambiados: 0, carpeta: web.nombre };
117
+ const tandas = enTandas(cambiados);
118
+ for (let i = 0; i < tandas.length; i++) {
119
+ const ultima = i === tandas.length - 1;
120
+ const archivos = tandas[i].map((a) => ({ ruta: a.ruta, datos: readFileSync(a.abs).toString('base64') }));
121
+ const r = await this.subirLote(o.servidor, o.token, o.remoto, {
122
+ archivos,
123
+ ...(ultima ? { raiz: web.nombre, token: this.token, lista: lista.map(({ ruta, hash, tam }) => ({ ruta, hash, tam })) } : {}),
124
+ });
125
+ if (ultima) {
126
+ this.url = r.url;
127
+ this.version = r.version;
128
+ }
129
+ }
130
+ this.subidos = new Map(lista.map((a) => [a.ruta, a.hash]));
131
+ return { url: this.url, version: this.version, cambiados: cambiados.length, carpeta: web.nombre };
132
+ }
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getparallel/parx",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "parx: the coding agent of Parallel in your terminal. Reads and edits your project, runs commands and tests, and does coding tasks end to end. Mac, Windows and Linux.",
5
5
  "keywords": [
6
6
  "parx",