primocode 7.8.0 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v7.8.0
1
+ # PrimoCode v7.9.0
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v7.8.0
12
+ ▐▛███▜▌ Primo Code v7.9.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/act.js CHANGED
@@ -271,7 +271,16 @@ async function runAct(userPrompt, opts) {
271
271
  }
272
272
  }
273
273
 
274
- if (!finalText) finalText = 'Cheguei ao limite de passos sem concluir. Refaça o pedido em partes menores.';
274
+ // Nunca terminar em silêncio. Quando o modelo devolvia resposta vazia sem
275
+ // chamar tool, o usuário via a tela parar e mais nada — pior que erro, porque
276
+ // não dá nem para saber que acabou. Medido: um pedido de navegador morria assim
277
+ // depois de a primeira tentativa de seletor falhar.
278
+ if (!finalText) {
279
+ finalText = falhasPendentes.length
280
+ ? `Parei sem concluir. A última falha foi: ${falhasPendentes[falhasPendentes.length - 1]}`
281
+ : 'Parei sem concluir e sem explicação do modelo. Tente pedir de novo, em partes menores.';
282
+ if (!subagent) console.log('\n' + c.warn(finalText));
283
+ }
275
284
 
276
285
  // Nada de arquivo mexido = nada para lembrar. E grava em `pastaAtual`, não em
277
286
  // `projectDir`: quando a pasta do projeto nasceu no meio do caminho, a memória
