primocode 8.37.0 → 8.38.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/lib/area.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * area.js — a área de transferência e o foco do terminal, sem dependência.
3
+ *
4
+ * "No login quero que ele copie o código pra área de transferência
5
+ * automaticamente. [...] Ele libera, você volta ao terminal
6
+ * automaticamente."
7
+ *
8
+ * ── POR QUE FALAR COM O SISTEMA, E NÃO COM UM PACOTE ────────────────────
9
+ * O CLI tem zero dependências e isto não é motivo para abrir a primeira: todo
10
+ * sistema já tem um programa que escreve na área de transferência (pbcopy,
11
+ * clip, wl-copy, xclip, xsel). O que este módulo faz é achar qual existe e
12
+ * falar com ele — e FALHAR EM SILÊNCIO quando nenhum existe, porque copiar é
13
+ * conveniência: o código continua na tela e a página continua aceitando
14
+ * digitação. Conveniência que derruba o login não é conveniência.
15
+ *
16
+ * ── O FOCO SÓ VOLTA NO MAC, E É HONESTO DIZER ────────────────────────────
17
+ * Trazer o terminal para a frente depois de a página aprovar é o que fecha o
18
+ * laço "clicou → voltou". No macOS dá: o `TERM_PROGRAM` diz qual app é o
19
+ * terminal e o `osascript` o ativa. No Windows e no Linux não há um jeito
20
+ * geral que funcione sem depender do gerenciador de janelas — então lá o CLI
21
+ * apita e imprime o próximo passo, e a janela do navegador diz "volte ao
22
+ * terminal". Prometer o foco onde ele não vem seria pior que não prometer.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const os = require('os');
28
+ const { execFile, execFileSync } = require('child_process');
29
+
30
+ /* Cada candidato é [programa, argumentos]. A ordem dentro de cada sistema é
31
+ a do que existe com mais frequência: no Linux o Wayland vem primeiro
32
+ porque, quando ele é a sessão, o xclip existe e NÃO funciona. */
33
+ function candidatos() {
34
+ const p = os.platform();
35
+ if (p === 'darwin') return [['pbcopy', []]];
36
+ if (p === 'win32') return [['clip', []]];
37
+ const lista = [];
38
+ if (process.env.WAYLAND_DISPLAY) lista.push(['wl-copy', []]);
39
+ lista.push(['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]);
40
+ if (!process.env.WAYLAND_DISPLAY) lista.push(['wl-copy', []]);
41
+ return lista;
42
+ }
43
+
44
+ /**
45
+ * Copia o texto para a área de transferência.
46
+ * @returns {Promise<{ok:boolean, como?:string}>} nunca rejeita
47
+ */
48
+ function copiar(texto) {
49
+ const fila = candidatos();
50
+ return new Promise((resolve) => {
51
+ const tentar = (i) => {
52
+ if (i >= fila.length) return resolve({ ok: false });
53
+ const [cmd, args] = fila[i];
54
+ let filho;
55
+ try {
56
+ filho = execFile(cmd, args, { timeout: 3000, windowsHide: true }, (erro) => {
57
+ if (erro) return tentar(i + 1);
58
+ resolve({ ok: true, como: cmd });
59
+ });
60
+ } catch { return tentar(i + 1); }
61
+ filho.on('error', () => tentar(i + 1));
62
+ try { filho.stdin.on('error', () => {}); filho.stdin.end(String(texto)); }
63
+ catch { tentar(i + 1); }
64
+ };
65
+ tentar(0);
66
+ });
67
+ }
68
+
69
+ /* O app do terminal, pelo que ele mesmo declara. Os cinco da lista são os que
70
+ têm nome de aplicativo diferente do valor da variável — os outros
71
+ (Warp, Alacritty, kitty, Ghostty) já se chamam pelo próprio nome. */
72
+ const APPS_MAC = {
73
+ Apple_Terminal: 'Terminal',
74
+ 'iTerm.app': 'iTerm',
75
+ vscode: 'Visual Studio Code',
76
+ Hyper: 'Hyper',
77
+ WezTerm: 'WezTerm',
78
+ };
79
+
80
+ /**
81
+ * Traz o terminal de volta para a frente. Só faz algo no macOS; nos outros
82
+ * sistemas devolve {ok:false, motivo} sem tentar nada.
83
+ */
84
+ function voltarAoTerminal() {
85
+ if (os.platform() !== 'darwin') return { ok: false, motivo: 'só no macOS' };
86
+ const programa = process.env.TERM_PROGRAM || '';
87
+ const app = APPS_MAC[programa] || programa.replace(/\.app$/, '');
88
+ if (!app) return { ok: false, motivo: 'terminal não se identificou' };
89
+ try {
90
+ // O nome vai como ARGUMENTO do AppleScript, nunca dentro do texto do
91
+ // script: TERM_PROGRAM é ambiente, e ambiente é o que qualquer coisa
92
+ // escreve.
93
+ execFileSync('osascript', ['-e', 'on run argv', '-e', 'tell application (item 1 of argv) to activate',
94
+ '-e', 'end run', app], { timeout: 3000, stdio: 'ignore' });
95
+ return { ok: true, app };
96
+ } catch { return { ok: false, motivo: 'osascript recusou' }; }
97
+ }
98
+
99
+ /** O sino do terminal: chama a atenção de quem está olhando o navegador. */
100
+ function apitar() {
101
+ try { if (process.stdout.isTTY) process.stdout.write('\x07'); } catch { /* sem tty */ }
102
+ }
103
+
104
+ module.exports = { copiar, voltarAoTerminal, apitar, candidatos, APPS_MAC };
package/lib/browser.js CHANGED
@@ -770,8 +770,23 @@ async function subirRevisor() {
770
770
  // pessoa. Não há variável para ligar a janela aqui de propósito.
771
771
  '--headless=new', '--disable-gpu', '--disable-dev-shm-usage',
772
772
  '--hide-scrollbars', '--mute-audio',
773
+ // A exportação escondida TOCA o vídeo para gravá-lo, e o player dá
774
+ // play sem ninguém clicar. Sem isto o navegador recusa o play e o
775
+ // arquivo sai com o primeiro quadro congelado do começo ao fim.
776
+ // `--mute-audio` não atrapalha: ele cala o alto-falante, e o que a
777
+ // gravação lê é o grafo de áudio, antes dele.
778
+ '--autoplay-policy=no-user-gesture-required',
779
+ // Aba que o Chrome considera "de fundo" para de DECODIFICAR o vídeo
780
+ // depois de um segundo e segue só com o áudio: o readyState do <video>
781
+ // cai para 1 e o export desenha o chão da cena no lugar do clipe da
782
+ // pessoa — medido no diagnóstico, quadro a quadro. Estas quatro
783
+ // desligam as economias de aba de fundo; a aba ainda é trazida à
784
+ // frente (Page.bringToFront) ao abrir.
785
+ '--disable-features=Translate,BackgroundVideoPauseOptimization,MediaSuspend',
786
+ '--disable-background-media-suspend',
787
+ '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows',
788
+ '--disable-background-timer-throttling',
773
789
  '--no-first-run', '--no-default-browser-check',
774
- '--disable-features=Translate',
775
790
  'about:blank',
776
791
  ];
777
792
  if (process.platform === 'linux' && process.getuid && process.getuid() === 0) {
@@ -914,6 +929,99 @@ async function inspecionarEscondido(url, op = {}) {
914
929
  }
915
930
  }
916
931
 
932
+ /* Uma aba escondida, com o canal de controle aberto e os erros dela sendo
933
+ anotados. É a peça comum do `inspecionarEscondido`, do `rodarEscondido` e
934
+ do `abrirEscondido`: abrir, navegar, esperar o load, assentar. Quem chama
935
+ recebe a sessão e a promessa de fechar tudo no fim. */
936
+ async function abaEscondida(url, op = {}) {
937
+ await subirRevisor();
938
+ const aba = await comTeto(
939
+ httpPutJson(`http://127.0.0.1:${PORTA_REVISOR}/json/new?${encodeURIComponent('about:blank')}`, 5000),
940
+ 8000, 'a aba do revisor');
941
+ if (!aba || !aba.webSocketDebuggerUrl) throw new Error('o navegador escondido não abriu uma aba');
942
+ const s = new CDPSession();
943
+ const erros = [];
944
+ s.onEvent = (metodo, p) => {
945
+ const linha = _falaDoErro(metodo, p);
946
+ if (linha && erros.length < 25 && !erros.some((e) => e.texto === linha.texto)) erros.push(linha);
947
+ };
948
+ const fechar = async () => {
949
+ try { s.close(); } catch {}
950
+ try { await httpGetJson(`http://127.0.0.1:${PORTA_REVISOR}/json/close/${aba.id}`, 2000); } catch {}
951
+ };
952
+ try {
953
+ await comTeto(s.attach(aba.webSocketDebuggerUrl), 12000, 'o canal do revisor');
954
+ await s.send('Page.enable');
955
+ await s.send('Runtime.enable');
956
+ await s.send('Log.enable');
957
+ // A aba nova nasce ATRÁS do about:blank inicial, e aba de fundo é
958
+ // onde o Chrome economiza: para o vídeo, estrangula os timers. Trazer
959
+ // para a frente é o que faz a página se comportar como na tela da pessoa.
960
+ await s.send('Page.bringToFront').catch(() => {});
961
+ await s.send('Page.navigate', { url });
962
+ const prazo = Date.now() + (op.prazoLoad || 20000);
963
+ while (Date.now() < prazo) {
964
+ const r = await s.send('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true }).catch(() => null);
965
+ if (r && r.result && r.result.value === 'complete') break;
966
+ await sleep(200);
967
+ }
968
+ await sleep(op.assentar == null ? 800 : op.assentar);
969
+ } catch (e) {
970
+ await fechar();
971
+ throw e;
972
+ }
973
+ return { sessao: s, erros, fechar };
974
+ }
975
+
976
+ /**
977
+ * Roda um script numa página escondida e espera o que ele deixar em
978
+ * `window.__primo`.
979
+ *
980
+ * É assim que o estúdio exporta o vídeo sem ninguém clicar: a página do
981
+ * player faz o trabalho que faria com o botão, e o resultado volta por aqui.
982
+ * O script recebe `userGesture` quando `op.gesto` é true — é o que destrava o
983
+ * AudioContext, que sem um gesto nasce suspenso e grava silêncio.
984
+ *
985
+ * @param {string} url
986
+ * @param {object} op { script, prazo, passo, gesto, assentar }
987
+ * @returns {Promise<{valor:any, erros:Array, expirou?:boolean}>}
988
+ */
989
+ async function rodarEscondido(url, op = {}) {
990
+ const aba = await abaEscondida(url, op);
991
+ try {
992
+ await aba.sessao.send('Runtime.evaluate', {
993
+ expression: `window.__primo = undefined; ${op.script || ''}`,
994
+ awaitPromise: false, returnByValue: false, userGesture: !!op.gesto,
995
+ });
996
+ const prazo = Date.now() + (op.prazo || 60000);
997
+ while (Date.now() < prazo) {
998
+ const r = await aba.sessao.send('Runtime.evaluate', {
999
+ expression: 'window.__primo === undefined ? null : JSON.stringify(window.__primo)',
1000
+ returnByValue: true,
1001
+ }).catch(() => null);
1002
+ const v = r && r.result && r.result.value;
1003
+ if (v) return { valor: JSON.parse(v), erros: aba.erros };
1004
+ await sleep(op.passo || 400);
1005
+ }
1006
+ return { valor: null, erros: aba.erros, expirou: true };
1007
+ } finally {
1008
+ await aba.fechar();
1009
+ }
1010
+ }
1011
+
1012
+ /**
1013
+ * Abre uma página escondida e a deixa aberta até quem chamou fechar.
1014
+ *
1015
+ * É o `abrir` da análise de áudio: a página faz a conta e MANDA o resultado
1016
+ * ao estúdio sozinha, então aqui não há o que esperar — só manter a aba viva
1017
+ * enquanto o CLI pergunta ao estúdio se já ficou pronto. Antes isso abria o
1018
+ * navegador da PESSOA, que via uma aba piscar para uma conta que não era dela.
1019
+ */
1020
+ async function abrirEscondido(url) {
1021
+ const aba = await abaEscondida(url, { assentar: 0 });
1022
+ return { fechar: aba.fechar, erros: aba.erros };
1023
+ }
1024
+
917
1025
  /* O revisor é ferramenta de uma tarefa, não um processo que fica. Deixá-lo de
918
1026
  pé come memória até a pessoa desligar o computador, e ele não é o navegador
919
1027
  dela — ninguém sentiria falta nem saberia que existe para fechar. */
@@ -937,5 +1045,5 @@ function fecharRevisor() {
937
1045
  module.exports = {
938
1046
  open, click, clickText, clickSelector, type, typeInSelector,
939
1047
  key, screenshot, evaluate, waitFor, wait, scroll, close,
940
- inspecionarEscondido, fecharRevisor,
1048
+ inspecionarEscondido, rodarEscondido, abrirEscondido, fecharRevisor,
941
1049
  };
package/lib/catalogo.js CHANGED
@@ -99,6 +99,11 @@ const GRUPOS = [
99
99
  + 'repetida (opcional, padrão true): false desliga o corte de fala repetida. '
100
100
  + 'Devolve "trechos": vire cada um numa cena com {"tipo":"clipe","de":...,"ate":...}. '
101
101
  + 'Devolve também "repetidos": o que foi tirado por ter sido dito duas vezes — CONTE isso ao usuário, com os tempos, porque é fala dele que sumiu do vídeo.' },
102
+ // Escreve no projeto (o .mp4 pronto), por isso `escreve: true`: é o
103
+ // que faz a pasta do projeto nascer quando ainda não existe.
104
+ { tool: 'studio_exportar', escreve: true, faz: 'gera o arquivo .mp4 de um vídeo do estúdio, sem ninguém clicar, e o deixa na pasta do projeto',
105
+ args: 'id: o vídeo (corte). destino (opcional): caminho do arquivo de saída — sem ele, <titulo>.mp4 na pasta do projeto. fps (opcional, padrão 30). '
106
+ + 'Leva o tempo do vídeo (um vídeo de 40s leva ~40s). Use quando o usuário pedir o ARQUIVO; para só mostrar, o link "ver" basta.' },
102
107
  ],
103
108
  },
104
109
  {
@@ -145,8 +150,8 @@ const GRUPOS = [
145
150
  { tool: 'remember_general', escreve: false, faz: 'grava algo sobre o usuário que vale para todo projeto (memory-geral.md)',
146
151
  args: 'fato: o que lembrar, em uma frase.' },
147
152
  // Estas duas o loop do agente atende (lib/act.js), não o tools.execute.
148
- { tool: 'spawn_agent', escreve: false, faz: 'chama um subagente para uma parte da tarefa', noLoop: true,
149
- args: 'description: 3 a 5 palavras, aparece na tela do usuário. prompt: instrução completa e autossuficiente — o subagente não vê esta conversa.' },
153
+ { tool: 'spawn_agent', escreve: false, faz: 'chama um subagente especialista para uma parte da tarefa', noLoop: true,
154
+ args: 'description: 3 a 5 palavras, aparece na tela do usuário. prompt: instrução completa e autossuficiente — o subagente não vê esta conversa. especialista (opcional): frontend, backend, testes, design, dados, revisor ou ambiente — cada um chega com o briefing da profissão (lib/especialistas.js); sem ele, a tarefa sugere.' },
150
155
  { tool: 'construir_app', escreve: true, noLoop: true,
151
156
  faz: 'constrói um app ou site inteiro com quatro papéis: engenheiro de prompt → protótipo → MVP → revisor que testa escondido, em laço até passar',
152
157
  args: 'pedido: o que o usuário quer, com as palavras dele. pasta (opcional): onde construir — sem ela, uma pasta nova com o nome do pedido. rodadas (opcional, 1 a 4, padrão 2): quantas voltas de correção. '
package/lib/conta.js CHANGED
@@ -29,6 +29,7 @@ const os = require('os');
29
29
  const path = require('path');
30
30
  const https = require('https');
31
31
  const http = require('http');
32
+ const area = require('./area.js');
32
33
 
33
34
  // O servidor do Conecta Primo — que NÃO é o servidor do PrimoCode. São dois
34
35
  // serviços diferentes: um responde o chat do app e a conta, o outro é o motor
@@ -105,12 +106,87 @@ function atual() {
105
106
 
106
107
  // ── entrar ───────────────────────────────────────────────────────────────
107
108
 
109
+ /* ── O CÓDIGO CHEGA À PÁGINA SEM NINGUÉM DIGITAR ─────────────────────────
110
+ *
111
+ * "No login quero que ele copie o código pra área de transferência
112
+ * automaticamente. A tela de login deve ter só o negócio pra verificar o
113
+ * código e ele já cola automaticamente o código."
114
+ *
115
+ * A regra da casa continua a mesma: O CÓDIGO NÃO VAI NO LINK. Com ele na URL,
116
+ * um link mandado por outra pessoa conectaria o computador DELA à conta de
117
+ * quem clicou. O que muda é COMO ele chega à página:
118
+ *
119
+ * 1. Enquanto espera a aprovação, o CLI serve `GET /pareamento` em
120
+ * 127.0.0.1, e a página pergunta ali. Só uma página aberta NESTA máquina
121
+ * alcança 127.0.0.1 desta máquina — o que é mais seguro que digitar, não
122
+ * menos: o link do atacante abriria a página na máquina da vítima, cujo
123
+ * CLI (se estiver rodando) tem o código DA VÍTIMA, nunca o dele.
124
+ * 2. O código também vai para a área de transferência: no Safari, que
125
+ * bloqueia http://127.0.0.1 a partir de página https, a página oferece
126
+ * "Colar" — um toque, sem ler oito letras.
127
+ * 3. Se nada disso servir, a pessoa digita, como antes.
128
+ *
129
+ * A resposta do servidor local só sai com `Access-Control-Allow-Origin` para a
130
+ * ORIGEM DO SITE (a que veio no `pedido.url`). Sem isso qualquer página aberta
131
+ * no navegador poderia ler 127.0.0.1 e roubar o código para aprová-lo na
132
+ * própria conta. A porta vai na URL (`?porta=`), e a porta não é segredo: ela
133
+ * só diz ONDE perguntar, e quem não está nesta máquina não alcança.
134
+ */
135
+ const PORTA_PAREAMENTO = Number(process.env.PRIMOCODE_PORTA_PAREAMENTO) || 7799;
136
+
137
+ function servirPareamento(codigo, origemDoSite) {
138
+ const permitidas = new Set([origemDoSite, 'http://127.0.0.1', 'http://localhost']
139
+ .filter(Boolean));
140
+ const permitida = (origem) => {
141
+ if (!origem) return false;
142
+ if (permitidas.has(origem)) return true;
143
+ // Os testes e a prévia local rodam em portas variadas de 127.0.0.1.
144
+ return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origem);
145
+ };
146
+
147
+ const servidor = http.createServer((req, res) => {
148
+ const origem = req.headers.origin;
149
+ const cab = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
150
+ if (permitida(origem)) {
151
+ cab['Access-Control-Allow-Origin'] = origem;
152
+ cab.Vary = 'Origin';
153
+ cab['Access-Control-Allow-Methods'] = 'GET, OPTIONS';
154
+ cab['Access-Control-Allow-Private-Network'] = 'true';
155
+ }
156
+ if (req.method === 'OPTIONS') { res.writeHead(204, cab); return res.end(); }
157
+ const rota = (req.url || '/').split('?')[0];
158
+ if (rota !== '/pareamento' || req.method !== 'GET') {
159
+ res.writeHead(404, cab); return res.end('{"ok":false}');
160
+ }
161
+ // Sem origem permitida, a resposta sai SEM o cabeçalho CORS: o navegador
162
+ // recebe e descarta antes de a página ler. O corpo continua igual de
163
+ // propósito — o que protege é o cabeçalho, não esconder o corpo, e
164
+ // dois corpos diferentes seriam dois caminhos para manter.
165
+ res.writeHead(200, cab);
166
+ res.end(JSON.stringify({ ok: true, codigo, origem: `PrimoCode ${versao()}` }));
167
+ });
168
+
169
+ return new Promise((ok) => {
170
+ let porta = PORTA_PAREAMENTO;
171
+ const tentar = () => {
172
+ servidor.once('error', (e) => {
173
+ if (e.code === 'EADDRINUSE' && porta < PORTA_PAREAMENTO + 10) { porta++; tentar(); }
174
+ else ok({ porta: null, fechar: () => {} }); // sem servidor local: digita ou cola
175
+ });
176
+ servidor.listen(porta, '127.0.0.1', () => {
177
+ ok({ porta, fechar: () => { try { servidor.close(); } catch { /* já fechou */ } } });
178
+ });
179
+ };
180
+ tentar();
181
+ });
182
+ }
183
+
108
184
  /**
109
185
  * O login inteiro: pede o código, abre o navegador, espera a aprovação.
110
186
  *
111
187
  * @param {object} op
112
188
  * @param {(url:string)=>any} op.abrir abre o navegador (vem do tools.js)
113
- * @param {(msg:string)=>void} [op.contar] para a tela ir dizendo o que faz
189
+ * @param {(info:object)=>void} [op.contar] recebe {url, codigo, copiado, porta} uma vez, antes de esperar
114
190
  * @param {number} [op.minutos] teto da espera
115
191
  */
116
192
  async function entrar({ abrir, contar = () => {}, minutos = 5 } = {}) {
@@ -124,35 +200,52 @@ async function entrar({ abrir, contar = () => {}, minutos = 5 } = {}) {
124
200
  return { ok: false, error: (pedido && pedido.error) || 'o servidor recusou abrir o login' };
125
201
  }
126
202
 
127
- contar(pedido.url);
128
- contar(pedido.codigo);
129
- if (abrir) { try { await abrir(pedido.url); } catch { /* segue: o link está na tela */ } }
130
-
131
- /* A espera. Dois segundos entre perguntas: mais rápido é marteladas no
132
- servidor por uma pessoa que ainda está digitando a senha; mais devagar e
133
- o terminal parece travado depois de a página já ter confirmado. */
134
- const prazo = Date.now() + minutos * 60000;
135
- while (Date.now() < prazo) {
136
- await new Promise((ok) => setTimeout(ok, 2000));
137
- let r;
138
- try {
139
- r = await pedir('/api/dispositivo/consultar',
140
- { codigo: pedido.codigo, segredo: pedido.segredo }, 10000);
141
- } catch { continue; } // rede tropeçou: pergunta de novo
142
- if (!r || !r.success) continue;
143
- if (r.estado === 'expirado') {
144
- return { ok: false, error: 'o código venceu antes de você aprovar. Tente de novo.' };
145
- }
146
- if (r.estado !== 'aprovado') continue;
203
+ let origemDoSite = null;
204
+ try { origemDoSite = new URL(pedido.url).origin; } catch { /* url estranha: sem CORS para ela */ }
205
+ const local = await servirPareamento(pedido.codigo, origemDoSite);
206
+ const copiado = await area.copiar(pedido.codigo);
147
207
 
148
- gravar({
149
- token: r.token, uid: r.uid, plano: r.plano || 'free',
150
- desde: new Date().toISOString(), servidor: CONECTA,
151
- });
152
- return { ok: true, uid: r.uid, plano: r.plano,
153
- pro: PLANOS_PRO.includes(r.plano) };
208
+ let url = pedido.url;
209
+ if (local.porta) url += (url.includes('?') ? '&' : '?') + 'porta=' + local.porta;
210
+
211
+ contar({ url, codigo: pedido.codigo, copiado: copiado.ok, porta: local.porta });
212
+ if (abrir) { try { await abrir(url); } catch { /* segue: o link está na tela */ } }
213
+
214
+ try {
215
+ /* A espera. Dois segundos entre perguntas: mais rápido é marteladas no
216
+ servidor por uma pessoa que ainda está digitando a senha; mais devagar e
217
+ o terminal parece travado depois de a página já ter confirmado. */
218
+ const prazo = Date.now() + minutos * 60000;
219
+ while (Date.now() < prazo) {
220
+ await new Promise((ok) => setTimeout(ok, 2000));
221
+ let r;
222
+ try {
223
+ r = await pedir('/api/dispositivo/consultar',
224
+ { codigo: pedido.codigo, segredo: pedido.segredo }, 10000);
225
+ } catch { continue; } // rede tropeçou: pergunta de novo
226
+ if (!r || !r.success) continue;
227
+ if (r.estado === 'expirado') {
228
+ return { ok: false, error: 'o código venceu antes de você aprovar. Tente de novo.' };
229
+ }
230
+ if (r.estado !== 'aprovado') continue;
231
+
232
+ gravar({
233
+ token: r.token, uid: r.uid, plano: r.plano || 'free',
234
+ desde: new Date().toISOString(), servidor: CONECTA,
235
+ });
236
+ /* Aprovou lá: a atenção da pessoa está no navegador. O sino e o foco
237
+ trazem-na de volta — o foco só no macOS, e o módulo diz por quê. */
238
+ area.apitar();
239
+ const volta = area.voltarAoTerminal();
240
+ return { ok: true, uid: r.uid, plano: r.plano,
241
+ pro: PLANOS_PRO.includes(r.plano), voltou: !!volta.ok };
242
+ }
243
+ return { ok: false, error: `passaram ${minutos} minutos sem aprovação. Rode /connect de novo.` };
244
+ } finally {
245
+ // O servidor local só existe durante a espera. Deixá-lo de pé seria
246
+ // uma porta respondendo um código morto para sempre.
247
+ local.fechar();
154
248
  }
155
- return { ok: false, error: `passaram ${minutos} minutos sem aprovação. Rode /entrar de novo.` };
156
249
  }
157
250
 
158
251
  /**
@@ -194,4 +287,4 @@ function versao() {
194
287
  try { return require('../package.json').version; } catch { return '?'; }
195
288
  }
196
289
 
197
- module.exports = { entrar, sair, atual, sessao, dispositivos, ARQUIVO, CONECTA, PLANOS_PRO };
290
+ module.exports = { entrar, sair, atual, sessao, dispositivos, servirPareamento, ARQUIVO, CONECTA, PLANOS_PRO, PORTA_PAREAMENTO };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * especialistas.js — quem o agente chama quando delega.
3
+ *
4
+ * "Para programar a IA deve ter skills, agentes e sub-agents. Cada um
5
+ * especialista em algo. Deve ser coisas surreal, entregar códigos a nível
6
+ * claude."
7
+ *
8
+ * ── POR QUE UM BRIEFING, E NÃO UM PROMPT INTEIRO ────────────────────────
9
+ * O subagente já tem o prompt de sistema do servidor e as ferramentas. O que
10
+ * ele NÃO tem é ofício: "crie a folha de estilo" chega a um generalista, e
11
+ * generalista escreve o CSS que qualquer um escreveria. O briefing é o que um
12
+ * sênior daquela área diria a um colega antes de ele começar — o que olhar
13
+ * primeiro, o que nunca fazer, como PROVAR que acabou. Ele vai na frente da
14
+ * tarefa, na MESMA mensagem: o servidor não precisa conhecer os especialistas,
15
+ * e o /claude e o /codex os recebem igual.
16
+ *
17
+ * ── CURTO DE PROPÓSITO ──────────────────────────────────────────────────
18
+ * Cada briefing cabe em ~800 caracteres. O subagente vive no mesmo teto de
19
+ * tokens por minuto do agente principal, e cada linha aqui é uma linha a
20
+ * menos de arquivo lido. O que está aqui é o que muda o resultado; o resto o
21
+ * prompt de sistema já diz. Todo briefing termina em como provar — "pronto"
22
+ * sem prova é a mentira mais comum de um agente, e o especialista é
23
+ * justamente quem sabe o que conta como prova na área dele.
24
+ *
25
+ * ── QUEM ESCOLHE ────────────────────────────────────────────────────────
26
+ * O modelo, pelo campo `especialista` do spawn_agent. Quando ele não diz,
27
+ * `inferir` lê a tarefa e adivinha pelos sinais — errar para o generalista é
28
+ * barato (é o que acontecia antes), então só se decide com sinal de verdade.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const ESPECIALISTAS = {
34
+ frontend: {
35
+ faz: 'tela, HTML, CSS e o JavaScript do navegador',
36
+ sinais: /\b(css|html|layout|tela|interface|componente|responsiv\w*|bot[ãa]o|p[áa]gina|front-?end|estilo|folha de estilo|ui|formul[áa]rio|menu|navbar|modal)\b/gi,
37
+ briefing: `Você é o especialista em FRONTEND. Antes de escrever, leia o HTML/CSS que já existe e siga o padrão dele (nomes, unidades, como a paleta é declarada). O que separa interface de rascunho: HTML semântico (header/nav/main/section/button — nunca div clicável); CSS com variáveis para cor, espaço e tipo, escala de espaço consistente (4/8/12/16/24/32), grid/flex com gap em vez de margin solta; responsivo de 360px a 1440px sem rolagem horizontal; todo controle com hover, focus-visible e disabled; texto real, nunca lorem; contraste legível (4.5:1). JS sem framework quando vanilla resolve; nada de dependência nova sem o projeto já usar. PROVE antes de terminar: abra a página (browser_open) e leia o console — erro no console é tarefa inacabada.`,
38
+ },
39
+ backend: {
40
+ faz: 'API, servidor, dados no servidor e autenticação',
41
+ sinais: /\b(api|rotas?|endpoints?|servidor|banco de dados|sql|express|flask|django|fastapi|autentica\w*|back-?end|middleware|token|sess[ãa]o|webhook)\b/gi,
42
+ briefing: `Você é o especialista em BACKEND. Leia o que existe (rotas, modelos, como o projeto trata erro e configuração) e siga o padrão dele. Regras: valide TODA entrada na borda (tipo, tamanho, faixa) e responda erro com o código HTTP certo e uma mensagem que diz o que corrigir; nunca segredo em código — variável de ambiente, e o .env.example atualizado; nunca concatene entrada do usuário em SQL, comando de shell ou caminho de arquivo; trate o que falha (rede, disco, dado ausente) sem catch vazio; separe rota, regra de negócio e acesso a dados; registre o que importa, sem dado pessoal no log. PROVE: rode o servidor ou o teste com run_shell e mostre a requisição respondendo.`,
43
+ },
44
+ testes: {
45
+ faz: 'escrever e rodar testes, reproduzir bug antes de consertar',
46
+ sinais: /\b(testes?|testar|test|spec|jest|pytest|mocha|vitest|cobertura|reproduz\w*|regress[ãa]o|assert\w*)\b/gi,
47
+ briefing: `Você é o especialista em TESTES. Primeiro descubra como o projeto testa (package.json, pytest, pasta test/) e use o mesmo jeito — não traga framework novo. Para um bug: escreva PRIMEIRO o teste que reproduz e falha, depois a correção, depois veja o teste passar. Para código novo: cubra o caminho feliz, os limites (vazio, zero, máximo, unicode) e o que dá errado (entrada inválida, dependência fora). Um teste, uma afirmação clara, com nome que diz o comportamento ("recusa e-mail sem @"), sem depender de rede nem de ordem. Rode com run_shell e devolva o resultado REAL — quantos passaram, qual falhou e a linha do erro. Nunca diga "os testes passam" sem a saída na mão.`,
48
+ },
49
+ design: {
50
+ faz: 'identidade visual: paleta, tipografia, espaço, hierarquia',
51
+ sinais: /\b(design|paleta|tipografia|identidade visual|cores?|fontes?|visual|bonit[oa]|est[ée]tica|marca|logo|[íi]cones?|tema)\b/gi,
52
+ briefing: `Você é o especialista em DESIGN VISUAL. Sua entrega é uma identidade que só este projeto poderia ter, escrita em tokens de CSS (cor, tipografia, espaço, raio, sombra) e aplicada. Decida a partir do assunto: quem usa, em que tela, com que sentimento. Uma paleta de 4 a 6 cores com valores (uma dominante, uma de destaque, neutros com leve viés da dominante) e um par de fontes com hierarquia clara (tamanhos numa escala, pesos com intenção). Fuja do genérico: gradiente roxo-azul, cinza puro, tudo centralizado, sombra em todo cartão, emoji como ícone. Espaço em branco é design; alinhe a uma grade. Ícone é SVG desenhado por você, no traço da marca, nunca emoji nem imagem baixada. PROVE: abra a página e descreva o que se vê em 390px e em 1280px.`,
53
+ },
54
+ dados: {
55
+ faz: 'CSV, planilha, análise, modelagem e transformação de dados',
56
+ sinais: /\b(dados|csv|json|planilha|tabela|colunas?|estat[íi]stica|an[áa]lise|gr[áa]fico|dataset|pandas|migra[çc][ãa]o|etl|relat[óo]rio)\b/gi,
57
+ briefing: `Você é o especialista em DADOS. Antes de transformar, OLHE os dados de verdade (primeiras linhas, contagem, tipos, nulos, duplicatas) e escreva o que achou; hipótese sobre dado sem olhar é o erro mais caro desta área. Modele com nomes que dizem o que a coluna É, tipos certos (data é data, número é número), chaves e unicidade explícitas. Scripts idempotentes: rodar duas vezes dá o mesmo resultado. Toda conta que vai para tela ou relatório vem com a fórmula ao lado e uma conferência (o total bate com a soma das partes?). Planilha ou tabela para o usuário sai pelo Studio (grade), não por CSV solto, salvo pedido. Nunca invente número: o que não está nos dados fica em branco, e dito. PROVE: mostre a contagem antes e depois.`,
58
+ },
59
+ revisor: {
60
+ faz: 'lê o código e aponta os defeitos, sem consertar',
61
+ sinais: /\b(revis\w*|audit\w*|code review|aponte|defeitos?|conferir|avalie|analise o c[óo]digo|o que est[áa] errado|cr[íi]tica)\b/gi,
62
+ briefing: `Você é o REVISOR, e não escreveu esse código: não conserte nada — aponte. Leia inteiros os arquivos que importam antes de opinar. Procure, nesta ordem: o que QUEBRA (erro de execução, caminho que não existe, promessa sem await, estado que nunca é limpo); o que é INSEGURO (entrada sem validação, segredo em código, injeção em SQL/shell/HTML, dependência sem necessidade); o que MENTE (botão que não faz nada, texto de exemplo, "pronto" sem prova); o que vai quebrar DEPOIS (limite, vazio, concorrência). Se há página HTML, ABRA (browser_open) e leia o console: o que a máquina mediu vale mais que a sua opinião. Responda com uma lista — ARQUIVO:linha, o que está errado, o que deveria acontecer — do mais grave ao menos, no máximo oito itens, sem gosto pessoal.`,
63
+ },
64
+ ambiente: {
65
+ faz: 'instalar dependências, configurar e fazer rodar',
66
+ sinais: /\b(instal\w*|depend[êe]ncias?|npm|pnpm|yarn|pip|uv|brew|apt(-get)?|winget|ffmpeg|configur\w*|ambiente|setup|rodar o projeto|node_modules|docker|venv)\b/gi,
67
+ briefing: `Você é o especialista em AMBIENTE — dependências, instalação e execução. Descubra o que a máquina tem (node --version, python3 --version, qual gerenciador: npm/pnpm/yarn, pip/uv, brew/apt/winget) antes de mandar instalar; use o gerenciador que o projeto já usa (o lockfile diz). Instale só o que a tarefa pede, com a versão fixada no manifesto (package.json, requirements.txt), nunca global sem motivo. Comando que demora ou fica de pé vai em run_in_new_terminal; o resto em run_shell, lendo a saída inteira — "warning" não é erro, "ERR!" é. Falta algo no sistema (ffmpeg, git, python)? Instale pelo gerenciador do sistema e confira com --version. PROVE: o comando de rodar funcionando, e um LEIA.md com instalar e rodar que qualquer um siga.`,
68
+ },
69
+ };
70
+
71
+ const NOMES = Object.keys(ESPECIALISTAS);
72
+
73
+ /** O especialista de nome dado, ou null para o generalista. Aceita maiúsculas e acento. */
74
+ function porNome(nome) {
75
+ const n = String(nome || '').trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
76
+ if (!n || n === 'geral') return null;
77
+ // Sinônimos que o modelo escreve: "front", "back", "qa", "reviewer", "devops".
78
+ const mapa = { front: 'frontend', 'front-end': 'frontend', ui: 'frontend', back: 'backend', 'back-end': 'backend',
79
+ api: 'backend', qa: 'testes', teste: 'testes', test: 'testes', tests: 'testes', designer: 'design', visual: 'design',
80
+ data: 'dados', reviewer: 'revisor', review: 'revisor', revisao: 'revisor', devops: 'ambiente', infra: 'ambiente',
81
+ instalacao: 'ambiente', setup: 'ambiente' };
82
+ const chave = mapa[n] || n;
83
+ return ESPECIALISTAS[chave] ? { nome: chave, ...ESPECIALISTAS[chave] } : null;
84
+ }
85
+
86
+ /**
87
+ * Adivinha o especialista pela tarefa. Conta os sinais de cada um; empate ou
88
+ * sinal fraco (menos de dois) devolve null — o generalista é o que já existia,
89
+ * e chamar o especialista errado é pior que não chamar nenhum.
90
+ */
91
+ function inferir(prompt) {
92
+ const t = String(prompt || '');
93
+ if (!t) return null;
94
+ let melhor = null;
95
+ let pontos = 0;
96
+ let empate = false;
97
+ for (const nome of NOMES) {
98
+ const n = (t.match(ESPECIALISTAS[nome].sinais) || []).length;
99
+ if (n > pontos) { melhor = nome; pontos = n; empate = false; }
100
+ else if (n === pontos && n > 0) empate = true;
101
+ }
102
+ if (!melhor || pontos < 2 || empate) return null;
103
+ return { nome: melhor, ...ESPECIALISTAS[melhor] };
104
+ }
105
+
106
+ /** O que o modelo pediu ganha; sem pedido, o que a tarefa sugere. */
107
+ function escolher(nome, prompt) {
108
+ return porNome(nome) || inferir(prompt);
109
+ }
110
+
111
+ /** A mensagem que o subagente recebe: o briefing na frente, a tarefa depois. */
112
+ function montarTarefa(especialista, prompt) {
113
+ const tarefa = String(prompt || '');
114
+ if (!especialista) return tarefa;
115
+ return `${especialista.briefing}\n\nSUA TAREFA:\n${tarefa}`;
116
+ }
117
+
118
+ /** O rótulo que aparece na tela: "Agente·frontend". */
119
+ function rotulo(especialista) {
120
+ return especialista ? `Agente·${especialista.nome}` : 'Agente';
121
+ }
122
+
123
+ module.exports = { ESPECIALISTAS, NOMES, porNome, inferir, escolher, montarTarefa, rotulo };