@getparallel/parx 0.5.0 → 0.6.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/dist/remoto.js ADDED
@@ -0,0 +1,284 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { abrirRemoto, cerrarRemoto, ErrorDeParallel, latidoRemoto, ordenesRemotas } from "./api.js";
5
+ import { sinColor } from "./marca.js";
6
+ export function seContestaDesdeFuera(clase) {
7
+ return clase !== 'credencial' && clase !== 'bypass';
8
+ }
9
+ const SECRETOS = [
10
+ /\b(sk|pk|rk)[-_](live|test|proj|ant)?[-_]?[A-Za-z0-9_-]{16,}/g,
11
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
12
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
13
+ /\bAKIA[0-9A-Z]{16}\b/g,
14
+ /\bAIza[0-9A-Za-z_-]{30,}/g,
15
+ /\bpar_(term|key)_[A-Za-z0-9_-]{8,}/g,
16
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
17
+ ];
18
+ const CON_NOMBRE = [/\b(Bearer\s+)[A-Za-z0-9._~+/-]{16,}=*/gi, /\b((?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['"]?)[^\s'"]{6,}/gi];
19
+ export function taparSecretos(s) {
20
+ let r = s;
21
+ for (const re of CON_NOMBRE)
22
+ r = r.replace(re, (_m, nombre) => `${nombre}[hidden]`);
23
+ for (const re of SECRETOS)
24
+ r = r.replace(re, '[hidden]');
25
+ return r;
26
+ }
27
+ const MAX_TEXTO = 8000;
28
+ export function paraElMovil(v, max = MAX_TEXTO) {
29
+ const s = taparSecretos(sinColor(typeof v === 'string' ? v : v == null ? '' : String(v)));
30
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
31
+ }
32
+ function limpiar(datos) {
33
+ const out = {};
34
+ for (const [k, v] of Object.entries(datos)) {
35
+ if (typeof v === 'string')
36
+ out[k] = paraElMovil(v);
37
+ else if (Array.isArray(v))
38
+ out[k] = v.slice(0, 60).map((x) => (typeof x === 'string' ? paraElMovil(x, 400) : x && typeof x === 'object' ? limpiar(x) : x));
39
+ else if (v && typeof v === 'object')
40
+ out[k] = limpiar(v);
41
+ else
42
+ out[k] = v;
43
+ }
44
+ return out;
45
+ }
46
+ const API = { abrir: abrirRemoto, latido: latidoRemoto, ordenes: ordenesRemotas, cerrar: cerrarRemoto };
47
+ let contadorDePreguntas = 0;
48
+ export class Remoto {
49
+ id = null;
50
+ url = null;
51
+ o;
52
+ api;
53
+ n = 0;
54
+ cola = [];
55
+ estado = 'libre';
56
+ tarea = null;
57
+ acciones = 0;
58
+ modo = '';
59
+ modelo = '';
60
+ activo = false;
61
+ enviando = false;
62
+ otraVez = false;
63
+ relojEnvio = null;
64
+ relojLatido = null;
65
+ escucha = null;
66
+ ultimoEnvio = 0;
67
+ preguntas = new Map();
68
+ constructor(o) {
69
+ this.o = o;
70
+ this.api = { ...API, ...o.api };
71
+ }
72
+ get encendido() {
73
+ return this.activo;
74
+ }
75
+ async abrir(datos) {
76
+ const r = await this.api.abrir(this.o.servidor, this.o.token, datos);
77
+ this.id = r.id;
78
+ this.url = r.url;
79
+ this.modo = datos.modo;
80
+ this.modelo = datos.modelo;
81
+ this.activo = true;
82
+ this.relojLatido = setInterval(() => void this.enviar(), this.o.latidoMs ?? 15_000);
83
+ this.relojLatido.unref?.();
84
+ void this.enviar();
85
+ void this.escuchar();
86
+ return r;
87
+ }
88
+ async cerrar() {
89
+ if (!this.activo)
90
+ return;
91
+ this.apagar();
92
+ if (this.id) {
93
+ try {
94
+ await this.api.cerrar(this.o.servidor, this.o.token, this.id);
95
+ }
96
+ catch {
97
+ }
98
+ }
99
+ }
100
+ apagar() {
101
+ this.activo = false;
102
+ if (this.carpeta && !this.o.carpetaDeFotos)
103
+ rmSync(this.carpeta, { recursive: true, force: true });
104
+ if (this.relojEnvio)
105
+ clearTimeout(this.relojEnvio);
106
+ if (this.relojLatido)
107
+ clearInterval(this.relojLatido);
108
+ this.relojEnvio = this.relojLatido = null;
109
+ this.escucha?.abort();
110
+ this.escucha = null;
111
+ this.preguntas.clear();
112
+ }
113
+ evento(tipo, datos) {
114
+ if (!this.activo)
115
+ return;
116
+ this.cola.push({ n: this.n++, tipo, datos: limpiar(datos) });
117
+ if (!this.relojEnvio) {
118
+ this.relojEnvio = setTimeout(() => {
119
+ this.relojEnvio = null;
120
+ void this.enviar();
121
+ }, this.o.cadaMs ?? 400);
122
+ this.relojEnvio.unref?.();
123
+ }
124
+ }
125
+ fijarEstado(estado, tarea) {
126
+ if (!this.activo)
127
+ return;
128
+ const cambia = estado !== this.estado || (tarea !== undefined && tarea !== this.tarea);
129
+ this.estado = estado;
130
+ if (tarea !== undefined)
131
+ this.tarea = tarea ? paraElMovil(tarea, 200) : null;
132
+ if (cambia)
133
+ void this.enviar();
134
+ }
135
+ fijarModo(modo, modelo) {
136
+ this.modo = modo;
137
+ this.modelo = modelo;
138
+ }
139
+ progreso(acciones) {
140
+ this.acciones = acciones;
141
+ }
142
+ preguntaRemota(texto, clase, opciones) {
143
+ const id = `p${Date.now().toString(36)}${(contadorDePreguntas++).toString(36)}`;
144
+ const fuera = seContestaDesdeFuera(clase);
145
+ let deFuera = null;
146
+ const valores = opciones.map((x) => x.valor);
147
+ if (this.activo && fuera)
148
+ deFuera = new Promise((resolver) => this.preguntas.set(id, { resolver, valores }));
149
+ const anterior = this.estado;
150
+ this.evento('pregunta', { id, texto, clase, opciones: opciones.map((x) => ({ etiqueta: x.etiqueta, valor: x.valor })), remota: fuera });
151
+ this.fijarEstado('pregunta');
152
+ return {
153
+ id,
154
+ deFuera,
155
+ contestada: (valor, desde) => {
156
+ this.preguntas.delete(id);
157
+ this.evento('respuesta', { id, valor, desde });
158
+ this.fijarEstado(anterior === 'pregunta' ? 'trabajando' : anterior);
159
+ },
160
+ };
161
+ }
162
+ async enviar() {
163
+ if (!this.activo || !this.id)
164
+ return;
165
+ if (this.enviando) {
166
+ this.otraVez = true;
167
+ return;
168
+ }
169
+ this.enviando = true;
170
+ try {
171
+ const tanda = this.cola.slice(0, 200);
172
+ const r = await this.api.latido(this.o.servidor, this.o.token, this.id, {
173
+ estado: this.estado,
174
+ tarea: this.tarea,
175
+ acciones: this.acciones,
176
+ modo: this.modo,
177
+ modelo: this.modelo,
178
+ eventos: tanda,
179
+ });
180
+ this.cola.splice(0, tanda.length);
181
+ this.ultimoEnvio = Date.now();
182
+ if (r.cerrado) {
183
+ this.apagar();
184
+ this.o.alApagarse('web');
185
+ return;
186
+ }
187
+ }
188
+ catch (e) {
189
+ if (this.cola.length > 2000)
190
+ this.cola.splice(0, this.cola.length - 2000);
191
+ if (e instanceof ErrorDeParallel && (e.codigo === 'sesion' || e.codigo === 'sin_acceso')) {
192
+ this.apagar();
193
+ this.o.alApagarse(e.codigo);
194
+ return;
195
+ }
196
+ }
197
+ finally {
198
+ this.enviando = false;
199
+ }
200
+ if (this.otraVez || (this.cola.length && !this.relojEnvio)) {
201
+ this.otraVez = false;
202
+ void this.enviar();
203
+ }
204
+ }
205
+ async escuchar() {
206
+ let pausa = 1000;
207
+ while (this.activo && this.id) {
208
+ this.escucha = new AbortController();
209
+ try {
210
+ const r = await this.api.ordenes(this.o.servidor, this.o.token, this.id, this.o.esperaMs ?? 25_000, this.escucha.signal);
211
+ pausa = 1000;
212
+ if (r.cerrado) {
213
+ this.apagar();
214
+ this.o.alApagarse('web');
215
+ return;
216
+ }
217
+ for (const orden of r.ordenes)
218
+ this.cumplir(orden);
219
+ }
220
+ catch (e) {
221
+ if (!this.activo)
222
+ return;
223
+ if (e instanceof ErrorDeParallel && (e.codigo === 'sesion' || e.codigo === 'sin_acceso')) {
224
+ this.apagar();
225
+ this.o.alApagarse(e.codigo);
226
+ return;
227
+ }
228
+ await new Promise((r) => setTimeout(r, pausa).unref?.());
229
+ pausa = Math.min(30_000, pausa * 2);
230
+ }
231
+ }
232
+ }
233
+ cumplir(orden) {
234
+ if (!this.activo)
235
+ return;
236
+ if (orden.tipo === 'mensaje') {
237
+ const texto = typeof orden.datos.texto === 'string' ? orden.datos.texto.trim().slice(0, MAX_TEXTO) : '';
238
+ const rutas = this.guardarFotos(orden);
239
+ const conFotos = [texto || (rutas.length ? 'Look at this photo.' : ''), ...rutas.map((r) => `"${r}"`)].filter(Boolean).join(' ');
240
+ if (conFotos)
241
+ this.o.alMensaje(conFotos);
242
+ return;
243
+ }
244
+ if (orden.tipo === 'parar') {
245
+ this.o.alParar();
246
+ return;
247
+ }
248
+ if (orden.tipo === 'respuesta') {
249
+ const id = String(orden.datos.pregunta ?? '');
250
+ const valor = orden.datos.valor;
251
+ const p = this.preguntas.get(id);
252
+ if (!p || (valor !== 'si' && valor !== 'siempre' && valor !== 'no') || !p.valores.includes(valor))
253
+ return;
254
+ this.preguntas.delete(id);
255
+ p.resolver(valor);
256
+ }
257
+ }
258
+ carpeta = null;
259
+ guardarFotos(orden) {
260
+ const fotos = Array.isArray(orden.datos.fotos) ? orden.datos.fotos.slice(0, 3) : [];
261
+ const rutas = [];
262
+ fotos.forEach((f, i) => {
263
+ const ext = f.mime === 'image/png' ? 'png' : f.mime === 'image/webp' ? 'webp' : f.mime === 'image/jpeg' ? 'jpg' : null;
264
+ if (!ext || typeof f.datos !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(f.datos))
265
+ return;
266
+ const buf = Buffer.from(f.datos, 'base64');
267
+ if (buf.length < 16 || buf.length > 2_500_000)
268
+ return;
269
+ try {
270
+ this.carpeta ??= this.o.carpetaDeFotos ?? join(tmpdir(), `parx-remoto-${process.pid}`);
271
+ mkdirSync(this.carpeta, { recursive: true, mode: 0o700 });
272
+ const ruta = join(this.carpeta, `photo-${orden.id.replace(/[^a-z0-9-]/gi, '').slice(0, 12)}-${i + 1}.${ext}`);
273
+ writeFileSync(ruta, buf, { mode: 0o600 });
274
+ rutas.push(ruta);
275
+ }
276
+ catch {
277
+ }
278
+ });
279
+ return rutas;
280
+ }
281
+ get msDesdeElUltimoEnvio() {
282
+ return this.ultimoEnvio ? Date.now() - this.ultimoEnvio : Number.POSITIVE_INFINITY;
283
+ }
284
+ }
package/dist/tar.js ADDED
@@ -0,0 +1,99 @@
1
+ import { gunzipSync } from 'node:zlib';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, normalize, sep } from 'node:path';
4
+ const BLOQUE = 512;
5
+ function campo(b, desde, largo) {
6
+ const fin = b.indexOf(0, desde);
7
+ return b.subarray(desde, fin === -1 || fin > desde + largo ? desde + largo : fin).toString('utf8');
8
+ }
9
+ function numero(b, desde, largo) {
10
+ const s = campo(b, desde, largo).trim();
11
+ if (!s)
12
+ return 0;
13
+ if (b[desde] & 0x80) {
14
+ let n = 0;
15
+ for (let i = 1; i < largo; i++)
16
+ n = n * 256 + b[desde + i];
17
+ return n;
18
+ }
19
+ const n = parseInt(s, 8);
20
+ return Number.isFinite(n) ? n : 0;
21
+ }
22
+ export function leerTar(tar, limites = {}) {
23
+ const maxFicheros = limites.maxFicheros ?? 400;
24
+ const maxBytes = limites.maxBytes ?? 20 * 1024 * 1024;
25
+ const prefijo = limites.prefijo ?? 'package/';
26
+ const salida = [];
27
+ let bytes = 0;
28
+ let pos = 0;
29
+ let nombreLargo = null;
30
+ let rutaPax = null;
31
+ while (pos + BLOQUE <= tar.length) {
32
+ const cab = tar.subarray(pos, pos + BLOQUE);
33
+ if (cab.every((x) => x === 0))
34
+ break;
35
+ const tipo = String.fromCharCode(cab[156]);
36
+ const tamano = numero(cab, 124, 12);
37
+ const datos = tar.subarray(pos + BLOQUE, pos + BLOQUE + tamano);
38
+ pos += BLOQUE + Math.ceil(tamano / BLOQUE) * BLOQUE;
39
+ if (tipo === 'L') {
40
+ nombreLargo = datos.toString('utf8').replace(/\0+$/, '');
41
+ continue;
42
+ }
43
+ if (tipo === 'x') {
44
+ for (const linea of datos.toString('utf8').split('\n')) {
45
+ const m = /^\d+ path=(.*)$/.exec(linea);
46
+ if (m)
47
+ rutaPax = m[1];
48
+ }
49
+ continue;
50
+ }
51
+ if (tipo === 'g')
52
+ continue;
53
+ let nombre = rutaPax ?? nombreLargo ?? campo(cab, 0, 100);
54
+ if (!rutaPax && !nombreLargo && campo(cab, 257, 6) === 'ustar') {
55
+ const pre = campo(cab, 345, 155);
56
+ if (pre)
57
+ nombre = `${pre}/${nombre}`;
58
+ }
59
+ rutaPax = null;
60
+ nombreLargo = null;
61
+ if (tipo === '5' || nombre.endsWith('/'))
62
+ continue;
63
+ if (tipo !== '0' && tipo !== '\0' && tipo !== '7')
64
+ throw new Error(`unsupported entry type "${tipo}" for ${nombre}`);
65
+ if (!nombre.startsWith(prefijo))
66
+ throw new Error(`entry outside the package: ${nombre}`);
67
+ const ruta = nombre.slice(prefijo.length);
68
+ if (!rutaSegura(ruta))
69
+ throw new Error(`unsafe path: ${nombre}`);
70
+ bytes += datos.length;
71
+ if (salida.length >= maxFicheros || bytes > maxBytes)
72
+ throw new Error('package too large');
73
+ salida.push({ ruta, contenido: Buffer.from(datos) });
74
+ }
75
+ return salida;
76
+ }
77
+ export function rutaSegura(ruta) {
78
+ if (!ruta || ruta.includes('\\') || ruta.includes('\0') || ruta.startsWith('/'))
79
+ return false;
80
+ if (/^[A-Za-z]:/.test(ruta))
81
+ return false;
82
+ const partes = ruta.split('/');
83
+ return partes.every((p) => p && p !== '.' && p !== '..');
84
+ }
85
+ export function leerTgz(tgz, limites = {}) {
86
+ const maxBytes = limites.maxBytes ?? 20 * 1024 * 1024;
87
+ const tar = gunzipSync(tgz, { maxOutputLength: maxBytes + 2 * 1024 * 1024 });
88
+ return leerTar(tar, limites);
89
+ }
90
+ export function escribirFicheros(ficheros, destino) {
91
+ const base = normalize(destino + sep);
92
+ for (const f of ficheros) {
93
+ const ruta = normalize(join(destino, ...f.ruta.split('/')));
94
+ if (!ruta.startsWith(base))
95
+ throw new Error(`unsafe path: ${f.ruta}`);
96
+ mkdirSync(dirname(ruta), { recursive: true });
97
+ writeFileSync(ruta, f.contenido, { mode: f.ruta.endsWith('.js') ? 0o755 : 0o644 });
98
+ }
99
+ }
package/dist/textos.js CHANGED
@@ -206,19 +206,43 @@ export const TEXTOS = {
206
206
  'salir.entorno': 'You are signed in with PARALLEL_API_KEY: remove it from your environment to sign out.',
207
207
  'salir.todas': 'Signed out on {n} terminals. API keys stay active.',
208
208
  'nombre.nuevo': 'parallel-code is now called parx. Type “parx”.',
209
+ 'estimacion.linea': '~{n} Actions (estimate)',
210
+ 'estimacion.con': '~{n} Actions (estimate) · with {modelo}: ~{otro}',
209
211
  'update.disponible': 'parx {version} available · run `parx update`',
210
212
  'update.al_dia': 'parx {version} is the latest version.',
211
213
  'update.sin_red': 'Could not reach npm to check for a new version. Try again later.',
212
- 'update.lanzando': 'Updating with: {orden}',
213
- 'update.hecho': 'Done. parx is now up to date.',
214
- 'update.fallo': 'The update did not finish. Run it yourself: {orden}',
215
214
  'update.npx': 'You are running parx through npx. Use `npx @getparallel/parx@latest` to always get the latest version.',
216
215
  'update.desarrollo': 'This is a development copy of parx (not installed from npm). Update it with git pull.',
216
+ 'update.actualizado': 'parx updated to {version}',
217
+ 'update.actualizado_con': 'parx updated to {version} · {novedad}',
218
+ 'update.preparada': 'parx {version} is ready · it runs the next time you open parx',
219
+ 'update.ya_preparada': 'parx {version} is already downloaded · it runs the next time you open parx',
220
+ 'update.no_arranco': 'parx {version} did not start · back on {actual}',
221
+ 'update.firma': 'parx {version} is on npm but its signature could not be verified, so it was not installed. Update by hand: {orden}',
222
+ 'update.no_se_pudo': 'parx {version} could not be downloaded or checked. Update by hand: {orden}',
223
+ 'autoupdate.on': 'parx keeps itself up to date: new versions are downloaded in the background and start with the next session.',
224
+ 'autoupdate.off': 'parx will not update itself. You will see a line when there is a new version; `parx update` installs it. /autoupdate on to turn it back on.',
225
+ 'remoto.abriendo': 'Turning on remote control…',
226
+ 'remoto.on': 'Remote control is on. Open this on your phone or any browser: {url}',
227
+ 'remoto.escanea': 'Or scan it with your phone:',
228
+ 'remoto.detalle': 'Everything still runs on this computer. Credentials and Bypass are only answered here.',
229
+ 'remoto.siempre': 'Remote control will turn on every time parx opens on this computer. /remote off to stop.',
230
+ 'remoto.off': 'Remote control is off on this computer.',
231
+ 'remoto.no_disponible': 'Remote control is not available for your account yet.',
232
+ 'remoto.sin_red': 'Could not turn on remote control: {motivo}. Try /remote again.',
233
+ 'remoto.cortado_web': 'Remote control was turned off from the web.',
234
+ 'remoto.sesion': 'Remote control stopped: sign in again with parx login.',
235
+ 'remoto.demasiados': 'You already have 12 parx sessions with remote control open. Close one and try again.',
236
+ 'remoto.del_movil': 'From your phone',
237
+ 'remoto.desde_el_movil': 'from your phone',
238
+ 'remoto.parado': 'Stopped from your phone.',
239
+ 'remoto.solo_interactiva': 'Remote control works in an interactive session: run parx and then /remote.',
217
240
  'error.sin_saldo': 'You are out of Actions. Top up or upgrade at getparallel.net/settings/billing',
218
241
  'error.sin_acceso': 'Your account does not have parx yet: it is in testing.',
219
242
  'error.sesion': 'Your session expired. Run: parx login',
220
243
  'error.red': 'Could not reach Parallel: {detalle}',
221
244
  'error.servidor': 'Parallel could not answer right now ({detalle}). Try again.',
245
+ 'error.ia': 'Parallel’s AI is temporarily unavailable. Your Actions weren’t charged. Try again in a minute.',
222
246
  'error.demasiado': 'This conversation is too long. Type /new to start another one.',
223
247
  'repl.adios': 'See you.',
224
248
  'repl.registro': 'Log of this session: {ruta}',
@@ -300,6 +324,7 @@ export const COMANDOS = [
300
324
  ['model', 'Swift, Stride or Summit'],
301
325
  ['folder', 'Let parx work in another folder'],
302
326
  ['lanes', 'Run 2 or 3 tasks at once'],
327
+ ['remote', 'Control this parx from your phone'],
303
328
  ['mission', 'Keep it running on Parallel, phone alert when done'],
304
329
  ['missions', 'Pick a mission and control it from here'],
305
330
  ['inbox', 'Answer what your missions are waiting for'],
@@ -315,6 +340,7 @@ export const COMANDOS = [
315
340
  ['tips', 'Tips when it opens: on or off'],
316
341
  ['mouse', 'Clicks and wheel in parx: on or off'],
317
342
  ['animation', 'Answers written out as they arrive: on or off'],
343
+ ['autoupdate', 'New versions install themselves: on or off'],
318
344
  ['log', 'Where the log of what it does is kept'],
319
345
  ['exit', 'Leave parx'],
320
346
  ];
@@ -325,6 +351,7 @@ export const ALIAS_COMANDO = {
325
351
  strength: 'model',
326
352
  folder: 'folder', 'add-folder': 'folder', carpeta: 'folder', dir: 'folder',
327
353
  lanes: 'lanes', carriles: 'lanes',
354
+ remote: 'remote', 'remote-control': 'remote', rc: 'remote', remoto: 'remote', phone: 'remote',
328
355
  mission: 'mission', mision: 'mission', misión: 'mission',
329
356
  missions: 'missions', misiones: 'missions',
330
357
  inbox: 'inbox', bandeja: 'inbox', approvals: 'inbox', waiting: 'inbox',
@@ -333,6 +360,7 @@ export const ALIAS_COMANDO = {
333
360
  settings: 'settings', ajustes: 'settings', preferences: 'settings',
334
361
  mouse: 'mouse', raton: 'mouse', ratón: 'mouse',
335
362
  animation: 'animation', animations: 'animation', animacion: 'animation', animación: 'animation',
363
+ autoupdate: 'autoupdate', 'auto-update': 'autoupdate', autoupdates: 'autoupdate', updates: 'autoupdate', autoactualizar: 'autoupdate',
336
364
  report: 'report', informe: 'report',
337
365
  teach: 'teach', class: 'teach', clase: 'teach',
338
366
  undo: 'undo', deshacer: 'undo',
@@ -343,7 +371,7 @@ export const ALIAS_COMANDO = {
343
371
  log: 'log', registro: 'log',
344
372
  exit: 'exit', quit: 'exit', salir: 'exit',
345
373
  };
346
- export const ALIAS_DESDE = { strength: 2 };
374
+ export const ALIAS_DESDE = { strength: 2, rc: 2, phone: 3 };
347
375
  export const ALIAS_SI_NO = {
348
376
  on: 'si', yes: 'si', y: 'si', '1': 'si', si: 'si', 'sí': 'si',
349
377
  off: 'no', no: 'no', n: 'no', '0': 'no',
@@ -354,7 +382,7 @@ export const USO = [
354
382
  ' parx "task" do the task and exit',
355
383
  ' parx --lanes "task" "task" ... 2 or 3 tasks at once, each in its own lane',
356
384
  ' parx login | logout | account',
357
- ' parx update update parx to the latest version',
385
+ ' parx update get the latest version now (parx also updates itself; /autoupdate off stops that)',
358
386
  '',
359
387
  'Options:',
360
388
  ' --mode ask|auto|read Ask (default), Auto inside the project, Read only',
@@ -366,6 +394,7 @@ export const USO = [
366
394
  ' --ascii plain characters only (also NO_COLOR for no color)',
367
395
  ' --no-mouse leave the mouse to the terminal (no clicks or wheel in parx)',
368
396
  ' --no-animation show answers whole, without writing them out',
397
+ ' --remote control this session from your phone (same as /remote)',
369
398
  ' --version, --help',
370
399
  '',
371
400
  'Sign in:',
@@ -377,7 +406,15 @@ export const USO = [
377
406
  ' parx logout [--all] sign out here, or on every terminal',
378
407
  ' PARALLEL_API_KEY=par_key_… sign in with that key without saving anything (CI)',
379
408
  '',
409
+ 'Remote control:',
410
+ ' /remote link and QR to control this parx from your phone or a browser',
411
+ ' /remote always turn it on every time parx opens on this computer',
412
+ ' /remote off turn it off here',
413
+ '',
380
414
  'Updates:',
381
- ' parx checks npm for a new version at most once a day, without slowing anything down.',
382
- ' PARX_NO_UPDATE_CHECK=1 turn that check off',
415
+ ' parx checks npm every few hours, without slowing anything down, and installs new versions',
416
+ ' itself: they are verified against npm’s signature and start with your next session.',
417
+ ' /autoupdate off only tell me when there is one (then: parx update)',
418
+ ' PARX_NO_AUTO_UPDATE=1 the same, from the environment',
419
+ ' PARX_NO_UPDATE_CHECK=1 never even check',
383
420
  ].join('\n');
package/dist/tui.js CHANGED
@@ -289,9 +289,9 @@ function teclaDeRaton(b, x, y, soltar, botonX10) {
289
289
  }
290
290
  export const RATON_SI = '\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h';
291
291
  export const RATON_NO = '\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l';
292
- const CON_VALOR = new Set(['mode', 'model', 'tips', 'notify', 'teach', 'mouse', 'animation']);
292
+ const CON_VALOR = new Set(['mode', 'model', 'tips', 'notify', 'teach', 'mouse', 'animation', 'autoupdate', 'remote']);
293
293
  const CON_TEXTO = new Set(['folder']);
294
- const SE_MANDAN_SOLOS = new Set(['teach', 'notify', 'tips', 'folder', 'mouse', 'animation']);
294
+ const SE_MANDAN_SOLOS = new Set(['teach', 'notify', 'tips', 'folder', 'mouse', 'animation', 'autoupdate', 'remote']);
295
295
  const MODOS_DEL_MENU = ['ask', 'auto', 'read', 'bypass'];
296
296
  export function opcionesDelMenu(valor) {
297
297
  if (!valor.startsWith('/') || valor.includes('\n'))
@@ -322,10 +322,16 @@ export function opcionesDelMenu(valor) {
322
322
  ? MODOS_DEL_MENU.map((k) => [k, tt(`modo.${k}.desc`)])
323
323
  : cmd === 'model'
324
324
  ? ORDEN_DE_MODELOS.map((k) => [k, `${MODELOS[k].nombre} ${MODELOS[k].version}: ${MODELOS[k].descripcion}`])
325
- : [
326
- ['on', cmd === 'teach' ? 'Explain each step as it codes' : cmd === 'notify' ? 'Notify me when it finishes or needs me' : cmd === 'mouse' ? 'Click options, scroll with the wheel' : cmd === 'animation' ? 'Write answers out as they arrive' : 'Show them when parx opens'],
327
- ['off', cmd === 'teach' ? 'Back to normal' : cmd === 'notify' ? 'No desktop notifications' : cmd === 'mouse' ? 'The terminal selects and scrolls as usual' : cmd === 'animation' ? 'Show answers whole, at once' : 'Don’t show them again'],
328
- ];
325
+ : cmd === 'remote'
326
+ ? [
327
+ ['on', 'Link and QR for your phone, for this session'],
328
+ ['always', 'Every time parx opens on this computer'],
329
+ ['off', 'Stop remote control here'],
330
+ ]
331
+ : [
332
+ ['on', cmd === 'teach' ? 'Explain each step as it codes' : cmd === 'notify' ? 'Notify me when it finishes or needs me' : cmd === 'mouse' ? 'Click options, scroll with the wheel' : cmd === 'animation' ? 'Write answers out as they arrive' : cmd === 'autoupdate' ? 'New versions install themselves, for the next session' : 'Show them when parx opens'],
333
+ ['off', cmd === 'teach' ? 'Back to normal' : cmd === 'notify' ? 'No desktop notifications' : cmd === 'mouse' ? 'The terminal selects and scrolls as usual' : cmd === 'animation' ? 'Show answers whole, at once' : cmd === 'autoupdate' ? 'Only tell me; I run parx update myself' : 'Don’t show them again'],
334
+ ];
329
335
  return valores
330
336
  .filter(([v]) => v.startsWith(q))
331
337
  .map(([v, d]) => ({ nombre: v, descripcion: d, completar: `${base}${v}`, enviar: true, ...(cmd === 'mode' ? { modo: v, ...(v === 'bypass' ? { peligro: true } : {}) } : {}) }));
@@ -547,7 +553,7 @@ export function componerCarril(d, ancho, alto) {
547
553
  cursor = null;
548
554
  }
549
555
  if (conPar)
550
- filaEscribir += ` ${cabezaDePar()} `;
556
+ filaEscribir += ` ${cabezaDePar(d.gesto)} `;
551
557
  const tinta = (t) => (bash ? c.bash(t) : c.violeta(t));
552
558
  const linea = conPar ? `${tinta(carril(util - ANCHO_DE_PAR - 2))}${caraDePar(d.gesto)}${tinta(carril(2))}` : tinta(carril(util));
553
559
  let barra;
@@ -1485,6 +1491,19 @@ export class Superficie {
1485
1491
  this.ponerValor(texto);
1486
1492
  this.enviar();
1487
1493
  }
1494
+ mandarDesdeFuera(texto) {
1495
+ const t = /^[/!]/.test(texto.trimStart()) ? `\u2060${texto.trimStart()}` : texto;
1496
+ if (this.resolverLectura && !this.trabajo && !this.pregunta) {
1497
+ const r = this.resolverLectura;
1498
+ this.resolverLectura = null;
1499
+ this.irAlFondo();
1500
+ this.anadir(this.bloqueDeEnvio(t));
1501
+ r(t);
1502
+ return;
1503
+ }
1504
+ this.cola.push(t);
1505
+ this.mostrarAviso(this.cola.length > 1 ? tt('tui.en_cola_n', { n: this.cola.length }) : tt('tui.en_cola'));
1506
+ }
1488
1507
  get libre() {
1489
1508
  return Boolean(this.resolverLectura) && !this.trabajo && !this.pregunta;
1490
1509
  }
@@ -1520,11 +1539,18 @@ export class Superficie {
1520
1539
  actualizarCarriles() {
1521
1540
  this.pintar();
1522
1541
  }
1523
- preguntar(texto, tipo = 'normal') {
1542
+ preguntar(texto, tipo = 'normal', externo) {
1524
1543
  const o = opcionesDePregunta(tipo);
1525
1544
  const peligro = tipo === 'bypass';
1526
1545
  return new Promise((resolver) => {
1527
- this.mostrarPregunta({
1546
+ let hecha = false;
1547
+ const contestar = (r) => {
1548
+ if (hecha)
1549
+ return;
1550
+ hecha = true;
1551
+ resolver(r);
1552
+ };
1553
+ const quitar = this.mostrarPregunta({
1528
1554
  texto,
1529
1555
  opciones: o.opciones,
1530
1556
  cortas: o.cortas,
@@ -1532,7 +1558,13 @@ export class Superficie {
1532
1558
  ...(peligro ? { peligro: true } : {}),
1533
1559
  indiceNo: o.valores.indexOf('no'),
1534
1560
  siConY: o.valores[o.elegida] !== 'no' || peligro,
1535
- }, (i) => resolver(i === null ? 'no' : (o.valores[i] ?? 'no')));
1561
+ }, (i) => contestar(i === null ? 'no' : (o.valores[i] ?? 'no')));
1562
+ externo?.then((r) => {
1563
+ if (hecha || !o.valores.includes(r))
1564
+ return;
1565
+ contestar(r);
1566
+ quitar();
1567
+ });
1536
1568
  });
1537
1569
  }
1538
1570
  elegir(texto, opciones, o = {}) {
@@ -1560,6 +1592,20 @@ export class Superficie {
1560
1592
  this.esperando.push(mostrar);
1561
1593
  else
1562
1594
  mostrar();
1595
+ return () => {
1596
+ const i = this.esperando.indexOf(mostrar);
1597
+ if (i >= 0) {
1598
+ this.esperando.splice(i, 1);
1599
+ return;
1600
+ }
1601
+ if (this.pregunta?.resolver !== resolver)
1602
+ return;
1603
+ this.pregunta = null;
1604
+ this.sobre = null;
1605
+ this.pulsado = null;
1606
+ this.pintar();
1607
+ this.esperando.shift()?.();
1608
+ };
1563
1609
  }
1564
1610
  responder(i) {
1565
1611
  const p = this.pregunta;