package/lib/browser.js CHANGED
@@ -57,6 +57,71 @@ function findChrome() {
57
57
  for (const p of candidates) {
58
58
  try { if (p && fs.existsSync(p)) return p; } catch {}
59
59
  }
60
+
61
+ // Último recurso: o navegador pode estar num disco montado ou numa pasta de
62
+ // aplicativos do usuário. Caso real medido nesta máquina: o Chrome 151 estava
63
+ // em `/Volumes/Google Chrome/Google Chrome.app` — a JANELA DO INSTALADOR,
64
+ // ainda montada. O usuário jurava (com razão) que tinha Chrome; a lista de
65
+ // caminhos padrão jurava que não. Os dois estavam certos.
66
+ const achado = procurarEmVolumes();
67
+ if (achado) return achado;
68
+
69
+ return null;
70
+ }
71
+
72
+ // Nomes de app e o binário dentro de cada um. Só Chromium: o Safari não fala o
73
+ // protocolo de depuração que permite controlar a página.
74
+ const APPS_MAC = [
75
+ ['Google Chrome.app', 'Google Chrome'],
76
+ ['Google Chrome Beta.app', 'Google Chrome Beta'],
77
+ ['Google Chrome Canary.app', 'Google Chrome Canary'],
78
+ ['Chromium.app', 'Chromium'],
79
+ ['Microsoft Edge.app', 'Microsoft Edge'],
80
+ ['Brave Browser.app', 'Brave Browser'],
81
+ ['Arc.app', 'Arc'],
82
+ ['Vivaldi.app', 'Vivaldi'],
83
+ ['Opera.app', 'Opera'],
84
+ ];
85
+
86
+ /**
87
+ * Procura o navegador em `~/Applications` e nos discos montados em `/Volumes`.
88
+ *
89
+ * Um app rodando de dentro do `.dmg` funciona, mas é frágil: o disco é
90
+ * somente-leitura, o macOS o executa por AppTranslocation (caminho temporário
91
+ * aleatório) e ele desaparece na hora que o disco for ejetado. Achamos e usamos —
92
+ * e `avisoDeLocal()` diz ao usuário para arrastar o app para Aplicativos.
93
+ */
94
+ function procurarEmVolumes() {
95
+ const bases = [];
96
+ try { bases.push(path.join(os.homedir(), 'Applications')); } catch {}
97
+ try {
98
+ for (const vol of fs.readdirSync('/Volumes')) bases.push(path.join('/Volumes', vol));
99
+ } catch {}
100
+
101
+ for (const base of bases) {
102
+ for (const [app, bin] of APPS_MAC) {
103
+ const alvo = path.join(base, app, 'Contents', 'MacOS', bin);
104
+ try { if (fs.existsSync(alvo)) return alvo; } catch {}
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * O navegador achado está num lugar problemático? Devolve o aviso, ou null.
112
+ *
113
+ * Não bloqueia nada: o controle funciona de dentro do disco montado. Só avisa,
114
+ * porque quem ejetar o disco vai ver a ferramenta parar de funcionar sem entender
115
+ * o motivo.
116
+ */
117
+ function avisoDeLocal(caminho) {
118
+ if (!caminho) return null;
119
+ if (caminho.startsWith('/Volumes/')) {
120
+ const app = (caminho.match(/\/Volumes\/[^/]+\/([^/]+\.app)/) || [, 'o navegador'])[1];
121
+ return `${app} está rodando do disco de instalação, não instalado. `
122
+ + 'Funciona, mas para de funcionar quando você ejetar esse disco — '
123
+ + 'arraste o app para a pasta Aplicativos.';
124
+ }
60
125
  return null;
61
126
  }
62
127
 
@@ -104,11 +169,25 @@ class MiniWebSocket {
104
169
  this.onClose = () => {};
105
170
  this.closed = false;
106
171
  }
107
- connect(url) {
172
+ connect(url, timeoutMs = 10000) {
108
173
  return new Promise((resolve, reject) => {
174
+ let pronto = false;
175
+ const relogio = setTimeout(() => {
176
+ if (pronto) return;
177
+ pronto = true;
178
+ try { if (this.socket) this.socket.destroy(); } catch {}
179
+ reject(new Error(`o navegador aceitou a conexão mas não completou o aperto de mão em ${Math.round(timeoutMs / 1000)}s`));
180
+ }, timeoutMs);
181
+ const terminar = (err) => {
182
+ if (pronto) return;
183
+ pronto = true;
184
+ clearTimeout(relogio);
185
+ err ? reject(err) : resolve();
186
+ };
187
+
109
188
  const u = new URL(url);
110
189
  const isSecure = u.protocol === 'wss:';
111
- if (isSecure) return reject(new Error('wss:// não suportado (CDP local usa ws://)'));
190
+ if (isSecure) return terminar(new Error('wss:// não suportado (CDP local usa ws://)'));
112
191
  const key = crypto.randomBytes(16).toString('base64');
113
192
  const port = u.port || 80;
114
193
  const path = u.pathname + (u.search || '');
@@ -130,21 +209,26 @@ class MiniWebSocket {
130
209
  const sep = headerBuf.indexOf('\r\n\r\n');
131
210
  if (sep === -1) return;
132
211
  const header = headerBuf.slice(0, sep).toString();
133
- if (!/HTTP\/1\.1 101/.test(header)) return reject(new Error('handshake WS falhou: ' + header.split('\r\n')[0]));
212
+ if (!/HTTP\/1\.1 101/.test(header)) return terminar(new Error('handshake WS falhou: ' + header.split('\r\n')[0]));
134
213
  handshaked = true;
135
214
  this.socket = sock;
136
215
  const rest = headerBuf.slice(sep + 4);
137
216
  if (rest.length) this._onData(rest);
138
- resolve();
217
+ terminar();
139
218
  } else {
140
219
  this._onData(data);
141
220
  }
142
221
  });
143
222
  sock.on('error', (err) => {
144
- if (!handshaked) reject(err);
223
+ if (!handshaked) terminar(err);
145
224
  this.closed = true; this.onClose(err);
146
225
  });
147
- sock.on('close', () => { this.closed = true; this.onClose(); });
226
+ sock.on('close', () => {
227
+ // Fechar ANTES do 101 também é falha: sem isto a Promise ficava
228
+ // pendente quando o Chrome derrubava a conexão calado.
229
+ if (!handshaked) terminar(new Error('o navegador fechou a conexão antes de aceitar o controle'));
230
+ this.closed = true; this.onClose();
231
+ });
148
232
  });
149
233
  }
150
234
  _onData(chunk) {
@@ -322,14 +406,22 @@ async function findOrCreatePageTarget(url) {
322
406
  return created;
323
407
  }
324
408
 
409
+ // Nenhuma etapa do controle de navegador pode pendurar o CLI sem explicação.
410
+ function comTeto(promessa, ms, oQue) {
411
+ return new Promise((resolve, reject) => {
412
+ const t = setTimeout(() => reject(new Error(`${oQue} não respondeu em ${Math.round(ms / 1000)}s`)), ms);
413
+ promessa.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
414
+ });
415
+ }
416
+
325
417
  async function connect(url) {
326
418
  await ensureChromeRunning();
327
- const target = await findOrCreatePageTarget(url);
419
+ const target = await comTeto(findOrCreatePageTarget(url), 15000, 'a lista de abas do navegador');
328
420
  if (!target || !target.webSocketDebuggerUrl) throw new Error('Não foi possível obter alvo de página do Chrome.');
329
421
  if (currentSession) { try { currentSession.close(); } catch {} }
330
422
  currentSession = new CDPSession();
331
423
  currentSession.targetId = target.id;
332
- await currentSession.attach(target.webSocketDebuggerUrl);
424
+ await comTeto(currentSession.attach(target.webSocketDebuggerUrl), 12000, 'o canal de controle do navegador');
333
425
  await currentSession.send('Page.enable');
334
426
  await currentSession.send('DOM.enable');
335
427
  await currentSession.send('Runtime.enable');
@@ -403,6 +495,74 @@ async function clickText(text) {
403
495
  return { ok: true, ...found };
404
496
  }
405
497
 
498
+ /**
499
+ * Espera o seletor aparecer por um instante antes de desistir.
500
+ *
501
+ * A página quase sempre ainda está montando quando a próxima ferramenta roda.
502
+ * Sem esta espera curta, "não encontrado" era muitas vezes "ainda não nasceu".
503
+ */
504
+ async function esperarSeletor(selector, ms = 4000) {
505
+ const fim = Date.now() + ms;
506
+ while (Date.now() < fim) {
507
+ if (await evaluate(`!!document.querySelector(${JSON.stringify(selector)})`)) return true;
508
+ await sleep(200);
509
+ }
510
+ return false;
511
+ }
512
+
513
+ /**
514
+ * O que EXISTE na página, para o erro ensinar em vez de só reclamar.
515
+ *
516
+ * Mesma ideia do `trechosParecidos` do edit_file: erro seco faz o modelo chutar de
517
+ * novo. Devolvendo o que está lá, ele acerta na segunda tentativa.
518
+ *
519
+ * Caso real: o modelo pediu `input[name="q"]` no Google. A caixa de busca do
520
+ * Google é `textarea[name="q"]` — o chute era razoável e estava errado, e o erro
521
+ * antigo não dava nenhuma pista de qual era o certo.
522
+ */
523
+ async function candidatosNaPagina() {
524
+ const js = `(function(){
525
+ // Ordem importa: quem errou um seletor quase sempre queria um CAMPO, e a
526
+ // lista é cortada em 12. Link de navegação por último, senão o campo de
527
+ // busca fica de fora justamente no caso em que ele é a resposta.
528
+ const porPrioridade = [
529
+ 'input:not([type=hidden]),textarea,[contenteditable],[role=textbox]',
530
+ 'button,[role=button],select,input[type=submit]',
531
+ 'a[href]',
532
+ ];
533
+ const alvos = [];
534
+ for (const grupo of porPrioridade) {
535
+ for (const e of document.querySelectorAll(grupo)) if (!alvos.includes(e)) alvos.push(e);
536
+ }
537
+ const vistos = [];
538
+ for (const e of alvos) {
539
+ const r = e.getBoundingClientRect();
540
+ if (r.width < 2 || r.height < 2) continue; // invisível não serve
541
+ const tag = e.tagName.toLowerCase();
542
+ let sel = tag;
543
+ if (e.id) sel = tag + '#' + e.id;
544
+ else if (e.name) sel = tag + '[name="' + e.name + '"]';
545
+ else if (e.getAttribute('aria-label')) sel = tag + '[aria-label="' + e.getAttribute('aria-label') + '"]';
546
+ else if (e.type) sel = tag + '[type="' + e.type + '"]';
547
+ const rotulo = (e.getAttribute('aria-label') || e.placeholder || (e.innerText||'').trim().slice(0,30) || '').replace(/\\s+/g,' ');
548
+ vistos.push(rotulo ? sel + ' (' + rotulo + ')' : sel);
549
+ if (vistos.length >= 12) break;
550
+ }
551
+ return JSON.stringify(vistos);
552
+ })()`;
553
+ try { return JSON.parse(await evaluate(js)) || []; } catch { return []; }
554
+ }
555
+
556
+ async function erroDeSeletor(selector) {
557
+ const candidatos = await candidatosNaPagina();
558
+ const err = new Error(
559
+ `seletor não encontrado: ${selector}`
560
+ + (candidatos.length ? `. Existem nesta página: ${candidatos.join(' · ')}` : '. A página não tem nenhum elemento clicável visível — ela pode não ter carregado.')
561
+ );
562
+ err.candidatos = candidatos;
563
+ return err;
564
+ }
565
+
406
566
  async function clickSelector(selector) {
407
567
  await ensure();
408
568
  const js = `(function(){
@@ -412,8 +572,9 @@ async function clickSelector(selector) {
412
572
  const r = el.getBoundingClientRect();
413
573
  return { x: r.left + r.width/2, y: r.top + r.height/2, tag: el.tagName };
414
574
  })()`;
415
- const found = await evaluate(js);
416
- if (!found) throw new Error(`seletor não encontrado: ${selector}`);
575
+ let found = await evaluate(js);
576
+ if (!found && await esperarSeletor(selector)) found = await evaluate(js);
577
+ if (!found) throw await erroDeSeletor(selector);
417
578
  await sleep(200);
418
579
  await click(found.x, found.y);
419
580
  return { ok: true, selector, ...found };
@@ -430,14 +591,16 @@ async function type(text) {
430
591
 
431
592
  async function typeInSelector(selector, text, clear = true) {
432
593
  await ensure();
433
- const focused = await evaluate(`(function(){
594
+ const focar = () => evaluate(`(function(){
434
595
  const el = document.querySelector(${JSON.stringify(selector)});
435
596
  if (!el) return false;
436
597
  el.focus();
437
598
  ${clear ? "if ('value' in el) el.value = '';" : ''}
438
599
  return true;
439
600
  })()`);
440
- if (!focused) throw new Error(`seletor não encontrado: ${selector}`);
601
+ let focused = await focar();
602
+ if (!focused && await esperarSeletor(selector)) focused = await focar();
603
+ if (!focused) throw await erroDeSeletor(selector);
441
604
  await type(text);
442
605
  // dispara evento input para frameworks reativos
443
606
  await evaluate(`(function(){
@@ -508,5 +671,6 @@ function close() {
508
671
  module.exports = {
509
672
  open, navigate, click, clickText, clickSelector, type, typeInSelector,
510
673
  key, screenshot, evaluate, currentUrl, waitFor, wait, scroll, close,
511
- _internal: { findChrome, PROFILE_DIR, DEBUG_PORT },
674
+ avisoDeLocal,
675
+ _internal: { findChrome, procurarEmVolumes, PROFILE_DIR, DEBUG_PORT },
512
676
  };
package/lib/tools.js CHANGED
@@ -317,11 +317,41 @@ function writeTodos({ todos }) {
317
317
  }
318
318
  function getTodos() { return TODOS; }
319
319
 
320
+ /**
321
+ * Executa uma ferramenta e NUNCA lança.
322
+ *
323
+ * O `await` aqui não é enfeite. O despachante abaixo faz `return browser.x(...)`
324
+ * em vinte casos, e `try { return promessa } catch` **não** pega a rejeição dessa
325
+ * promessa — só `return await` pega. Sem isto, qualquer falha de navegador ou
326
+ * desktop subia por cima do loop do agente e derrubava o CLI inteiro com exit 1,
327
+ * em vez de virar uma ação falhada que o modelo pode corrigir.
328
+ *
329
+ * Foi exatamente o que acontecia ao pedir uma pesquisa no Google: o seletor não
330
+ * batia, a promessa rejeitava, e o programa morria calado no meio da tarefa.
331
+ */
320
332
  async function execute(name, args, ctx) {
333
+ try {
334
+ return await despachar(name, args, ctx);
335
+ } catch (e) {
336
+ // Erro que sabe o que EXISTE leva a informação junto — mesma ideia do
337
+ // `trechosParecidos` do edit_file. Erro seco faz o modelo chutar de novo.
338
+ if (e && Array.isArray(e.candidatos) && e.candidatos.length) {
339
+ return {
340
+ ok: false,
341
+ error: e.message,
342
+ seletoresDisponiveis: e.candidatos,
343
+ comoCorrigir: 'Chame a tool de novo com um seletor de "seletoresDisponiveis", copiado LITERALMENTE. O primeiro da lista é o mais provável.',
344
+ };
345
+ }
346
+ return { ok: false, error: e.message || String(e) };
347
+ }
348
+ }
349
+
350
+ async function despachar(name, args, ctx) {
321
351
  const projectDir = (ctx && ctx.projectDir) || process.cwd();
322
352
  args = args || {};
323
353
 
324
- try {
354
+ {
325
355
  switch (name) {
326
356
  case 'write_file': {
327
357
  const filePath = resolveInProject(args.path, projectDir);
@@ -410,8 +440,6 @@ async function execute(name, args, ctx) {
410
440
  default:
411
441
  return { ok: false, error: `Tool não implementada: ${name}` };
412
442
  }
413
- } catch (e) {
414
- return { ok: false, error: e.message };
415
443
  }
416
444
  }
417
445
 
package/lib/ui.js CHANGED
@@ -163,7 +163,7 @@ const TIPS = [
163
163
  const NEWS = [
164
164
  'Modelos grátis, com reserva: um no limite não vira erro na sua tela',
165
165
  'Ele trabalha em ~/primocode. Fale o nome de uma pasta e ele muda sozinho',
166
- '/tudo lista as 30 ferramentas que ele sabe usar',
166
+ 'Falha de navegador agora diz quais seletores EXISTEM na página',
167
167
  ];
168
168
 
169
169
  // ── Cabeçalho ────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "7.8.0",
3
+ "version": "7.9.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {