primocode 7.16.0 → 7.18.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/bin/primocode.js CHANGED
@@ -22,7 +22,6 @@ const cfg = require('../lib/config');
22
22
  const { runAct, interromper } = require('../lib/act');
23
23
  const pasta = require('../lib/pasta');
24
24
  const catalogo = require('../lib/catalogo');
25
- const streaming = require('../lib/streaming');
26
25
  const effort = require('../lib/effort');
27
26
 
28
27
  // A pasta de onde o comando foi dado. Não é mais onde ele trabalha (o padrão é
@@ -57,7 +56,6 @@ ${c.bold('Comandos')}
57
56
  ${c.brand('/read')} <arquivo> mostra um arquivo
58
57
  ${c.brand('/run')} <comando> executa no shell do projeto
59
58
  ${c.brand('/open')} <arquivo|url> abre no navegador
60
- ${c.brand('/streaming')} painel ao vivo — veja o agente mexendo no computador
61
59
  ${c.brand('/status')} checa o servidor
62
60
  ${c.brand('/name')} <nome> como o Primo te chama
63
61
  ${c.brand('/mascot')} <estilo> mascote: primo|fox|cat
@@ -81,7 +79,7 @@ ${c.bold('Onde ele trabalha')}
81
79
 
82
80
  // ── Autopreenchimento dos comandos (estilo Claude Code) ────────────────
83
81
  // Metade do comando já serve: Tab completa, e um prefixo único executa
84
- // direto — "/str" vira "/streaming" sozinho. A lista é só a dos nomes
82
+ // direto — "/eff" vira "/effort" sozinho. A lista é só a dos nomes
85
83
  // canônicos; os apelidos (/cd, /stream…) continuam valendo por extenso.
86
84
  const SLASH_COMANDOS = [
87
85
  ['/help', 'todos os comandos'],
@@ -97,7 +95,6 @@ const SLASH_COMANDOS = [
97
95
  ['/read', 'mostra um arquivo'],
98
96
  ['/run', 'roda um comando'],
99
97
  ['/open', 'abre no navegador'],
100
- ['/streaming', 'painel ao vivo — vídeo dele trabalhando'],
101
98
  ['/status', 'checa o servidor'],
102
99
  ['/name', 'como ele te chama'],
103
100
  ['/mascot', 'primo | fox | cat'],
@@ -122,12 +119,11 @@ function completarSlash(line) {
122
119
  // Régua com os níveis, o marcador ▲ embaixo do escolhido, "Faster" à
123
120
  // esquerda e "Smarter" à direita. ←/→ move, Enter confirma, Esc cancela.
124
121
 
125
- function desenharEffort(idx) {
122
+ // O layout sai em texto PLANO, com as marcas de onde pintar. Assim os dois
123
+ // pintores (o normal e o do ultracode animado) desenham exatamente a mesma
124
+ // régua — sem duas contas de posição para manter em sincronia.
125
+ function layoutEffort(idx) {
126
126
  const cols = Math.max(52, Math.min(process.stdout.columns || 100, 104));
127
- const regua = '▔'.repeat(cols);
128
- const linhas = [];
129
-
130
- // Cada nível ocupa uma coluna própria; o ultracode fica destacado no fim.
131
127
  const rotulos = effort.NOMES;
132
128
  const larguras = rotulos.map((n) => Math.max(n.length + 3, 9));
133
129
  const posicoes = [];
@@ -137,38 +133,130 @@ function desenharEffort(idx) {
137
133
  x += larguras[i];
138
134
  }
139
135
  const fimRegua = x;
140
-
141
- linhas.push(c.dim(regua));
142
- linhas.push(' ' + c.bold(c.white('Effort')));
143
- linhas.push('');
144
-
145
- // Faster ....▲.... Smarter — a trilha, com o marcador na posição atual.
146
136
  const marca = posicoes[idx];
137
+
147
138
  let trilha = '';
148
139
  for (let k = 23; k < fimRegua; k++) {
149
- if (k === marca) trilha += c.brand('▲');
150
- else if (k === fimRegua - 12) trilha += c.dim('┆'); // ultracode é um degrau à parte
151
- else trilha += c.dim(k < marca ? '─' : '─');
140
+ if (k === marca) trilha += '▲';
141
+ else if (k === fimRegua - 12) trilha += '┆'; // ultracode é um degrau à parte
142
+ else trilha += '─';
152
143
  }
153
- linhas.push(' ' + c.muted('Faster') + ' '.repeat(Math.max(1, fimRegua - 23 - 6 - 7)) + c.muted('Smarter'));
154
- linhas.push(' ' + trilha);
155
144
 
156
- // Os nomes, o escolhido aceso.
157
- let linhaNomes = ' '.repeat(23);
145
+ let nomes = ' '.repeat(23);
146
+ let deNome = 0, ateNome = 0;
158
147
  for (let i = 0; i < rotulos.length; i++) {
159
148
  const nome = rotulos[i];
160
- const espaco = larguras[i];
161
- const pad = Math.floor((espaco - nome.length) / 2);
162
- const texto = i === idx ? c.bold(c.brand(nome)) : c.muted(nome);
163
- linhaNomes += ' '.repeat(pad) + texto + ' '.repeat(espaco - nome.length - pad);
149
+ const pad = Math.floor((larguras[i] - nome.length) / 2);
150
+ if (i === idx) { deNome = nomes.length + pad; ateNome = deNome + nome.length; }
151
+ nomes += ' '.repeat(pad) + nome + ' '.repeat(larguras[i] - nome.length - pad);
164
152
  }
165
- linhas.push(linhaNomes);
166
153
 
167
154
  const nivel = effort.nivelDe(rotulos[idx]);
168
- linhas.push(' ' + c.dim(nivel.resumo));
169
- linhas.push('');
170
- linhas.push(' ' + c.muted('←/→ ajusta · Enter confirma · Esc cancela'));
171
- return linhas.join('\n');
155
+ const vaoExtremos = Math.max(1, fimRegua - 23 - 6 - 7);
156
+
157
+ return {
158
+ cols,
159
+ marca,
160
+ destaque: { de: deNome, ate: ateNome },
161
+ linhas: [
162
+ { tipo: 'regua', texto: '▔'.repeat(cols) },
163
+ { tipo: 'titulo', texto: ' Effort' },
164
+ { tipo: 'vazio', texto: '' },
165
+ { tipo: 'extremos', texto: ' '.repeat(23) + 'Faster' + ' '.repeat(vaoExtremos) + 'Smarter' },
166
+ { tipo: 'trilha', texto: ' '.repeat(23) + trilha },
167
+ { tipo: 'nomes', texto: nomes },
168
+ { tipo: 'resumo', texto: ' '.repeat(23) + nivel.resumo },
169
+ { tipo: 'vazio', texto: '' },
170
+ { tipo: 'ajuda', texto: ' ←/→ ajusta · Enter confirma · Esc cancela' },
171
+ ],
172
+ };
173
+ }
174
+
175
+ // ── O brilho do ultracode ────────────────────────────────────────────────
176
+ // No nível máximo o painel inteiro ganha um fundo roxo que ondula, como no
177
+ // Claude Code. São faixas verticais de roxo em brilhos diferentes; a onda
178
+ // anda para o lado a cada quadro, e o texto fica branco por cima.
179
+ //
180
+ // Roxos do cubo de 256 cores, ordenados pelo brilho REAL (luminância), do
181
+ // mais escuro ao mais claro — a onda escolhe um deles por faixa. Fora da
182
+ // lista ficaram os roxos escuros que puxam para o vinho (53-56): quebravam a
183
+ // família violeta. Só 256 cores (não truecolor): funciona em qualquer
184
+ // terminal, inclusive no Terminal.app do macOS.
185
+ const ROXOS = [57, 92, 93, 63, 98, 99, 105, 141];
186
+
187
+ function fundoRoxo(x, fase) {
188
+ // Duas ondas de períodos diferentes: o encontro delas dá o desenho
189
+ // irregular de faixas, em vez de listras repetidas e óbvias.
190
+ const faixa = Math.floor(x / 2);
191
+ const onda = Math.sin(faixa * 0.55 + fase) * 0.6 + Math.sin(faixa * 0.17 - fase * 0.7) * 0.4;
192
+ const i = Math.round((onda + 1) / 2 * (ROXOS.length - 1));
193
+ return ROXOS[Math.max(0, Math.min(ROXOS.length - 1, i))];
194
+ }
195
+
196
+ // Pinta a linha inteira (até a largura do painel) com a onda por baixo.
197
+ // `destaque` é o trecho que fica branco e forte (o nível escolhido); o resto
198
+ // da linha sai num lilás claro, para o escolhido saltar aos olhos.
199
+ function pintarLinhaUltra(texto, largura, fase, opts = {}) {
200
+ const base = opts.base || 231; // branco
201
+ const apagado = opts.apagado || base;
202
+ const destaque = opts.destaque;
203
+ let saida = '';
204
+ let corFundo = -1;
205
+ let corTexto = -1;
206
+ let forte = null;
207
+
208
+ for (let x = 0; x < largura; x++) {
209
+ const fundo = fundoRoxo(x, fase);
210
+ if (fundo !== corFundo) { saida += `\x1b[48;5;${fundo}m`; corFundo = fundo; }
211
+
212
+ const dentro = destaque && x >= destaque.de && x < destaque.ate;
213
+ const querForte = opts.negrito || dentro;
214
+ const querCor = dentro ? base : apagado;
215
+ if (querForte !== forte) { saida += querForte ? '\x1b[1m' : '\x1b[22m'; forte = querForte; }
216
+ if (querCor !== corTexto) { saida += `\x1b[38;5;${querCor}m`; corTexto = querCor; }
217
+
218
+ saida += texto[x] || ' ';
219
+ }
220
+ return saida + '\x1b[0m';
221
+ }
222
+
223
+ function desenharEffort(idx, fase) {
224
+ const L = layoutEffort(idx);
225
+ const ultra = effort.NOMES[idx] === 'ultracode';
226
+
227
+ if (ultra) {
228
+ // No ultracode o painel todo vira fundo animado. Cada linha é pintada
229
+ // com a MESMA onda, então as faixas ficam alinhadas de cima a baixo.
230
+ const LILAS = 189; // lilás bem claro, para o que não é o escolhido
231
+ return L.linhas.map((l) => {
232
+ if (l.tipo === 'titulo') return pintarLinhaUltra(l.texto, L.cols, fase, { negrito: true });
233
+ if (l.tipo === 'nomes') return pintarLinhaUltra(l.texto, L.cols, fase, { destaque: L.destaque, apagado: LILAS });
234
+ if (l.tipo === 'resumo' || l.tipo === 'extremos') return pintarLinhaUltra(l.texto, L.cols, fase, { base: LILAS, apagado: LILAS });
235
+ return pintarLinhaUltra(l.texto, L.cols, fase, {});
236
+ }).join('\n');
237
+ }
238
+
239
+ return L.linhas.map((l) => {
240
+ const t = l.texto;
241
+ switch (l.tipo) {
242
+ case 'regua': return c.dim(t);
243
+ case 'titulo': return c.bold(c.white(t));
244
+ case 'extremos': return c.muted(t);
245
+ case 'trilha': {
246
+ // O marcador aceso, o resto da trilha recuada.
247
+ const antes = t.slice(0, L.marca);
248
+ const depois = t.slice(L.marca + 1);
249
+ return c.dim(antes) + c.brand('▲') + c.dim(depois);
250
+ }
251
+ case 'nomes': {
252
+ const { de, ate } = L.destaque;
253
+ return c.muted(t.slice(0, de)) + c.bold(c.brand(t.slice(de, ate))) + c.muted(t.slice(ate));
254
+ }
255
+ case 'resumo': return c.dim(t);
256
+ case 'ajuda': return c.muted(t);
257
+ default: return t;
258
+ }
259
+ }).join('\n');
172
260
  }
173
261
 
174
262
  // Desenha, escuta as setas, e devolve o nome escolhido (ou null se cancelou).
@@ -180,21 +268,38 @@ function sliderEffort(atual) {
180
268
  }
181
269
  let idx = effort.indiceDe(atual);
182
270
  const ALTURA = 9; // linhas que o desenho ocupa — para redesenhar por cima
271
+ let fase = 0;
272
+ let anima = null;
183
273
 
184
274
  const pintar = (primeira) => {
185
275
  if (!primeira) process.stdout.write(`\x1b[${ALTURA}A\r`);
186
- process.stdout.write(desenharEffort(idx).split('\n').map((l) => '\x1b[2K' + l).join('\n') + '\n');
276
+ process.stdout.write(desenharEffort(idx, fase).split('\n').map((l) => '\x1b[2K' + l).join('\n') + '\n');
277
+ };
278
+
279
+ // O fundo do ultracode ondula; sair dele apaga a animação na hora, para
280
+ // não redesenhar sozinho um painel que já está parado.
281
+ const cuidarDaAnimacao = () => {
282
+ const ultra = effort.NOMES[idx] === 'ultracode';
283
+ if (ultra && !anima) {
284
+ anima = setInterval(() => { fase += 0.32; pintar(false); }, 90);
285
+ anima.unref();
286
+ } else if (!ultra && anima) {
287
+ clearInterval(anima); anima = null;
288
+ }
187
289
  };
290
+
188
291
  pintar(true);
292
+ cuidarDaAnimacao();
189
293
 
190
294
  const aoTeclar = (ch, key) => {
191
295
  if (!key) return;
192
- if (key.name === 'right' || key.name === 'l') { idx = Math.min(effort.NOMES.length - 1, idx + 1); pintar(false); return; }
193
- if (key.name === 'left' || key.name === 'h') { idx = Math.max(0, idx - 1); pintar(false); return; }
296
+ if (key.name === 'right' || key.name === 'l') { idx = Math.min(effort.NOMES.length - 1, idx + 1); cuidarDaAnimacao(); pintar(false); return; }
297
+ if (key.name === 'left' || key.name === 'h') { idx = Math.max(0, idx - 1); cuidarDaAnimacao(); pintar(false); return; }
194
298
  if (key.name === 'return' || key.name === 'enter') { encerrar(effort.NOMES[idx]); return; }
195
299
  if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { encerrar(null); return; }
196
300
  };
197
301
  const encerrar = (valor) => {
302
+ if (anima) { clearInterval(anima); anima = null; }
198
303
  process.stdin.removeListener('keypress', aoTeclar);
199
304
  sliderAtivo = false;
200
305
  resolve(valor);
@@ -222,7 +327,7 @@ function mostrarMenuSlash() {
222
327
  for (const [nome, desc] of SLASH_COMANDOS) {
223
328
  console.log(` ${c.brand(ui.padRight(nome, 12))}${c.muted(desc)}`);
224
329
  }
225
- console.log(c.dim('\n Tab completa · metade do comando já serve: /str vira /streaming'));
330
+ console.log(c.dim('\n Tab completa · metade do comando já serve: /eff vira /effort'));
226
331
  }
227
332
 
228
333
  let rl = null;
@@ -667,33 +772,6 @@ async function handleSlash(text) {
667
772
  break;
668
773
  }
669
774
 
670
- // Painel ao vivo: um servidorzinho local mostra cada ação do agente na
671
- // tela + a foto tirada logo depois dela. Liga/desliga; /streaming on|off
672
- // também funciona.
673
- case 'streaming': case 'stream': case 'live': {
674
- const pedido = rest.toLowerCase();
675
- const ligar = pedido === 'on' ? true : pedido === 'off' ? false : !streaming.isOn();
676
-
677
- if (!ligar) {
678
- if (streaming.isOn()) { streaming.stop(); console.log(c.ok('Streaming desligado.')); }
679
- else console.log(c.muted('O streaming já estava desligado.'));
680
- break;
681
- }
682
- if (streaming.isOn()) {
683
- console.log(c.muted(`Já está ligado: ${streaming.url()}`));
684
- break;
685
- }
686
- try {
687
- const url = await streaming.start();
688
- console.log(c.ok('Streaming ligado: ') + c.white(url));
689
- console.log(c.muted(' Dê uma instrução e veja cada passo dele aí, ao vivo. /streaming de novo desliga.'));
690
- await tools.openInBrowser(url);
691
- } catch (e) {
692
- console.log(c.err(`Não consegui ligar o streaming: ${e.message}`));
693
- }
694
- break;
695
- }
696
-
697
775
  case 'name': {
698
776
  if (!rest) { console.log(c.muted(` Nome atual: ${state.config.userName || '(nenhum)'}`)); break; }
699
777
  state.config.userName = rest;
@@ -770,7 +848,7 @@ async function handleSlash(text) {
770
848
  case 'exit': case 'quit': if (rl) rl.close(); break;
771
849
  default: {
772
850
  // "/" sozinho abre o menu; um prefixo único autopreenche e executa
773
- // ("/str" → /streaming); prefixo ambíguo mostra as opções.
851
+ // ("/eff" → /effort); prefixo ambíguo mostra as opções.
774
852
  if (!cmd) { mostrarMenuSlash(); break; }
775
853
  const casam = SLASH_COMANDOS.map(([nome]) => nome.slice(1)).filter((n) => n.startsWith(cmd.toLowerCase()));
776
854
  if (casam.length === 1) {
package/lib/act.js CHANGED
@@ -15,10 +15,6 @@ const tools = require('./tools');
15
15
  const api = require('./api');
16
16
  const cfg = require('./config');
17
17
  const catalogo = require('./catalogo');
18
- // "painel", não "streaming": dentro do runAct existe um `let streaming` (a
19
- // flag de texto chegando em stream) que SOMBREARIA este módulo — e o step()
20
- // viraria TypeError engolido pelo catch. Foi um bug real, pego em teste.
21
- const painel = require('./streaming');
22
18
 
23
19
  const MAX_ITERATIONS = 15;
24
20
  const MAX_PROFUNDIDADE = 2; // principal + 1 nível de subagente
@@ -201,11 +197,6 @@ async function runAct(userPrompt, opts) {
201
197
 
202
198
  let finalText = '';
203
199
 
204
- // O pedido do usuário aparece na barra de comando do painel, digitado
205
- // letra a letra — só o pedido principal; sub-tarefa de agente não é
206
- // instrução de gente.
207
- if (!subagent) { try { painel.instrucao(userPrompt); } catch {} }
208
-
209
200
  // Pedido novo zera o pedido de parada anterior.
210
201
  if (!subagent) PARAR = false;
211
202
 
@@ -308,10 +299,6 @@ async function runAct(userPrompt, opts) {
308
299
  const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
309
300
  logResult(tc.name, result, profundidade);
310
301
 
311
- // Painel ao vivo (/streaming): registra o passo e fotografa a tela.
312
- // Nunca pode derrubar o loop — falha de foto é problema do painel.
313
- try { painel.step(tc.name, tc.arguments, result); } catch {}
314
-
315
302
  // Uma escrita bem-sucedida limpa a dívida: o agente se corrigiu.
316
303
  if (result && result.ok === false) falhasPendentes.push(`${tc.name}(${tc.arguments?.path || ''}): ${result.error}`);
317
304
  else if (['write_file', 'edit_file'].includes(tc.name)) falhasPendentes.length = 0;
package/lib/desktop.js CHANGED
@@ -659,8 +659,8 @@ function clickElement(nome, opts = {}) {
659
659
  }
660
660
 
661
661
  // ── Screenshot ────────────────────────────────────────
662
- // A ferramenta do Linux é resolvida UMA vez: o vídeo do /streaming chama isto
663
- // várias vezes por segundo, e um `which` por frame seria desperdício.
662
+ // A ferramenta do Linux é resolvida UMA vez: um `which` a cada screenshot
663
+ // seria desperdício.
664
664
  let LINUX_SHOT;
665
665
  function linuxShot() {
666
666
  if (LINUX_SHOT === undefined) LINUX_SHOT = firstCmd(['scrot', 'maim', 'gnome-screenshot', 'spectacle', 'import']);
@@ -669,8 +669,8 @@ function linuxShot() {
669
669
 
670
670
  /**
671
671
  * O comando de shell que fotografa a tela para `filename` — ou null quando a
672
- * plataforma não tem como. Existe separado do screenshot() para o painel do
673
- * /streaming capturar em LOOP, assíncrono, sem travar o agente.
672
+ * plataforma não tem como. Existe separado do screenshot() para quem precisa
673
+ * capturar de forma assíncrona, sem travar o processo.
674
674
  */
675
675
  function screenshotCommand(filename) {
676
676
  if (PLATFORM === 'darwin') return `screencapture -x "${filename}"`;
package/lib/referencia.js CHANGED
@@ -8,10 +8,9 @@
8
8
  *
9
9
  * Duas decisões de projeto, deliberadas:
10
10
  *
11
- * 1. É VISÍVEL, não oculto. Cada pesquisa aparece no terminal e vira uma aba
12
- * no /streaming, com as fontes. Um agente que abre coisas escondido do dono
13
- * da máquina é um agente em que não dá para confiar — e o /streaming existe
14
- * justamente para mostrar tudo.
11
+ * 1. É VISÍVEL, não oculto. Cada pesquisa aparece no terminal com as fontes
12
+ * que ele abriu. Um agente que abre coisas escondido do dono da máquina é
13
+ * um agente em que não dá para confiar.
15
14
  *
16
15
  * 2. Ele extrai CONHECIMENTO (como se faz, qual API, que cuidado tomar), não
17
16
  * blocos de código para colar. Copiar trechos de terceiros para dentro do
package/lib/tools.js CHANGED
@@ -391,7 +391,7 @@ async function despachar(name, args, ctx) {
391
391
 
392
392
  // Pesquisa de referência: abre documentação e exemplos sobre o
393
393
  // pedido e devolve o TEXTO para o modelo aprender antes de
394
- // escrever. Aparece no terminal e no /streaming — nada oculto.
394
+ // escrever. Aparece no terminal, com as fontes — nada oculto.
395
395
  case 'pesquisar_referencia':
396
396
  return referencia.pesquisar({
397
397
  consulta: args.consulta || args.query || '',
package/lib/ui.js CHANGED
@@ -157,13 +157,13 @@ const NOME = 'Primo Code';
157
157
  const TIPS = [
158
158
  'Peça um app novo, ou aponte para um repositório e mande clonar',
159
159
  '/dir <pasta> para ele trabalhar numa pasta que já é sua',
160
- '/help lista tudo · /effort ajusta o esforço · /streaming mostra ao vivo',
160
+ '/help lista tudo · /effort ajusta o esforço · /status confere a API',
161
161
  ];
162
162
 
163
163
  const NEWS = [
164
164
  '/effort escolhe o esforço num slider — do rápido ao mais esperto',
165
165
  'Do "high" para cima ele PESQUISA referências antes de escrever',
166
- 'Esc Esc para o agente · /streaming mostra tudo ao vivo, e limpa os prints',
166
+ 'Esc Esc a qualquer momento para o agente no meio do trabalho',
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.16.0",
3
+ "version": "7.18.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: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
package/lib/streaming.js DELETED
@@ -1,759 +0,0 @@
1
- /**
2
- * streaming.js — o painel "ao vivo": ver o agente mexendo no computador.
3
- *
4
- * O agente age no desktop do usuário (clica, digita, abre janela), mas rápido
5
- * demais para acompanhar a olho nu — e às vezes numa janela que não está na
6
- * frente. O /streaming liga um servidorzinho HTTP local que abre um site com
7
- * a cara do PrimoCode: mascote + nome no cabeçalho e, abaixo, uma "janela"
8
- * com ABAS — "Ao vivo" mostra a tela agora, e cada app/janela em que o agente
9
- * mexeu vira uma aba própria, com os passos e as fotos daquele app. Dá para
10
- * voltar e rever o que ele já fez, passo a passo.
11
- *
12
- * Zero dependências: http nativo, uma página HTML embutida que consulta
13
- * /state a cada 700ms. Só escuta em 127.0.0.1 — nada disso sai da máquina.
14
- */
15
-
16
- 'use strict';
17
-
18
- const http = require('http');
19
- const fs = require('fs');
20
- const path = require('path');
21
- const os = require('os');
22
- const { exec } = require('child_process');
23
- const desktop = require('./desktop');
24
-
25
- const DIR = path.join(os.homedir(), '.primocode', 'streaming');
26
- const MAX_LOG = 300;
27
- // Fotos guardadas em disco para o "voltar no tempo" das abas. Além disso a
28
- // pasta viraria um álbum infinito — as mais velhas saem.
29
- const MAX_FRAMES = 60;
30
- const MAX_ABAS = 12;
31
- // Teto de disco: mesmo com poucos quadros, tela 4K dá PNG gordo. Passou
32
- // disso, as fotos mais velhas saem até caber. 120 MB de screenshots numa
33
- // pasta escondida é exatamente o tipo de coisa que ninguém vai limpar.
34
- const MAX_BYTES = 120 * 1024 * 1024;
35
- let bytesEmDisco = 0;
36
- // Uma foto por ação no máximo a cada 600ms: digitar letra por letra não pode
37
- // virar uma rajada de screenshots.
38
- const INTERVALO_FOTO_MS = 600;
39
-
40
- const state = {
41
- on: false,
42
- server: null,
43
- url: '',
44
- frames: [], // [{ id, hora, acao, aba }] — id é o nome do arquivo
45
- proximoFrame: 1,
46
- abas: [], // [{ id, nome, passos: 0 }] — "Ao vivo" é virtual, não entra aqui
47
- abaAtual: null,
48
- log: [],
49
- instrucoes: [], // o que o usuário pediu — a página autopreenche digitando
50
- erroFoto: null,
51
- ultimaFoto: 0,
52
- passo: 0,
53
- // ── vídeo ao vivo ──
54
- liveSeq: 0, // sobe a cada frame novo; a página troca a imagem quando muda
55
- liveAt: 0, // quando o último frame de vídeo foi capturado
56
- viewerAt: 0, // último sinal de que alguém está com a página aberta
57
- liveTimer: null,
58
- liveBusy: false, // uma captura em voo por vez — sem isso cada poll da página
59
- // abria OUTRA cadeia de captura e elas se multiplicavam
60
- };
61
-
62
- const LIVE = () => path.join(DIR, 'live.png');
63
- const LIVE_TMP = () => path.join(DIR, 'live-tmp.png');
64
-
65
- // ── O vídeo: um loop de captura enquanto alguém assiste ──────────────────
66
- // "Vídeo" aqui é o jeito honesto de fazer sem dependência: fotografar a tela
67
- // em sequência (2 a 3 quadros por segundo, conforme a máquina aguenta) e a
68
- // página trocar a imagem no ritmo — como um VNC simples. O loop é assíncrono
69
- // (exec, nunca execSync) para não roubar tempo do agente, escreve num tmp e
70
- // renomeia (troca atômica: o navegador nunca lê um PNG pela metade), e SÓ
71
- // roda enquanto a página deu sinal de vida há menos de 8s — painel fechado
72
- // não custa nada.
73
- function viuAgora() {
74
- state.viewerAt = Date.now();
75
- agendarVideo(0);
76
- }
77
-
78
- function agendarVideo(ms) {
79
- if (!state.on || state.liveTimer || state.liveBusy) return;
80
- state.liveTimer = setTimeout(cicloVideo, ms);
81
- state.liveTimer.unref();
82
- }
83
-
84
- function cicloVideo() {
85
- state.liveTimer = null;
86
- if (!state.on || state.liveBusy) return;
87
- if (Date.now() - state.viewerAt > 8000) return; // ninguém olhando — o próximo /state religa
88
-
89
- const cmd = desktop.screenshotCommand(LIVE_TMP());
90
- if (!cmd) { if (!state.erroFoto) state.erroFoto = 'screenshot indisponível nesta máquina'; return; }
91
-
92
- const t0 = Date.now();
93
- state.liveBusy = true;
94
- exec(cmd, { timeout: 8000 }, (err) => {
95
- state.liveBusy = false;
96
- const dur = Date.now() - t0;
97
- if (!err) {
98
- try {
99
- // Rename é atômico; no Windows ele pode falhar se o navegador
100
- // estiver lendo o arquivo NAQUELE instante — aí copia por cima.
101
- try { fs.renameSync(LIVE_TMP(), LIVE()); }
102
- catch { fs.copyFileSync(LIVE_TMP(), LIVE()); try { fs.unlinkSync(LIVE_TMP()); } catch {} }
103
- state.liveSeq++;
104
- state.liveAt = Date.now();
105
- state.erroFoto = null;
106
- } catch {}
107
- } else if (!state.erroFoto) {
108
- state.erroFoto = corta(String(err.message || err), 120);
109
- }
110
- // Máquina rápida ≈ 3 quadros/s; máquina lenta desacelera sozinha.
111
- agendarVideo(Math.max(280, Math.min(1200, dur)));
112
- });
113
- }
114
-
115
- // Ações cujo efeito aparece NA TELA — são as que merecem uma foto nova.
116
- const ACOES_NA_TELA = new Set([
117
- 'desktop_click', 'desktop_double_click', 'desktop_click_element',
118
- 'desktop_move', 'desktop_drag', 'desktop_type', 'desktop_key',
119
- 'desktop_scroll', 'desktop_open_app', 'desktop_focus', 'desktop_screenshot',
120
- 'browser_open', 'browser_click', 'browser_type', 'browser_key',
121
- 'browser_scroll',
122
- ]);
123
-
124
- function isOn() { return state.on; }
125
- function url() { return state.url; }
126
-
127
- const corta = (s, n) => { const t = String(s ?? ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
128
-
129
- // A frase humana de cada ação — é o que aparece na linha do tempo.
130
- function descrever(name, args = {}) {
131
- switch (name) {
132
- case 'desktop_click': return `clique em (${args.x}, ${args.y})`;
133
- case 'desktop_double_click': return `clique duplo em (${args.x}, ${args.y})`;
134
- case 'desktop_click_element': return `clique em “${corta(args.name, 40)}”${args.double ? ' (duplo)' : ''}`;
135
- case 'desktop_move': return `mouse para (${args.x}, ${args.y})`;
136
- case 'desktop_drag': return `arrastou (${args.from_x}, ${args.from_y}) → (${args.to_x}, ${args.to_y})`;
137
- case 'desktop_type': return `digitou “${corta(args.text, 40)}”`;
138
- case 'desktop_key': return `tecla ${args.key}`;
139
- case 'desktop_scroll': return `rolou a tela (${args.dy ?? 300})`;
140
- case 'desktop_open_app': return `abriu ${corta(args.app || args.name, 40)}`;
141
- case 'desktop_focus': return `focou a janela “${corta(args.window || args.title, 40)}”`;
142
- case 'desktop_windows': return 'listou as janelas abertas';
143
- case 'desktop_elements': return `leu os elementos${args.window ? ` de “${corta(args.window, 30)}”` : ' da janela em foco'}`;
144
- case 'desktop_screenshot': return 'tirou um print da tela';
145
- case 'desktop_size': return 'mediu a tela';
146
- case 'browser_open': return `abriu ${corta(args.url, 60)} no navegador`;
147
- case 'browser_click': return `clicou em “${corta(args.text || args.selector || `${args.x},${args.y}`, 40)}” na página`;
148
- case 'browser_type': return `digitou “${corta(args.text, 40)}” na página`;
149
- case 'browser_key': return `tecla ${args.key} na página`;
150
- case 'browser_scroll': return 'rolou a página';
151
- case 'browser_read': return 'leu a página';
152
- case 'browser_screenshot': return 'print da página';
153
- case 'browser_wait': return `esperou ${corta(args.selector, 40)}`;
154
- case 'browser_eval': return 'rodou JavaScript na página';
155
- case 'write_file': return `criou/reescreveu ${corta(args.path, 50)}`;
156
- case 'edit_file': return `editou ${corta(args.path, 50)}`;
157
- case 'multi_edit': return `editou ${corta(args.path, 50)} (várias trocas)`;
158
- case 'read_file': return `leu ${corta(args.path, 50)}`;
159
- case 'run_shell': return `rodou: ${corta(args.command, 60)}`;
160
- case 'run_in_new_terminal': return `terminal novo: ${corta(args.command, 50)}`;
161
- case 'write_todos': return 'atualizou o plano';
162
- case 'pesquisar_referencia': return `pesquisou referências: “${corta(args.consulta || args.query, 50)}”`;
163
- default: return name.replace(/_/g, ' ');
164
- }
165
- }
166
-
167
- // ── Abas: uma por app/janela em que o agente mexeu ────────────────────────
168
- // Abrir um app, focar uma janela ou abrir um site começa (ou reencontra) a
169
- // aba daquele contexto. As ações seguintes caem nela — é o que deixa o
170
- // "o que a IA já fez" navegável depois, em vez de um rolo único.
171
-
172
- function nomeDeAba(name, args = {}) {
173
- if (name === 'desktop_open_app') return String(args.app || args.name || '').trim();
174
- if (name === 'desktop_focus') return String(args.window || args.title || '').trim();
175
- if (name === 'browser_open') {
176
- try { return new URL(String(args.url)).hostname || 'navegador'; } catch { return 'navegador'; }
177
- }
178
- if (args && args.window) return String(args.window).trim();
179
-
180
- // Trabalho que não é de janela também merece aba — senão o painel fica
181
- // vazio de "o que ela fez" justamente no caso mais comum (mexer em
182
- // arquivo, rodar comando, pesquisar referência).
183
- if (['write_file', 'edit_file', 'multi_edit', 'read_file'].includes(name)) {
184
- const arq = String(args.path || '').split(/[\\/]/).pop();
185
- return arq ? `📄 ${arq}` : '📄 Arquivos';
186
- }
187
- if (['run_shell', 'run_in_new_terminal'].includes(name)) return '⌨ Terminal';
188
- if (name === 'pesquisar_referencia') return '🔎 Pesquisa';
189
- if (['glob', 'grep', 'list_dir'].includes(name)) return '🔍 Busca';
190
- if (name.startsWith('browser_')) return '🌐 Navegador';
191
- if (name.startsWith('desktop_')) return '🖥 Tela';
192
- if (name === 'spawn_agent') return '🤖 Subagente';
193
- return '';
194
- }
195
-
196
- let proximaAba = 1;
197
- function entrarNaAba(nome) {
198
- const alvo = corta(nome, 24);
199
- if (!alvo) return;
200
- let aba = state.abas.find((a) => a.nome.toLowerCase() === alvo.toLowerCase());
201
- if (!aba) {
202
- // id de contador próprio: com `abas.length + 1`, uma aba descartada
203
- // fazia a próxima nascer com id repetido — e os passos da nova
204
- // apareciam dentro da antiga.
205
- aba = { id: proximaAba++, nome: alvo, passos: 0 };
206
- state.abas.push(aba);
207
- if (state.abas.length > MAX_ABAS) state.abas.shift();
208
- }
209
- state.abaAtual = aba;
210
- }
211
-
212
- // Guarda o quadro e cobra o aluguel: acima de MAX_FRAMES ou de MAX_BYTES, as
213
- // fotos mais velhas são APAGADAS do disco. É o que impede a pasta de virar um
214
- // álbum infinito na máquina do usuário.
215
- function registrarFrame(meta, arquivo) {
216
- let tamanho = 0;
217
- try { tamanho = fs.statSync(arquivo).size; } catch {}
218
- state.frames.push({ ...meta, bytes: tamanho });
219
- bytesEmDisco += tamanho;
220
-
221
- while (state.frames.length > MAX_FRAMES || (bytesEmDisco > MAX_BYTES && state.frames.length > 1)) {
222
- const velho = state.frames.shift();
223
- bytesEmDisco -= velho.bytes || 0;
224
- try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
225
- }
226
- if (bytesEmDisco < 0) bytesEmDisco = 0;
227
- }
228
-
229
- /** Apaga TUDO que o painel deixou no disco. Chamado ao desligar e ao sair. */
230
- function limparDisco() {
231
- try {
232
- for (const f of fs.readdirSync(DIR)) {
233
- if (/^(frame-\d+|live|live-tmp)\.png$/.test(f)) {
234
- try { fs.unlinkSync(path.join(DIR, f)); } catch {}
235
- }
236
- }
237
- } catch {}
238
- state.frames = [];
239
- bytesEmDisco = 0;
240
- }
241
-
242
- /**
243
- * A instrução que o usuário acabou de dar. A página autopreenche na barra de
244
- * comando, letra por letra, e marca o começo dela na linha do tempo.
245
- */
246
- function instrucao(texto) {
247
- if (!state.on) return;
248
- const t = String(texto || '').trim();
249
- if (!t) return;
250
- state.instrucoes.push({ n: state.instrucoes.length + 1, hora: new Date().toTimeString().slice(0, 8), texto: corta(t, 300) });
251
- if (state.instrucoes.length > 40) state.instrucoes.splice(0, state.instrucoes.length - 40);
252
-
253
- state.passo++;
254
- state.log.push({ n: state.passo, hora: new Date().toTimeString().slice(0, 8), tipo: 'instrucao', acao: corta(t, 160), ok: true, aba: null });
255
- if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
256
- }
257
-
258
- /**
259
- * Chamada pelo loop do agente depois de CADA tool. Quando o streaming está
260
- * desligado é um no-op — o caminho normal não paga nada por isto.
261
- */
262
- function step(name, args, result) {
263
- if (!state.on) return;
264
-
265
- const troca = nomeDeAba(name, args || {});
266
- if (troca) entrarNaAba(troca);
267
- if (!state.abaAtual) entrarNaAba('🖥 Tela');
268
-
269
- state.passo++;
270
- if (state.abaAtual) state.abaAtual.passos++;
271
-
272
- const entrada = {
273
- n: state.passo,
274
- hora: new Date().toTimeString().slice(0, 8),
275
- acao: descrever(name, args || {}),
276
- ok: !(result && result.ok === false),
277
- erro: result && result.ok === false ? corta(result.error, 120) : undefined,
278
- aba: state.abaAtual ? state.abaAtual.id : null,
279
- };
280
-
281
- if (ACOES_NA_TELA.has(name)) {
282
- const frame = capturar(entrada);
283
- if (frame) entrada.frame = frame;
284
- }
285
-
286
- state.log.push(entrada);
287
- if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
288
- }
289
-
290
- function capturar(entrada) {
291
- const agora = Date.now();
292
- if (agora - state.ultimaFoto < INTERVALO_FOTO_MS) return null;
293
- state.ultimaFoto = agora;
294
-
295
- const id = state.proximoFrame;
296
- const arquivo = path.join(DIR, `frame-${id}.png`);
297
-
298
- // Com o vídeo rodando, o quadro mais novo já está no disco — copiar é
299
- // grátis e não fotografa duas vezes a mesma tela.
300
- if (agora - state.liveAt < 900) {
301
- try { fs.copyFileSync(LIVE(), arquivo); } catch { return null; }
302
- state.proximoFrame++;
303
- registrarFrame({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba }, arquivo);
304
- return id;
305
- }
306
-
307
- const r = desktop.screenshot(arquivo);
308
- if (!r.ok) { if (!state.erroFoto) state.erroFoto = r.error; return null; }
309
-
310
- state.erroFoto = null;
311
- state.proximoFrame++;
312
- registrarFrame({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba }, arquivo);
313
- return id;
314
- }
315
-
316
- // ── Página ────────────────────────────────────────────────────────────────
317
- // O desenho combinado: mascote + PrimoCode no cabeçalho; abaixo, uma "janela"
318
- // de borda branca com as três bolinhas e as ABAS; dentro, a tela que a IA
319
- // está usando (ou usou, nas abas antigas), com os passos daquela aba embaixo.
320
-
321
- // O mascote do CLI (▐▛███▜▌ …) redesenhado em SVG, célula a célula, na mesma
322
- // paleta: bloco cheio na cor viva, contorno na apagada.
323
- // O mascote: um robozinho PROGRAMADOR. A cabeça é um monitor com o prompt
324
- // "❯_" na tela (é isso que ele faz: linha de comando), antena com luz de
325
- // atividade e dois bracinhos que digitam num tecladinho. O anterior era um
326
- // bloco de pixels sem leitura nenhuma — não dizia "programação", não dizia
327
- // nada. Olhos, antena, mão e cursor têm classe própria: a animação é CSS.
328
- const MASCOTE_SVG = `<svg viewBox="0 0 44 40" aria-hidden="true">
329
- <!-- antena com a luz de atividade -->
330
- <line x1="22" y1="1.5" x2="22" y2="6" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round"/>
331
- <circle class="antena" cx="22" cy="2" r="2.3" fill="#22d3ee"/>
332
- <!-- corpo/monitor -->
333
- <rect x="4" y="6" width="36" height="24" rx="5" fill="#0e2233" stroke="#22d3ee" stroke-width="1.8"/>
334
- <rect x="7.5" y="9.5" width="29" height="17" rx="3" fill="#071522"/>
335
- <!-- olhos -->
336
- <g fill="#22d3ee">
337
- <rect class="olho" x="12" y="14" width="5" height="6" rx="2.2"/>
338
- <rect class="olho" x="27" y="14" width="5" height="6" rx="2.2"/>
339
- </g>
340
- <!-- o prompt: ❯ e o cursor piscando (é um programador, afinal) -->
341
- <path d="M12.5 23.2 l2.4 1.5 -2.4 1.5" fill="none" stroke="#5eead4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
342
- <rect class="cursor" x="17" y="23" width="4.5" height="3.2" rx=".6" fill="#5eead4"/>
343
- <!-- braços + teclado -->
344
- <g stroke="#0891b2" stroke-width="1.8" stroke-linecap="round">
345
- <path class="braco braco1" d="M6 27 L3.5 33"/>
346
- <path class="braco braco2" d="M38 27 L40.5 33"/>
347
- </g>
348
- <rect x="8" y="33" width="28" height="5" rx="2" fill="#12384d" stroke="#0891b2" stroke-width="1.2"/>
349
- <g fill="#22d3ee" opacity=".85">
350
- <rect class="tecla t1" x="11" y="35" width="4" height="1.6" rx=".6"/>
351
- <rect class="tecla t2" x="17" y="35" width="4" height="1.6" rx=".6"/>
352
- <rect class="tecla t3" x="23" y="35" width="4" height="1.6" rx=".6"/>
353
- <rect class="tecla t1" x="29" y="35" width="4" height="1.6" rx=".6"/>
354
- </g>
355
- </svg>`;
356
-
357
- // A tela do painel, na identidade do app — a MESMA paleta do design system do
358
- // CLI (lib/ui.js): primária cyan #22d3ee, neutros slate, rose para falha. A
359
- // estrutura é a do desenho do dono: mascote inclinado no canto, "Primocode"
360
- // no alto, a janela com as bolinhas e as abas separadas por "|", a tela
361
- // embaixo. Dentro da tela roda o VÍDEO ao vivo (o loop de captura acima); as
362
- // abas em cima são tudo que o agente abriu — clicáveis, cada uma com seus
363
- // quadros e passos; e a barra de comando autopreenche a instrução digitando.
364
- // O favicon é o próprio mascote — o viewBox vem dele, não fixo (já ficou
365
- // errado uma vez quando o desenho mudou de tamanho e o número aqui não).
366
- const FAVICON = 'data:image/svg+xml,' + encodeURIComponent(
367
- MASCOTE_SVG.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" '),
368
- );
369
-
370
- const PAGINA = `<!doctype html>
371
- <html lang="pt-BR"><head><meta charset="utf-8">
372
- <meta name="viewport" content="width=device-width, initial-scale=1">
373
- <title>Primocode · Ao vivo</title>
374
- <link rel="icon" href="${FAVICON}">
375
- <style>
376
- * { box-sizing: border-box; margin: 0; }
377
- ::selection { background: rgba(34,211,238,.28); }
378
- body {
379
- background: radial-gradient(1100px 480px at 50% -120px, #12263f 0%, #0d1729 46%, #0b1220 100%) fixed #0b1220;
380
- color: #e2e8f0; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
381
- min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 0 18px 34px;
382
- }
383
-
384
- /* ── Cabeçalho: mascote inclinado + nome, como no desenho ── */
385
- #topo { position: relative; width: min(1120px, 100%); padding: 44px 0 26px; text-align: center; }
386
- #topo .mascote { position: absolute; left: 8px; top: 18px; width: 62px; height: 56px; filter: drop-shadow(0 8px 20px rgba(34,211,238,.35)); animation: flutua 3s ease-in-out infinite; }
387
- @keyframes flutua { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(5px) } }
388
- #topo .mascote svg { width: 100%; height: 100%; display: block; overflow: visible; }
389
- /* vivo: pisca os olhos, o cursor do prompt pisca, a antena respira e as
390
- mãozinhas digitam no teclado */
391
- .mascote .olho { transform-box: fill-box; transform-origin: center; animation: piscaOlho 4s infinite; }
392
- @keyframes piscaOlho { 0%, 91%, 100% { transform: scaleY(1) } 94%, 96% { transform: scaleY(.1) } }
393
- .mascote .cursor { animation: piscaCursor 1.05s steps(1) infinite; }
394
- @keyframes piscaCursor { 50% { opacity: 0 } }
395
- .mascote .antena { animation: respira 2.2s ease-in-out infinite; }
396
- @keyframes respira { 0%, 100% { opacity: 1; r: 2.3 } 50% { opacity: .55; r: 1.9 } }
397
- .mascote .braco { transform-box: fill-box; transform-origin: top center; }
398
- .mascote .braco1 { animation: digita .62s ease-in-out infinite; }
399
- .mascote .braco2 { animation: digita .62s ease-in-out infinite; animation-delay: .31s; }
400
- @keyframes digita { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(1.6px) } }
401
- .mascote .tecla { animation: teclaAcende 1.24s steps(1) infinite; }
402
- .mascote .t2 { animation-delay: .31s } .mascote .t3 { animation-delay: .62s }
403
- @keyframes teclaAcende { 0%, 74% { opacity: .35 } 75%, 99% { opacity: 1 } }
404
- #topo h1 { font-size: clamp(34px, 5.6vw, 54px); font-weight: 650; letter-spacing: .4px; color: #f1f5f9; text-shadow: 0 4px 28px rgba(34,211,238,.22); }
405
- #topo h1 span { color: #22d3ee; }
406
- #topo .vivo { position: absolute; right: 2px; top: 48px; display: inline-flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 12.5px; background: rgba(15,23,42,.85); border: 1px solid #1e293b; border-radius: 999px; padding: 6px 14px; }
407
- .pulso { width: 8px; height: 8px; border-radius: 50%; background: #22d3ee; box-shadow: 0 0 10px #22d3ee99; animation: pulsa 1.4s infinite; }
408
- @keyframes pulsa { 50% { opacity: .3 } }
409
-
410
- /* ── A janela ── */
411
- #janela {
412
- width: min(1120px, 100%); background: #000; border: 1px solid #24344d; border-radius: 22px;
413
- overflow: hidden; display: flex; flex-direction: column;
414
- box-shadow: 0 30px 80px rgba(2,6,23,.75), 0 0 60px rgba(34,211,238,.07), inset 0 1px 0 rgba(226,232,240,.07);
415
- }
416
- #barra { position: relative; display: flex; align-items: center; background: linear-gradient(#152238, #101a2c); padding: 13px 22px; min-height: 54px; border-bottom: 1px solid #0a1120; }
417
- #barra .b { flex: none; width: 13px; height: 13px; border-radius: 50%; margin-right: 9px; box-shadow: inset 0 -1.5px 3px rgba(0,0,0,.35); }
418
- #barra .b.r { background: #ff5f57; } #barra .b.y { background: #febc2e; } #barra .b.g { background: #28c840; }
419
-
420
- /* Abas: tudo que ele abriu, clicável, separado por | como no desenho */
421
- #abas { position: absolute; left: 50%; transform: translateX(-50%); max-width: 62%; display: flex; align-items: center; gap: 11px; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font-size: clamp(14px, 1.9vw, 18px); font-weight: 550; }
422
- #abas::-webkit-scrollbar { display: none; }
423
- .aba { position: relative; border: 0; background: none; cursor: pointer; font: inherit; color: #64748b; padding: 4px 3px 7px; transition: color .18s ease; }
424
- .aba:hover { color: #cbd5e1; }
425
- .aba.ativa { color: #22d3ee; text-shadow: 0 0 18px rgba(34,211,238,.5); }
426
- .aba.ativa::after { content: ''; position: absolute; left: 15%; right: 15%; bottom: 0; height: 2px; border-radius: 2px; background: #22d3ee; box-shadow: 0 0 10px #22d3ee; }
427
- .aba .qtd { color: #475569; font-size: .68em; vertical-align: super; margin-left: 4px; font-weight: 600; }
428
- .sep { color: #334155; font-weight: 300; }
429
-
430
- /* ── Barra de comando: a instrução autopreenchendo ── */
431
- #comando { display: flex; align-items: center; gap: 11px; background: #0a1120; border-bottom: 1px solid #060b16; padding: 11px 22px; font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: 14px; min-height: 44px; }
432
- #comando .chevron { color: #22d3ee; font-weight: 700; flex: none; text-shadow: 0 0 10px rgba(34,211,238,.6); }
433
- #comando .texto { color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
434
- #comando .texto.vazio { color: #475569; font-style: italic; }
435
- #comando .cursor { flex: none; width: 8px; height: 16px; background: #22d3ee; margin-left: 2px; animation: pisca 1s steps(1) infinite; box-shadow: 0 0 8px rgba(34,211,238,.7); }
436
- @keyframes pisca { 50% { opacity: 0 } }
437
- #comando .hora { margin-left: auto; color: #475569; font-size: 11.5px; flex: none; }
438
-
439
- /* ── A tela: o vídeo ao vivo ── */
440
- #tela { position: relative; min-height: 440px; display: flex; align-items: center; justify-content: center; background: #000; }
441
- #tela img { width: 100%; max-height: 60vh; object-fit: contain; display: none; }
442
- #aviso { text-align: center; padding: 84px 24px; }
443
- #aviso .titulo { font-size: clamp(19px, 3vw, 28px); font-weight: 550; color: #f1f5f9; }
444
- #aviso .sub { color: #64748b; font-size: 14px; margin-top: 13px; line-height: 1.7; }
445
- #badge { position: absolute; top: 14px; right: 16px; display: none; align-items: center; gap: 7px; background: rgba(8,12,22,.78); backdrop-filter: blur(6px); border: 1px solid #1e293b; color: #f1f5f9; font-size: 11.5px; font-weight: 700; letter-spacing: 1.2px; padding: 6px 12px; border-radius: 999px; }
446
- #badge .rec { width: 8px; height: 8px; border-radius: 50%; background: #fb7185; box-shadow: 0 0 9px #fb7185; animation: pulsa 1.1s infinite; }
447
- #carimbo { position: absolute; left: 16px; bottom: 13px; background: rgba(8,12,22,.78); backdrop-filter: blur(6px); border: 1px solid #1e293b; color: #94a3b8; font-size: 12px; padding: 5px 13px; border-radius: 999px; display: none; max-width: 82%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
448
- #carimbo b { color: #22d3ee; font-weight: 600; }
449
-
450
- /* ── Linha do tempo ── */
451
- #historico { width: min(1120px, 100%); padding-top: 18px; }
452
- #filme { display: flex; gap: 9px; padding: 4px 2px 13px; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
453
- .foto { flex: none; border: 1.5px solid #1e293b; border-radius: 9px; background: #000; cursor: pointer; padding: 0; width: 100px; height: 60px; overflow: hidden; opacity: .6; transition: transform .16s ease, opacity .16s ease, border-color .16s ease, box-shadow .16s ease; }
454
- .foto:hover { opacity: 1; transform: translateY(-2px); border-color: #475569; }
455
- .foto img { width: 100%; height: 100%; object-fit: cover; display: block; }
456
- .foto.ativa { border-color: #22d3ee; opacity: 1; box-shadow: 0 0 0 2px rgba(34,211,238,.3), 0 6px 18px rgba(2,6,23,.6); }
457
-
458
- #cartao { background: #0f172a; border: 1px solid #1e293b; border-radius: 16px; overflow: hidden; }
459
- #cartao header { display: flex; align-items: center; justify-content: space-between; padding: 11px 17px; border-bottom: 1px solid #16223a; color: #94a3b8; font-size: 13px; font-weight: 600; letter-spacing: .3px; }
460
- #cartao header span b { color: #22d3ee; }
461
- #passos { max-height: 236px; overflow-y: auto; padding: 5px 0 9px; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
462
- #passos::-webkit-scrollbar { width: 8px; } #passos::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; }
463
- .item { padding: 5px 17px; display: flex; gap: 10px; font-size: 13.5px; align-items: baseline; transition: background .12s ease; }
464
- .item:hover { background: #131f35; }
465
- .item .n { color: #475569; min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
466
- .item .ok { color: #22d3ee; } .item .falha { color: #fb7185; }
467
- .item .txt { flex: 1; word-break: break-word; color: #cbd5e1; }
468
- .item .txt .erro { color: #fb7185; font-size: 12px; display: block; margin-top: 1px; }
469
- .item .hora { color: #475569; font-size: 11px; font-variant-numeric: tabular-nums; }
470
- .item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #f1f5f9; }
471
- .item.pedido { background: #0c1526; border-top: 1px solid #16223a; border-bottom: 1px solid #16223a; margin: 6px 0; padding: 8px 17px; }
472
- .item.pedido .txt { color: #f1f5f9; font-weight: 600; }
473
- .item.pedido .chev { color: #22d3ee; font-weight: 800; }
474
-
475
- footer { color: #475569; font-size: 12px; padding-top: 17px; }
476
- footer b { color: #64748b; font-weight: 600; }
477
- </style></head>
478
- <body>
479
- <div id="topo">
480
- <div class="mascote">${MASCOTE_SVG}</div>
481
- <h1>Primo<span>code</span></h1>
482
- <span class="vivo"><span class="pulso"></span><span id="st">conectando…</span></span>
483
- </div>
484
-
485
- <div id="janela">
486
- <div id="barra">
487
- <span class="b r"></span><span class="b y"></span><span class="b g"></span>
488
- <nav id="abas"></nav>
489
- </div>
490
- <div id="comando">
491
- <span class="chevron">❯</span>
492
- <span class="texto vazio" id="cmdtexto">aguardando a sua instrução no terminal…</span>
493
- <span class="cursor"></span>
494
- <span class="hora" id="cmdhora"></span>
495
- </div>
496
- <section id="tela">
497
- <img id="frame" alt="tela do computador, ao vivo">
498
- <div id="aviso">
499
- <div class="titulo">A tela do computador aparece aqui, em vídeo</div>
500
- <div class="sub">Dê uma instrução no terminal do PrimoCode e assista ele trabalhando, ao vivo.<br>Cada app que ele abrir vira uma aba lá em cima — clique nelas para rever o que já foi feito.</div>
501
- </div>
502
- <div id="badge"><span class="rec"></span>AO VIVO</div>
503
- <div id="carimbo"></div>
504
- </section>
505
- </div>
506
-
507
- <div id="historico">
508
- <div id="filme"></div>
509
- <div id="cartao">
510
- <header><span>Linha do tempo · <b id="abanome">Ao vivo</b></span><span id="resumo"></span></header>
511
- <div id="passos"></div>
512
- </div>
513
- </div>
514
- <footer>só nesta máquina <b>(127.0.0.1)</b> · <b>/streaming</b> desliga</footer>
515
-
516
- <script>
517
- const VIVO = 0; // id da aba "Ao vivo"
518
- let dados = { abas: [], frames: [], log: [], instrucoes: [], liveSeq: 0 };
519
- let abaSel = VIVO; // aba escolhida pelo usuário
520
- let fotoSel = null; // frame fixado (null = vídeo/mais recente)
521
- let ultimoEstado = '';
522
-
523
- function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
524
-
525
- // ── o vídeo: busca o quadro direto, desde o primeiro segundo ──
526
- // Não espera o /state confirmar nada: enquanto não há seq, tenta com
527
- // timestamp (um 404 só significa "primeiro quadro ainda não saiu" — tenta de
528
- // novo). Assim a tela aparece assim que o painel abre, sem depender de o
529
- // agente ter feito qualquer coisa.
530
- let quadroNaTela = '';
531
- let buscandoQuadro = false;
532
- function modoVideo() { return abaSel === VIVO && fotoSel == null; }
533
- function videoTick() {
534
- if (modoVideo() && !buscandoQuadro) {
535
- const marca = dados.liveSeq > 0 ? 's' + dados.liveSeq : 't' + Date.now();
536
- if (marca !== quadroNaTela) {
537
- buscandoQuadro = true;
538
- const pre = new Image(); // pré-carrega fora da tela:
539
- pre.onload = () => { // o quadro só entra inteiro
540
- buscandoQuadro = false;
541
- if (!modoVideo()) return;
542
- const img = document.getElementById('frame');
543
- img.src = pre.src; img.style.display = 'block';
544
- document.getElementById('aviso').style.display = 'none';
545
- document.getElementById('badge').style.display = 'flex';
546
- document.getElementById('carimbo').style.display = 'none';
547
- quadroNaTela = marca;
548
- };
549
- pre.onerror = () => { buscandoQuadro = false; };
550
- pre.src = '/live?' + marca;
551
- }
552
- }
553
- setTimeout(videoTick, 300);
554
- }
555
-
556
- // ── a instrução autopreenchendo, letra por letra ──
557
- let digitando = null, instrucaoNaTela = 0;
558
- function autopreencher(inst) {
559
- if (!inst || inst.n === instrucaoNaTela) return;
560
- instrucaoNaTela = inst.n;
561
- clearInterval(digitando);
562
- const el = document.getElementById('cmdtexto');
563
- el.classList.remove('vazio');
564
- el.textContent = '';
565
- document.getElementById('cmdhora').textContent = inst.hora;
566
- let i = 0;
567
- digitando = setInterval(() => {
568
- i += 1 + Math.floor(Math.random() * 2); // cadência de gente digitando
569
- el.textContent = inst.texto.slice(0, i);
570
- if (i >= inst.texto.length) clearInterval(digitando);
571
- }, 32);
572
- }
573
-
574
- function framesDaAba(id) {
575
- return id === VIVO ? dados.frames : dados.frames.filter((f) => f.aba === id);
576
- }
577
- function logDaAba(id) {
578
- return id === VIVO ? dados.log : dados.log.filter((e) => e.aba === id || e.tipo === 'instrucao');
579
- }
580
-
581
- function render() {
582
- // Abas: "Ao vivo" + tudo que ele abriu, separadas por |
583
- const nav = document.getElementById('abas');
584
- const abas = [{ id: VIVO, nome: 'Ao vivo' }, ...dados.abas];
585
- nav.innerHTML = abas.map((a) =>
586
- '<button class="aba' + (a.id === abaSel ? ' ativa' : '') + '" data-id="' + a.id + '">' + esc(a.nome)
587
- + (a.passos ? '<span class="qtd">' + a.passos + '</span>' : '') + '</button>'
588
- ).join('<span class="sep">|</span>');
589
- nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => {
590
- abaSel = Number(b.dataset.id); fotoSel = null; quadroNaTela = -1; render();
591
- });
592
- const abaObj = abas.find((a) => a.id === abaSel) || abas[0];
593
- document.getElementById('abanome').textContent = abaObj.nome;
594
-
595
- // Fora do modo vídeo (aba antiga ou foto fixada): mostra o quadro daquela aba
596
- const fs = framesDaAba(abaSel);
597
- const img = document.getElementById('frame');
598
- const aviso = document.getElementById('aviso');
599
- const badge = document.getElementById('badge');
600
- const carimbo = document.getElementById('carimbo');
601
- if (!modoVideo()) {
602
- const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
603
- badge.style.display = 'none';
604
- if (atual) {
605
- if (img.dataset.n !== 'f' + atual.id) { img.src = '/frame?n=' + atual.id; img.dataset.n = 'f' + atual.id; }
606
- img.style.display = 'block'; aviso.style.display = 'none';
607
- carimbo.style.display = 'block';
608
- carimbo.innerHTML = '<b>#' + atual.id + '</b> · ' + atual.hora + ' · ' + esc(atual.acao)
609
- + (fotoSel != null ? ' — <b>clique na miniatura de novo para voltar</b>' : '');
610
- } else {
611
- img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
612
- }
613
- } else {
614
- carimbo.style.display = 'none';
615
- if (dados.erroFoto) { aviso.style.display = 'block'; aviso.querySelector('.sub').textContent = 'Sem imagem da tela: ' + dados.erroFoto; }
616
- }
617
-
618
- // Filmstrip da aba (as últimas 16)
619
- const filme = document.getElementById('filme');
620
- const tira = fs.slice(-16);
621
- const marcada = fotoSel != null ? fotoSel : -1;
622
- filme.innerHTML = tira.map((f) =>
623
- '<button class="foto' + (f.id === marcada ? ' ativa' : '') + '" data-id="' + f.id + '" title="' + esc(f.acao) + '"><img loading="lazy" src="/frame?n=' + f.id + '"></button>').join('');
624
- filme.querySelectorAll('.foto').forEach((b) => b.onclick = () => {
625
- const id = Number(b.dataset.id);
626
- fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao vídeo
627
- quadroNaTela = -1;
628
- render();
629
- });
630
- if (fotoSel == null) filme.scrollLeft = filme.scrollWidth;
631
-
632
- // Linha do tempo da aba (mais novos primeiro); pedidos viram divisórias
633
- const passos = logDaAba(abaSel).slice(-60).reverse();
634
- document.getElementById('resumo').textContent = passos.filter((e) => e.tipo !== 'instrucao').length + ' passos';
635
- document.getElementById('passos').innerHTML = passos.map((e) => {
636
- if (e.tipo === 'instrucao') {
637
- return '<div class="item pedido"><span class="n">' + e.n + '</span><span class="chev">❯</span>'
638
- + '<span class="txt">' + esc(e.acao) + '</span><span class="hora">' + e.hora + '</span></div>';
639
- }
640
- return '<div class="item' + (e.frame ? ' temfoto" data-frame="' + e.frame : '') + '">'
641
- + '<span class="n">' + e.n + '</span>'
642
- + '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
643
- + '<span class="txt">' + esc(e.acao) + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '') + '</span>'
644
- + '<span class="hora">' + e.hora + '</span></div>';
645
- }).join('');
646
- document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); quadroNaTela = -1; render(); });
647
- }
648
-
649
- async function tick() {
650
- try {
651
- const s = await (await fetch('/state')).json();
652
- dados = s;
653
- document.getElementById('st').textContent = 'ao vivo';
654
- autopreencher(s.instrucoes[s.instrucoes.length - 1]);
655
- const assinatura = JSON.stringify([s.frames.length, s.log.length, s.abas.length, s.erroFoto]);
656
- if (assinatura !== ultimoEstado) { ultimoEstado = assinatura; render(); }
657
- } catch (e) {
658
- document.getElementById('st').textContent = 'reconectando…';
659
- }
660
- setTimeout(tick, 600);
661
- }
662
- tick();
663
- videoTick();
664
- </script>
665
- </body></html>`;
666
-
667
-
668
- function start() {
669
- if (state.on) return Promise.resolve(state.url);
670
- try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
671
- // Fotos de sessões passadas não pertencem a esta linha do tempo.
672
- try { for (const f of fs.readdirSync(DIR)) if (/^(frame-\d+|live|live-tmp)\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
673
-
674
- return new Promise((resolve, reject) => {
675
- const server = http.createServer((req, res) => {
676
- const u = new URL(req.url || '/', 'http://x');
677
- if (u.pathname === '/state') {
678
- viuAgora(); // página aberta = vídeo ligado
679
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
680
- res.end(JSON.stringify({
681
- erroFoto: state.erroFoto,
682
- abas: state.abas,
683
- frames: state.frames,
684
- log: state.log.slice(-120),
685
- instrucoes: state.instrucoes.slice(-20),
686
- liveSeq: state.liveSeq,
687
- }));
688
- return;
689
- }
690
- if (u.pathname === '/live') {
691
- viuAgora();
692
- fs.readFile(LIVE(), (err, buf) => {
693
- if (err) { res.writeHead(404); res.end(); return; }
694
- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
695
- res.end(buf);
696
- });
697
- return;
698
- }
699
- if (u.pathname === '/frame') {
700
- // O id vem de fora: só serve o arquivo se for número — nada de
701
- // path traversal por aqui.
702
- const id = Math.round(Number(u.searchParams.get('n')));
703
- const conhecido = Number.isFinite(id) && state.frames.some((f) => f.id === id);
704
- if (!conhecido) { res.writeHead(404); res.end(); return; }
705
- fs.readFile(path.join(DIR, `frame-${id}.png`), (err, buf) => {
706
- if (err) { res.writeHead(404); res.end(); return; }
707
- // Cada frame é imutável: o navegador pode guardar à vontade.
708
- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=3600' });
709
- res.end(buf);
710
- });
711
- return;
712
- }
713
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
714
- res.end(PAGINA);
715
- });
716
-
717
- server.on('error', reject);
718
- // unref: o painel não pode segurar o processo vivo — /exit tem de sair
719
- // mesmo com o streaming ligado.
720
- server.on('connection', (s) => s.unref());
721
- // Só na própria máquina: isto é a tela do usuário, não um serviço.
722
- server.listen(0, '127.0.0.1', () => {
723
- server.unref();
724
- state.server = server;
725
- state.on = true;
726
- state.url = `http://127.0.0.1:${server.address().port}`;
727
- // O vídeo começa JÁ: quando o navegador terminar de abrir, o
728
- // primeiro quadro está no ar — nada de tela vazia esperando ação.
729
- state.viewerAt = Date.now();
730
- agendarVideo(50);
731
- armarFaxina();
732
- resolve(state.url);
733
- });
734
- });
735
- }
736
-
737
- function stop() {
738
- if (state.server) { try { state.server.close(); } catch {} }
739
- if (state.liveTimer) { clearTimeout(state.liveTimer); state.liveTimer = null; }
740
- state.server = null;
741
- state.on = false;
742
- state.url = '';
743
- limparDisco(); // desligou o painel, as fotos vão junto
744
- }
745
-
746
- // Fechou o PrimoCode (/exit, Ctrl+C, Ctrl+D): nada de print esquecido no
747
- // disco. `exit` cobre a saída normal; os sinais cobrem o resto.
748
- let faxinaArmada = false;
749
- function armarFaxina() {
750
- if (faxinaArmada) return;
751
- faxinaArmada = true;
752
- const faxina = () => { try { limparDisco(); } catch {} };
753
- process.once('exit', faxina);
754
- for (const sinal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
755
- process.once(sinal, () => { faxina(); process.exit(0); });
756
- }
757
- }
758
-
759
- module.exports = { start, stop, step, instrucao, isOn, url, descrever, limparDisco };