chocolatito-code 1.2.0 → 1.3.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.
@@ -68,6 +68,155 @@ function ambiguityNote(p, choice) {
68
68
  function fail(action, msg) {
69
69
  return `Error en computer_use (${action}): ${msg}`;
70
70
  }
71
+ /**
72
+ * El estado de la ventana DESPUES de actuar, en el mismo resultado.
73
+ *
74
+ * POR QUE
75
+ *
76
+ * El ciclo de trabajo del agente es mirar, actuar, mirar. Hasta ahora eso
77
+ * costaba TRES viajes al modelo:
78
+ *
79
+ * ui_snapshot -> llamada al modelo
80
+ * ui_click -> llamada al modelo (devolvia solo "Activado [3]")
81
+ * ui_snapshot -> llamada al modelo (para ver que habia pasado)
82
+ *
83
+ * Y cada viaje son entre tres y diez segundos. Medido en esta maquina, un
84
+ * ui_snapshot cuesta 43 ms: es GRATIS al lado de una llamada al modelo. Asi que
85
+ * el tercer viaje se hacia solo para pedir algo que el ordenador podia haber
86
+ * adjuntado desde el principio.
87
+ *
88
+ * Devolviendo el estado nuevo con la accion, el ciclo pasa de tres viajes a
89
+ * dos. En una tarea de diez pasos son diez viajes menos: medio minuto largo.
90
+ *
91
+ * CUANDO NO SE HACE
92
+ *
93
+ * Si la accion fallo no se adjunta nada: lo que hace falta entonces es el
94
+ * motivo del fallo, y una lista de controles solo lo entierra.
95
+ */
96
+ /**
97
+ * Mirar una ventana muchas veces sin pagarlo muchas veces.
98
+ *
99
+ * EL PROBLEMA
100
+ *
101
+ * Leer el arbol de accesibilidad cuesta 43 ms: es gratis. Lo que no es gratis
102
+ * es MANDARSELO al modelo. Un ui_snapshot de una ventana normal son cien lineas
103
+ * que entran en el historial y se vuelven a pagar en cada turno siguiente. Asi
104
+ * que mirar seguido -que es lo que hace falta para trabajar en tiempo real- sale
105
+ * caro por el lado del contexto, no por el de la maquina.
106
+ *
107
+ * LA IDEA
108
+ *
109
+ * Casi nunca cambia la ventana entera. Se pulsa un boton y se habilita otro; se
110
+ * escribe en un campo y cambia su valor. De cien controles cambian dos.
111
+ *
112
+ * Guardando la ultima foto de cada ventana se puede devolver solo la diferencia:
113
+ *
114
+ * CAMBIOS EN "Calculadora" (3 de 47 controles):
115
+ * ~ [12] Text "0" -> "42"
116
+ * + [31] Button "Igual" (nuevo)
117
+ * - Button "Limpiar" (ya no esta)
118
+ *
119
+ * Tres lineas en vez de cuarenta y siete. Eso es lo que hace viable mirar
120
+ * despues de cada accion sin inflar el contexto.
121
+ *
122
+ * CUANDO SE DEVUELVE LA FOTO ENTERA
123
+ *
124
+ * La primera vez, cuando no hay con que comparar. Y cuando cambia tanto que el
125
+ * resumen ya no es mas corto que la lista: si cambio media ventana, es que se
126
+ * abrio otra pantalla, y ahi lo util es verla entera.
127
+ */
128
+ /** La ultima foto de cada ventana, para poder contar solo lo que cambio. */
129
+ const ultimaFoto = new Map();
130
+ /** Identidad de un control que NO depende de su numero de ref, que se renumera. */
131
+ function claveDe(e) {
132
+ return `${e.role}::${e.name}`;
133
+ }
134
+ function compararCon(clave, els) {
135
+ const ahora = new Map();
136
+ for (const e of els)
137
+ ahora.set(claveDe(e), String(e.value ?? ""));
138
+ const antes = ultimaFoto.get(clave);
139
+ ultimaFoto.set(clave, ahora);
140
+ if (!antes)
141
+ return { hayFoto: false, texto: "", cuantos: 0 };
142
+ const lineas = [];
143
+ const porClave = new Map(els.map((e) => [claveDe(e), e]));
144
+ for (const [k, valor] of ahora) {
145
+ const e = porClave.get(k);
146
+ if (!antes.has(k)) {
147
+ lineas.push(` + [${e.ref}] ${e.role} "${e.name}" (nuevo)`);
148
+ }
149
+ else if (antes.get(k) !== valor) {
150
+ lineas.push(` ~ [${e.ref}] ${e.role} "${e.name}": "${antes.get(k)}" -> "${valor}"`);
151
+ }
152
+ }
153
+ for (const k of antes.keys()) {
154
+ if (!ahora.has(k)) {
155
+ const [role, name] = k.split("::");
156
+ lineas.push(` - ${role} "${name}" (ya no esta)`);
157
+ }
158
+ }
159
+ return { hayFoto: true, texto: lineas.join("\n"), cuantos: lineas.length };
160
+ }
161
+ /** Cuantos controles se adjuntan. Bastantes para decidir, pocos para no inundar. */
162
+ const CONTROLES_TRAS_ACTUAR = 40;
163
+ /**
164
+ * Espera a que la ventana se quede quieta, y no un tiempo fijo.
165
+ *
166
+ * Un clic puede abrir un menu, cargar una lista o no hacer nada visible. Dormir
167
+ * 700 ms "por si acaso" es lento cuando no pasa nada y corto cuando si pasa.
168
+ * Se mira dos veces seguidas: si el arbol de controles sale igual, la ventana
169
+ * ya termino.
170
+ */
171
+ async function esperarQuieto(args) {
172
+ let anterior = "";
173
+ for (let intento = 0; intento < 4; intento++) {
174
+ const res = await winHost.send("ui_snapshot", { ...args, max: CONTROLES_TRAS_ACTUAR, interactiveOnly: true }, 20_000);
175
+ if (!res.ok)
176
+ return null;
177
+ const huella = JSON.stringify((res.elements || []).map((e) => [e.role, e.name, e.value]));
178
+ if (huella === anterior)
179
+ return res;
180
+ anterior = huella;
181
+ await new Promise((r) => setTimeout(r, 120));
182
+ }
183
+ // Cuatro vueltas sin quedarse quieta: la ventana esta animando algo. Se
184
+ // devuelve lo ultimo visto, que es mas util que nada.
185
+ return null;
186
+ }
187
+ /** Lo que se pega al resultado de una accion. Cadena vacia si no hay nada que contar. */
188
+ async function estadoTrasActuar(args) {
189
+ let res;
190
+ try {
191
+ res = await esperarQuieto(args);
192
+ }
193
+ catch {
194
+ return "";
195
+ }
196
+ const els = res?.elements || [];
197
+ if (els.length === 0)
198
+ return "";
199
+ // Si hay foto anterior de esta ventana, con decir lo que cambio basta: tres
200
+ // lineas en vez de cuarenta, y el contexto no se infla en cada clic.
201
+ const cambios = compararCon(String(res.title || ""), els);
202
+ if (cambios.hayFoto && cambios.cuantos > 0 && cambios.cuantos <= els.length / 2) {
203
+ return `\n\nCAMBIOS EN "${res.title}" (${cambios.cuantos} de ${els.length} controles):\n${cambios.texto}`;
204
+ }
205
+ if (cambios.hayFoto && cambios.cuantos === 0) {
206
+ return `\n\nLa ventana "${res.title}" no cambio en nada visible.`;
207
+ }
208
+ // Primera vez, o cambio tanto que el resumen ya no seria mas corto: entonces
209
+ // lo util es la lista entera, porque eso significa que se abrio otra pantalla.
210
+ const filas = els
211
+ .map((e) => {
212
+ const val = e.value ? ` = "${e.value}"` : "";
213
+ const off = e.enabled ? "" : " [deshabilitado]";
214
+ return ` [${e.ref}] ${e.role} "${e.name}"${val}${off} @(${e.x}, ${e.y})`;
215
+ })
216
+ .join("\n");
217
+ return (`\n\nASI HA QUEDADO "${res.title}" (los numeros son NUEVOS, usa estos):\n${filas}` +
218
+ (res.truncated ? `\n … hay mas controles; pide ui_snapshot con 'max' mas alto si te falta alguno.` : ""));
219
+ }
71
220
  export async function computerUse(params, cwd = process.cwd(), apiKey) {
72
221
  const { action } = params;
73
222
  const mode = params.mode || "background";
@@ -151,6 +300,10 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
151
300
  `Si es un lienzo grafico, usa screenshot con analyze.` +
152
301
  ambiguityNote(params, choice));
153
302
  }
303
+ // Se apunta esta foto como referencia: si no, el resumen de cambios de la
304
+ // siguiente accion comparara contra una foto vieja y contara cambios que
305
+ // el modelo ya ha visto.
306
+ compararCon(String(res.title || ""), els);
154
307
  const rows = els
155
308
  .map((e) => {
156
309
  const val = e.value ? ` = "${e.value}"` : "";
@@ -163,13 +316,38 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
163
316
  `Actua con ui_click / ui_type usando el numero entre corchetes. No hace falta mover el raton.` +
164
317
  ambiguityNote(params, choice));
165
318
  }
319
+ // Esperar a que la ventana haga algo, en vez de dormir un rato y mirar.
320
+ //
321
+ // Antes solo existia "wait", que duerme un numero fijo de milisegundos:
322
+ // corto cuando la aplicacion tarda y desperdiciado cuando no tarda nada.
323
+ // Esto se suscribe a los avisos de la propia ventana y vuelve en cuanto
324
+ // pasa algo. Medido en vivo: 275 ms contra los 2 s que se habrian dormido.
325
+ case "wait_change": {
326
+ const elegida = await resolveWindow(params);
327
+ if (!elegida.ok)
328
+ return fail(action, elegida.error);
329
+ const ms = Math.min(Math.max(params.ms ?? 3000, 100), 30_000);
330
+ const res = await winHost.send("wait_change", { ...pinned(elegida), ...(elegida.hwnd ? {} : windowArgs(params)), timeoutMs: ms }, ms + 10_000);
331
+ if (!res.ok)
332
+ return fail(action, res.error || "fallo desconocido");
333
+ if (!res.changed) {
334
+ return (`La ventana "${elegida.title || params.window}" no cambio en ${ms} ms. ` +
335
+ `Si esperabas una reaccion, puede que la accion anterior no surtiera efecto.`);
336
+ }
337
+ // Y ya que se vuelve, se dice COMO quedo: si no, hace falta otro viaje.
338
+ const despues = await estadoTrasActuar(windowArgs(params));
339
+ return `Cambio: ${res.what}.${despues}`;
340
+ }
166
341
  case "ui_click": {
167
342
  if (!params.ref)
168
343
  return fail(action, 'indica el "ref" que devolvio ui_snapshot.');
169
344
  const res = await winHost.send("ui_click", { ref: params.ref }, 20_000);
170
345
  if (!res.ok)
171
346
  return fail(action, res.error || "fallo desconocido");
172
- return `Activado [${params.ref}] ${res.label || ""} (via ${res.via}, sin mover el raton).`;
347
+ // El estado nuevo va aqui y no en otro viaje al modelo: cuesta 43 ms en
348
+ // la maquina contra varios segundos de ida y vuelta. Ver estadoTrasActuar.
349
+ const despues = await estadoTrasActuar(windowArgs(params));
350
+ return `Activado [${params.ref}] ${res.label || ""} (via ${res.via}, sin mover el raton).${despues}`;
173
351
  }
174
352
  case "ui_type": {
175
353
  if (!params.ref)
@@ -221,8 +399,11 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
221
399
  interactiveOnly: false,
222
400
  });
223
401
  const landed = (check.elements || []).length > 0;
402
+ // Igual que en ui_click: el estado nuevo viaja con la accion en vez de
403
+ // costar otra ida y vuelta al modelo.
404
+ const tras = landed ? await estadoTrasActuar(windowArgs(params)) : "";
224
405
  return landed
225
- ? `Texto escrito en [${params.ref}] con clic y teclado reales, y verificado en el control: "${preview(params.text)}"`
406
+ ? `Texto escrito en [${params.ref}] con clic y teclado reales, y verificado en el control: "${preview(params.text)}"${tras}`
226
407
  : `Aviso: se escribio en [${params.ref}] pero el control no muestra el texto. ` +
227
408
  `Puede que el foco se perdiera. Haz ui_snapshot y reintenta antes de dar por hecho que funciono.`;
228
409
  }
@@ -82,7 +82,10 @@ export const COMPUTER_USE_TOOL = {
82
82
  "REGLA DE ORO: nunca hagas clic en una coordenada que no te haya dado antes una herramienta. " +
83
83
  "FLUJO CORRECTO: 'list_windows' para saber que hay abierto -> 'ui_snapshot' con la ventana objetivo para listar sus controles reales -> " +
84
84
  "'ui_click' / 'ui_type' con el numero de ref. Eso actua en segundo plano, sin mover el raton ni robar el foco al usuario. " +
85
- "Solo si el arbol de accesibilidad no ve el control (lienzos, juegos, imagenes) recurre a 'screenshot' con 'window' y luego a 'find_element'. " +
85
+ "CUANTO CUESTA CADA COSA, que decide cual usar: ui_snapshot tarda unos 40 ms y te devuelve texto que entiendes directo. "
86
+ + "screenshot cuesta una llamada entera a otro modelo -varios segundos- mas los tokens de la imagen. "
87
+ + "O sea que screenshot es unas cien veces mas caro. Usalo SOLO cuando el arbol de accesibilidad no vea el control: lienzos, juegos, imagenes, interfaces dibujadas a mano. "
88
+ + "Para todo lo demas, ui_snapshot y luego find_element. " +
86
89
  "Las coordenadas siempre son pixeles fisicos de pantalla.",
87
90
  parameters: {
88
91
  type: "object",
@@ -109,6 +112,7 @@ export const COMPUTER_USE_TOOL = {
109
112
  "type",
110
113
  "key",
111
114
  "wait",
115
+ "wait_change",
112
116
  "cursor_position",
113
117
  "focus_window",
114
118
  "get_active_window",
@@ -116,7 +120,7 @@ export const COMPUTER_USE_TOOL = {
116
120
  ],
117
121
  description: "list_windows: inventario de ventanas. ui_snapshot: lista los controles reales de una ventana con su ref. " +
118
122
  "ui_click/ui_type/ui_focus: actuan sobre un ref sin tocar el raton. find_element: busca un control por texto y, si hace falta, lo localiza con vision. " +
119
- "screenshot: captura una ventana concreta (aunque este tapada) y la describe. click/type/key: entrada real, solo como ultimo recurso.",
123
+ "wait_change: se suscribe a la ventana y vuelve EN CUANTO cambia algo, con el estado nuevo. Usalo en vez de wait siempre que esperes una reaccion: wait duerme un tiempo fijo (corto si la app tarda, desperdiciado si no). " + "screenshot: captura una ventana concreta (aunque este tapada) y la describe con otro modelo; lento y caro, ultimo recurso para lo que el arbol no ve. click/type/key: entrada real, solo como ultimo recurso.",
120
124
  },
121
125
  window: {
122
126
  type: "string",
@@ -5,15 +5,72 @@ import { apuntarUso } from "../agent/usageMeter.js";
5
5
  function client(apiKey) {
6
6
  return createEngineClient(apiKey, "vision");
7
7
  }
8
- function toDataUrl(imagePath) {
8
+ /**
9
+ * Cuanto puede pesar una imagen antes de que la peticion se muera por el camino.
10
+ *
11
+ * EL FALLO QUE CIERRA ESTO
12
+ *
13
+ * `toDataUrl` leia el archivo entero y lo metia en base64 sin mirar el tamano.
14
+ * Una captura de pantalla en PNG de un monitor normal son 1,3 MB, que en base64
15
+ * se convierten en 1,75 MB dentro del JSON de la peticion. Eso no llega: el
16
+ * Worker del proxy se queda sin margen a mitad de camino, la peticion muere sin
17
+ * respuesta, y ENCIMA se lleva por delante uno de los cuatro huecos de la
18
+ * licencia.
19
+ *
20
+ * Desde fuera se veia asi, y el usuario lo sufrio una tarde entera:
21
+ *
22
+ * chrome(screenshot) → chrome_tab.png (1314.8 KB)
23
+ * 💭 penso 4s
24
+ * ⚠ El motor no devolvio nada.
25
+ * ...
26
+ * ⏸ Tienes 4 peticiones en curso. Se libera en 169s.
27
+ *
28
+ * Cuatro capturas grandes y la licencia queda bloqueada tres minutos, sin que
29
+ * nada explique por que. Ahora se para ANTES de mandarla y se dice como
30
+ * arreglarlo, que cuesta lo mismo y no gasta un hueco.
31
+ *
32
+ * El limite no es caprichoso: 900 KB de base64 son ~675 KB de imagen, muy por
33
+ * encima de lo que ocupa una captura en JPEG (unos 120 KB) y muy por debajo de
34
+ * donde empiezan los problemas.
35
+ */
36
+ const MAX_BASE64 = 900_000;
37
+ /** El tipo real de la imagen, mirando sus primeros bytes y no su extension. */
38
+ function tipoDeImagen(buf) {
39
+ if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
40
+ return "image/jpeg";
41
+ if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50)
42
+ return "image/png";
43
+ if (buf.length > 12 && buf.toString("ascii", 8, 12) === "WEBP")
44
+ return "image/webp";
45
+ // Se deja pasar como PNG: equivocarse aqui es un error del servidor, no un
46
+ // cuelgue silencioso.
47
+ return "image/png";
48
+ }
49
+ function prepararImagen(imagePath) {
9
50
  const buf = fs.readFileSync(imagePath);
10
- return `data:image/png;base64,${buf.toString("base64")}`;
51
+ const b64 = buf.toString("base64");
52
+ if (b64.length > MAX_BASE64) {
53
+ const kb = Math.round(buf.length / 1024);
54
+ return {
55
+ ok: false,
56
+ error: `La imagen pesa ${kb} KB y no cabe en una peticion (el tope son ${Math.round(MAX_BASE64 / 1024)} KB de datos). ` +
57
+ `Mandarla igualmente no falla con un error: la peticion se muere por el camino, no vuelve nada, y se pierde ` +
58
+ `uno de los cuatro huecos de peticion durante minutos.\n\n` +
59
+ `Vuelve a capturar en JPEG, o recorta la zona que te interesa en vez de la pantalla entera.`,
60
+ };
61
+ }
62
+ return { ok: true, url: `data:${tipoDeImagen(buf)};base64,${b64}` };
11
63
  }
12
64
  async function ask(apiKey, prompt, imagePath, maxTokens) {
13
65
  if (!apiKey)
14
66
  return { ok: false, error: "No hay API key disponible para el modelo de vision." };
15
67
  if (!fs.existsSync(imagePath))
16
68
  return { ok: false, error: `No existe la imagen "${imagePath}".` };
69
+ // Se comprueba ANTES de gastar un hueco de peticion: una imagen demasiado
70
+ // grande no da error, mata la peticion en silencio. Ver prepararImagen().
71
+ const imagen = prepararImagen(imagePath);
72
+ if (!imagen.ok)
73
+ return { ok: false, error: imagen.error };
17
74
  try {
18
75
  const res = await client(apiKey).chat.completions.create({
19
76
  model: VISION_MODEL,
@@ -24,7 +81,7 @@ async function ask(apiKey, prompt, imagePath, maxTokens) {
24
81
  role: "user",
25
82
  content: [
26
83
  { type: "text", text: prompt },
27
- { type: "image_url", image_url: { url: toDataUrl(imagePath) } },
84
+ { type: "image_url", image_url: { url: imagen.url } },
28
85
  ],
29
86
  },
30
87
  ],
@@ -518,6 +518,155 @@ public static class Choco
518
518
  return null;
519
519
  }
520
520
 
521
+ // Lo que el elemento dice SER AHORA MISMO, con el mismo formato que se
522
+ // guardo al hacer la foto. Si un elemento murio, aqui salta y se trata como
523
+ // cambio, que es lo correcto.
524
+ static string EtiquetaViva(AutomationElement e)
525
+ {
526
+ try { return RoleOf(e) + " " + (e.Current.Name == null ? "" : e.Current.Name); }
527
+ catch { return "(elemento muerto)"; }
528
+ }
529
+
530
+ // Un ref solo vale si sigue siendo lo que era.
531
+ //
532
+ // POR QUE
533
+ //
534
+ // ui_snapshot guarda los elementos en _snap y su etiqueta en _snapLabel, y
535
+ // hasta ahora _snapLabel solo servia para INFORMAR: nunca se comparaba. Pero
536
+ // entre mirar y actuar pasa tiempo, y las interfaces cambian solas -una
537
+ // lista que se refresca, un menu que se cierra, un dialogo que aparece-. El
538
+ // elemento guardado en el ref 3 sigue ahi, pero ya no es el boton que el
539
+ // modelo vio: es otro. Y se pulsa ese otro, sin que nada avise.
540
+ //
541
+ // Es la misma familia del fallo que ya mordio dos veces en el navegador
542
+ // (hallazgos #19 y #20): actuar sobre lo que habia antes creyendo que es lo
543
+ // de ahora. Alli se arreglo; aqui seguia abierto.
544
+ //
545
+ // Comparar la etiqueta cuesta una lectura y convierte un clic en el sitio
546
+ // equivocado -que el usuario descubre cuando ya se ha borrado algo- en un
547
+ // mensaje que el modelo puede resolver volviendo a mirar.
548
+ static string RefCambiado(int id)
549
+ {
550
+ AutomationElement e = Ref(id);
551
+ if (e == null) return "ref inexistente; repite ui_snapshot";
552
+
553
+ string antes;
554
+ if (!_snapLabel.TryGetValue(id, out antes)) return null;
555
+
556
+ string ahora = EtiquetaViva(e);
557
+ if (ahora == antes) return null;
558
+
559
+ if (ahora == "(elemento muerto)")
560
+ {
561
+ return "el elemento del ref " + N(id) + " (" + antes + ") ya no existe: la ventana cambio. Repite ui_snapshot.";
562
+ }
563
+ return "el ref " + N(id) + " ya no es lo que viste: era \"" + antes + "\" y ahora es \"" + ahora +
564
+ "\". Actuar sobre el seria pulsar otra cosa. Repite ui_snapshot y usa el numero nuevo.";
565
+ }
566
+
567
+ // ------------------------------------------------------------- esperar cambios
568
+ //
569
+ // Esperar a que la ventana haga algo, en vez de preguntarle cada tanto.
570
+ //
571
+ // POR QUE
572
+ //
573
+ // Hasta ahora, para saber si una accion habia surtido efecto se leia el arbol
574
+ // varias veces seguidas hasta que dos salieran iguales. Funciona, pero tiene
575
+ // dos costes: se lee el arbol entero N veces, y la respuesta llega en el
576
+ // siguiente sondeo, no cuando pasa la cosa. Con una ventana que tarda 40 ms en
577
+ // reaccionar y un sondeo cada 120, se pierden 80 ms de nada; con una que tarda
578
+ // 2 s, se leyo el arbol veinte veces para nada.
579
+ //
580
+ // UI Automation puede avisar. Aqui se suscribe a los cambios de la ventana, se
581
+ // espera bloqueado, y se vuelve en cuanto ocurre el primero.
582
+ //
583
+ // LO QUE HAY QUE HACER BIEN O NO HACERLO
584
+ //
585
+ // Los manejadores de UIA viven dentro del proceso de la aplicacion observada.
586
+ // Si se registran y no se quitan, esa aplicacion se queda mas lenta PARA
587
+ // SIEMPRE, hasta que el usuario la cierre. Por eso todo va dentro de un
588
+ // try/finally y se quitan pase lo que pase, incluido si salta una excepcion o
589
+ // se agota el tiempo.
590
+ //
591
+ // El evento llega en un hilo interno de UIA, no en este: por eso lo que se
592
+ // comparte es un ManualResetEventSlim y una cadena, y nada mas.
593
+ static string _cambioQue = "";
594
+ static readonly object _cambioLock = new object();
595
+
596
+ static void ApuntarCambio(string texto)
597
+ {
598
+ lock (_cambioLock) { if (_cambioQue.Length == 0) _cambioQue = texto; }
599
+ }
600
+
601
+ public static string WaitChange(IntPtr hwnd, int timeoutMs)
602
+ {
603
+ if (hwnd == IntPtr.Zero) return "{\"ok\":false,\"error\":\"ventana no encontrada\"}";
604
+ if (timeoutMs < 100) timeoutMs = 100;
605
+ if (timeoutMs > 30000) timeoutMs = 30000;
606
+
607
+ AutomationElement raiz;
608
+ try { raiz = AutomationElement.FromHandle(hwnd); }
609
+ catch (Exception ex) { return "{\"ok\":false,\"error\":" + Q(ex.Message) + "}"; }
610
+ if (raiz == null) return "{\"ok\":false,\"error\":\"la ventana no expone arbol de accesibilidad\"}";
611
+
612
+ lock (_cambioLock) { _cambioQue = ""; }
613
+ ManualResetEventSlim listo = new ManualResetEventSlim(false);
614
+
615
+ AutomationPropertyChangedEventHandler onProp = delegate(object src, AutomationPropertyChangedEventArgs e)
616
+ {
617
+ string prop = e.Property == ValuePattern.ValueProperty ? "valor"
618
+ : e.Property == AutomationElement.NameProperty ? "nombre"
619
+ : e.Property == AutomationElement.IsEnabledProperty ? "habilitado"
620
+ : "propiedad";
621
+ string quien = "";
622
+ try { AutomationElement el = src as AutomationElement; if (el != null) quien = RoleOf(el) + " " + el.Current.Name; }
623
+ catch { quien = "un control"; }
624
+ ApuntarCambio("cambio el " + prop + " de " + quien);
625
+ listo.Set();
626
+ };
627
+
628
+ StructureChangedEventHandler onStruct = delegate(object src, StructureChangedEventArgs e)
629
+ {
630
+ ApuntarCambio("la ventana anadio o quito controles");
631
+ listo.Set();
632
+ };
633
+
634
+ bool propOk = false, structOk = false;
635
+ try
636
+ {
637
+ AutomationProperty[] props = new AutomationProperty[] {
638
+ ValuePattern.ValueProperty,
639
+ AutomationElement.NameProperty,
640
+ AutomationElement.IsEnabledProperty
641
+ };
642
+ try { Automation.AddAutomationPropertyChangedEventHandler(raiz, TreeScope.Subtree, onProp, props); propOk = true; } catch { }
643
+ try { Automation.AddStructureChangedEventHandler(raiz, TreeScope.Subtree, onStruct); structOk = true; } catch { }
644
+
645
+ if (!propOk && !structOk)
646
+ {
647
+ return "{\"ok\":false,\"error\":\"esta ventana no admite avisos de cambio; usa ui_snapshot para mirar\"}";
648
+ }
649
+
650
+ bool paso = listo.Wait(timeoutMs);
651
+ string que;
652
+ lock (_cambioLock) { que = _cambioQue; }
653
+
654
+ if (!paso)
655
+ {
656
+ return "{\"ok\":true,\"changed\":false,\"waited\":" + N(timeoutMs) + "}";
657
+ }
658
+ return "{\"ok\":true,\"changed\":true,\"what\":" + Q(que.Length > 0 ? que : "algo cambio") + "}";
659
+ }
660
+ finally
661
+ {
662
+ // Sin esto, la aplicacion observada se queda mas lenta hasta que el
663
+ // usuario la cierre. Se quitan siempre, aunque haya saltado todo.
664
+ if (propOk) { try { Automation.RemoveAutomationPropertyChangedEventHandler(raiz, onProp); } catch { } }
665
+ if (structOk) { try { Automation.RemoveStructureChangedEventHandler(raiz, onStruct); } catch { } }
666
+ listo.Dispose();
667
+ }
668
+ }
669
+
521
670
  public static string RefRect(int id)
522
671
  {
523
672
  AutomationElement e = Ref(id);
@@ -537,6 +686,8 @@ public static class Choco
537
686
  {
538
687
  AutomationElement e = Ref(id);
539
688
  if (e == null) return "{\"ok\":false,\"error\":\"ref inexistente; repite ui_snapshot\"}";
689
+ string cambio = RefCambiado(id);
690
+ if (cambio != null) return "{\"ok\":false,\"error\":" + Q(cambio) + "}";
540
691
  string label = _snapLabel.ContainsKey(id) ? _snapLabel[id] : ("ref " + N(id));
541
692
  try
542
693
  {
@@ -555,6 +706,8 @@ public static class Choco
555
706
  {
556
707
  AutomationElement e = Ref(id);
557
708
  if (e == null) return "{\"ok\":false,\"error\":\"ref inexistente; repite ui_snapshot\"}";
709
+ string cambio = RefCambiado(id);
710
+ if (cambio != null) return "{\"ok\":false,\"error\":" + Q(cambio) + "}";
558
711
  try
559
712
  {
560
713
  // ValuePattern.SetValue SUSTITUYE todo el contenido del control. En un
@@ -902,6 +1055,7 @@ while ($true) {
902
1055
  'ui_snapshot' {
903
1056
  $res = [Choco]::Snapshot((Resolve-Hwnd $cmd), [int](Get-Prop $cmd 'max' 250), [string](Get-Prop $cmd 'filter' ''), [bool](Get-Prop $cmd 'interactiveOnly' $true))
904
1057
  }
1058
+ 'wait_change' { $res = [Choco]::WaitChange((Resolve-Hwnd $cmd), [int](Get-Prop $cmd 'timeoutMs' 3000)) }
905
1059
  'ui_click' { $res = [Choco]::RefInvoke([int](Get-Prop $cmd 'ref' 0)) }
906
1060
  'ui_set_value' { $res = [Choco]::RefSetValue([int](Get-Prop $cmd 'ref' 0), [string](Get-Prop $cmd 'text' ''), [bool](Get-Prop $cmd 'replace' $false)) }
907
1061
  'ui_focus' { $res = [Choco]::RefFocus([int](Get-Prop $cmd 'ref' 0)) }
@@ -3,6 +3,14 @@ export declare function tomarTecladoParaPrompt(): void;
3
3
  /** Lo llama el prompt al cerrarse, para que el siguiente turno pueda restaurar. */
4
4
  export declare function devolverTecladoDelPrompt(): void;
5
5
  export declare function releaseKeyboard(): void;
6
+ /**
7
+ * Devuelve el teclado a quien lo tenia antes del dialogo.
8
+ *
9
+ * NO se llama a start(): eso crearia un AbortController nuevo, y la peticion en
10
+ * curso se quedaria escuchando al viejo. Esc dejaria de cancelar justo lo que el
11
+ * usuario quiere cancelar.
12
+ */
13
+ export declare function retomarTeclado(): void;
6
14
  export declare class InterruptWatcher {
7
15
  private controller;
8
16
  private wasRaw;
@@ -13,6 +21,8 @@ export declare class InterruptWatcher {
13
21
  get interrupted(): boolean;
14
22
  get signal(): AbortSignal | undefined;
15
23
  start(): AbortSignal | undefined;
24
+ /** Vuelve a escuchar despues de un dialogo, sin tocar la senal de aborto. */
25
+ reanudar(): void;
16
26
  private abort;
17
27
  stop(): void;
18
28
  }
@@ -3,6 +3,7 @@ import process from "node:process";
3
3
  import chalk from "chalk";
4
4
  import { cycleMode, modeAnnouncement } from "./modes.js";
5
5
  import { pushTyped, backspaceTyped, commitTyped } from "./typeAhead.js";
6
+ import { reafirmarModoCrudo } from "./keyboardGuard.js";
6
7
  import { dibujar } from "./salida.js";
7
8
  /**
8
9
  * Interrupcion con Esc mientras el agente trabaja.
@@ -69,11 +70,39 @@ export function tomarTecladoParaPrompt() {
69
70
  export function devolverTecladoDelPrompt() {
70
71
  promptConElTeclado = false;
71
72
  }
73
+ /**
74
+ * Vigilantes que se soltaron para ensenar un dialogo, y que hay que devolver.
75
+ *
76
+ * El dialogo de permisos suelta el teclado antes de dibujarse -si no, el
77
+ * vigilante se come las teclas y la caja no responde-, pero hasta ahora nadie lo
78
+ * devolvia despues. El resto del turno se quedaba SIN vigilante: en modo cocido
79
+ * el terminal hacia el eco por su cuenta, Esc dejaba de cancelar, y lo que el
80
+ * usuario escribiera no se encolaba.
81
+ *
82
+ * Se veia asi, y el usuario dio con la pista sin saberlo: "a veces se queda
83
+ * trabado y no se puede escribir, pero con darle Enter vuelve a la normalidad".
84
+ * Claro: en modo cocido el terminal guarda la linea y Enter la suelta de golpe.
85
+ */
86
+ let soltados = [];
72
87
  export function releaseKeyboard() {
73
- for (const w of Array.from(activos)) {
88
+ soltados = Array.from(activos);
89
+ for (const w of soltados) {
74
90
  w.stop();
75
91
  }
76
92
  }
93
+ /**
94
+ * Devuelve el teclado a quien lo tenia antes del dialogo.
95
+ *
96
+ * NO se llama a start(): eso crearia un AbortController nuevo, y la peticion en
97
+ * curso se quedaria escuchando al viejo. Esc dejaria de cancelar justo lo que el
98
+ * usuario quiere cancelar.
99
+ */
100
+ export function retomarTeclado() {
101
+ const devolver = soltados;
102
+ soltados = [];
103
+ for (const w of devolver)
104
+ w.reanudar();
105
+ }
77
106
  export class InterruptWatcher {
78
107
  controller = null;
79
108
  wasRaw = false;
@@ -164,6 +193,23 @@ export class InterruptWatcher {
164
193
  activos.add(this);
165
194
  return this.controller.signal;
166
195
  }
196
+ /** Vuelve a escuchar despues de un dialogo, sin tocar la senal de aborto. */
197
+ reanudar() {
198
+ if (this.listening || !this.onKey)
199
+ return;
200
+ if (!process.stdin.isTTY || !process.stdin.setRawMode)
201
+ return;
202
+ readline.emitKeypressEvents(process.stdin);
203
+ if (activos.size === 0)
204
+ modoCrudoOriginal = process.stdin.isRaw || false;
205
+ // Reafirmar y no solo poner: el readline del dialogo deja la consola en modo
206
+ // cocido, y un setRawMode(true) a secas puede ser un no-op.
207
+ reafirmarModoCrudo(process.stdin);
208
+ process.stdin.resume();
209
+ process.stdin.on("keypress", this.onKey);
210
+ this.listening = true;
211
+ activos.add(this);
212
+ }
167
213
  abort() {
168
214
  if (this.aborted)
169
215
  return;
@@ -176,7 +222,9 @@ export class InterruptWatcher {
176
222
  stop() {
177
223
  if (this.onKey) {
178
224
  process.stdin.removeListener("keypress", this.onKey);
179
- this.onKey = null;
225
+ // El manejador NO se tira: reanudar() lo vuelve a enganchar tal cual, con
226
+ // su AbortController intacto. Rehacerlo desde start() romperia el Esc de
227
+ // la peticion que ya esta en vuelo.
180
228
  }
181
229
  const escuchaba = this.listening;
182
230
  this.listening = false;