primocode 7.13.0 → 7.14.1

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
@@ -78,6 +78,53 @@ ${c.bold('Onde ele trabalha')}
78
78
  ${c.dim('Pasta de projeto nova só nasce quando ele vai mexer em arquivo.')}
79
79
  `;
80
80
 
81
+ // ── Autopreenchimento dos comandos (estilo Claude Code) ────────────────
82
+ // Metade do comando já serve: Tab completa, e um prefixo único executa
83
+ // direto — "/str" vira "/streaming" sozinho. A lista é só a dos nomes
84
+ // canônicos; os apelidos (/cd, /stream…) continuam valendo por extenso.
85
+ const SLASH_COMANDOS = [
86
+ ['/help', 'todos os comandos'],
87
+ ['/project', 'lista/cria projetos em ~/primocode'],
88
+ ['/dir', 'trabalhar numa pasta sua'],
89
+ ['/mode', 'primocode | chat | auto'],
90
+ ['/model', 'top | main | fast'],
91
+ ['/attach', 'anexa arquivo ao pedido'],
92
+ ['/detach', 'remove os anexos'],
93
+ ['/files', 'lista os anexos'],
94
+ ['/tudo', 'tudo que ele sabe fazer'],
95
+ ['/tree', 'árvore do projeto'],
96
+ ['/read', 'mostra um arquivo'],
97
+ ['/run', 'roda um comando'],
98
+ ['/open', 'abre no navegador'],
99
+ ['/streaming', 'painel ao vivo — vídeo dele trabalhando'],
100
+ ['/status', 'checa o servidor'],
101
+ ['/name', 'como ele te chama'],
102
+ ['/mascot', 'primo | fox | cat'],
103
+ ['/config', 'suas preferências'],
104
+ ['/clear', 'limpa a conversa'],
105
+ ['/cls', 'limpa a tela'],
106
+ ['/uninstall', 'remove o PrimoCode'],
107
+ ['/version', 'versão'],
108
+ ['/exit', 'sair'],
109
+ ];
110
+
111
+ // O completer do readline: Tab completa o que der. Só age na palavra do
112
+ // comando — depois do primeiro espaço o argumento é seu, não dele.
113
+ function completarSlash(line) {
114
+ if (!line.startsWith('/') || /\s/.test(line)) return [[], line];
115
+ const baixa = line.toLowerCase();
116
+ const acertos = SLASH_COMANDOS.map(([nome]) => nome).filter((nome) => nome.startsWith(baixa));
117
+ return [acertos, line];
118
+ }
119
+
120
+ function mostrarMenuSlash() {
121
+ console.log('');
122
+ for (const [nome, desc] of SLASH_COMANDOS) {
123
+ console.log(` ${c.brand(ui.padRight(nome, 12))}${c.muted(desc)}`);
124
+ }
125
+ console.log(c.dim('\n Tab completa · metade do comando já serve: /str vira /streaming'));
126
+ }
127
+
81
128
  let rl = null;
82
129
 
83
130
  function makePrompt() {
@@ -605,7 +652,26 @@ async function handleSlash(text) {
605
652
  // rl.close() em vez de process.exit(): sair na marra descarta o que
606
653
  // ainda estava no buffer de saída.
607
654
  case 'exit': case 'quit': if (rl) rl.close(); break;
608
- default: console.log(c.err(`Comando desconhecido: /${cmd}. Digite /help.`));
655
+ default: {
656
+ // "/" sozinho abre o menu; um prefixo único autopreenche e executa
657
+ // ("/str" → /streaming); prefixo ambíguo mostra as opções.
658
+ if (!cmd) { mostrarMenuSlash(); break; }
659
+ const casam = SLASH_COMANDOS.map(([nome]) => nome.slice(1)).filter((n) => n.startsWith(cmd.toLowerCase()));
660
+ if (casam.length === 1) {
661
+ console.log(c.dim(` → /${casam[0]}`));
662
+ await handleSlash('/' + casam[0] + (rest ? ' ' + rest : ''));
663
+ break;
664
+ }
665
+ if (casam.length > 1) {
666
+ console.log(c.muted(` /${cmd}… pode ser:`));
667
+ for (const n of casam) {
668
+ const [, desc] = SLASH_COMANDOS.find(([nome]) => nome === '/' + n);
669
+ console.log(` ${c.brand(ui.padRight('/' + n, 12))}${c.dim(desc)}`);
670
+ }
671
+ break;
672
+ }
673
+ console.log(c.err(`Comando desconhecido: /${cmd}. Digite / para ver o menu.`));
674
+ }
609
675
  }
610
676
  }
611
677
 
@@ -682,7 +748,7 @@ async function main() {
682
748
  userName: state.config.userName,
683
749
  pastaPadrao: state.workDirOrigem === 'padrao',
684
750
  }));
685
- rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: makePrompt() });
751
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: makePrompt(), completer: completarSlash });
686
752
  rl.prompt();
687
753
 
688
754
  // Fila: o readline continua emitindo 'line' enquanto um pedido está sendo
@@ -692,6 +758,38 @@ async function main() {
692
758
  let closed = false;
693
759
  let saidBye = false;
694
760
 
761
+ // ── Sugestão fantasma: o autopreenchimento VISÍVEL enquanto digita ──
762
+ // "/str" mostra "eaming" esmaecido na frente do cursor, como o Claude
763
+ // Code. Seta → (ou Tab) aceita; Enter com o pedaço já executa. Desenhada
764
+ // DEPOIS de o readline processar a tecla (setImmediate), por cima do que
765
+ // sobrou da linha (\\x1b[0K limpa), e o cursor volta para o lugar.
766
+ if (process.stdin.isTTY && process.stdout.isTTY) {
767
+ let fantasma = '';
768
+ const desenharFantasma = () => {
769
+ if (busy || pendingAsk || !rl) return;
770
+ const linha = rl.line;
771
+ const noFim = rl.cursor === linha.length;
772
+ fantasma = '';
773
+ if (noFim && linha.startsWith('/') && !/\s/.test(linha) && linha.length > 1) {
774
+ const baixa = linha.toLowerCase();
775
+ const hit = SLASH_COMANDOS.map(([n]) => n).find((n) => n.startsWith(baixa) && n.length > linha.length);
776
+ if (hit) fantasma = hit.slice(linha.length);
777
+ }
778
+ if (!noFim) return; // cursor no meio: não mexe no que vem depois dele
779
+ // Limpa resto da linha (fantasma antigo) e desenha o novo, voltando o cursor.
780
+ process.stdout.write('\x1b[0K' + (fantasma ? `\x1b[2m${fantasma}\x1b[0m\x1b[${fantasma.length}D` : ''));
781
+ };
782
+ process.stdin.on('keypress', (ch, key) => {
783
+ // Seta → no fim da linha aceita a sugestão inteira.
784
+ if (key && key.name === 'right' && fantasma && rl && rl.cursor === rl.line.length) {
785
+ const resto = fantasma;
786
+ fantasma = '';
787
+ rl.write(resto);
788
+ }
789
+ setImmediate(desenharFantasma);
790
+ });
791
+ }
792
+
695
793
  function sayBye() {
696
794
  if (saidBye) return;
697
795
  saidBye = true;
package/lib/desktop.js CHANGED
@@ -659,25 +659,40 @@ function clickElement(nome, opts = {}) {
659
659
  }
660
660
 
661
661
  // ── Screenshot ────────────────────────────────────────
662
- function screenshot(saveTo) {
663
- const filename = saveTo || `desktop-${Date.now()}.png`;
664
- const done = (r) => (r.ok ? { ok: true, path: filename, bytes: sizeOf(filename) } : r);
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.
664
+ let LINUX_SHOT;
665
+ function linuxShot() {
666
+ if (LINUX_SHOT === undefined) LINUX_SHOT = firstCmd(['scrot', 'maim', 'gnome-screenshot', 'spectacle', 'import']);
667
+ return LINUX_SHOT;
668
+ }
665
669
 
666
- if (PLATFORM === 'darwin') {
667
- return done(run(`screencapture -x "${filename}"`));
668
- }
670
+ /**
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.
674
+ */
675
+ function screenshotCommand(filename) {
676
+ if (PLATFORM === 'darwin') return `screencapture -x "${filename}"`;
669
677
  if (PLATFORM === 'win32') {
670
678
  const ps = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$bmp.Size); $bmp.Save('${filename.replace(/'/g, "''")}'); $g.Dispose(); $bmp.Dispose()`;
671
- return done(pwshRun(ps));
679
+ return pwshCmd(ps);
672
680
  }
673
- // Linux: tenta as ferramentas comuns, na ordem.
674
- const scrot = firstCmd(['scrot', 'gnome-screenshot', 'spectacle', 'import', 'maim']);
675
- if (scrot === 'scrot') return done(run(`scrot -o "${filename}"`));
676
- if (scrot === 'maim') return done(run(`maim "${filename}"`));
677
- if (scrot === 'gnome-screenshot') return done(run(`gnome-screenshot -f "${filename}"`));
678
- if (scrot === 'spectacle') return done(run(`spectacle -b -n -o "${filename}"`));
679
- if (scrot === 'import') return done(run(`import -window root "${filename}"`)); // ImageMagick
680
- return { ok: false, error: 'Screenshot indisponível no Linux. Instale scrot, gnome-screenshot, spectacle, maim ou ImageMagick (import).' };
681
+ const tool = linuxShot();
682
+ if (tool === 'scrot') return `scrot -o "${filename}"`;
683
+ if (tool === 'maim') return `maim "${filename}"`;
684
+ if (tool === 'gnome-screenshot') return `gnome-screenshot -f "${filename}"`;
685
+ if (tool === 'spectacle') return `spectacle -b -n -o "${filename}"`;
686
+ if (tool === 'import') return `import -window root "${filename}"`; // ImageMagick
687
+ return null;
688
+ }
689
+
690
+ function screenshot(saveTo) {
691
+ const filename = saveTo || `desktop-${Date.now()}.png`;
692
+ const cmd = screenshotCommand(filename);
693
+ if (!cmd) return { ok: false, error: 'Screenshot indisponível no Linux. Instale scrot, maim, gnome-screenshot, spectacle ou ImageMagick (import).' };
694
+ const r = run(cmd);
695
+ return r.ok ? { ok: true, path: filename, bytes: sizeOf(filename) } : r;
681
696
  }
682
697
 
683
698
  function sizeOf(f) {
@@ -719,7 +734,7 @@ module.exports = {
719
734
  click, doubleClick, move, drag, scroll,
720
735
  type, key,
721
736
  openApp, listWindows, focusWindow, uiElements, clickElement,
722
- screenshot, getScreenSize,
737
+ screenshot, screenshotCommand, getScreenSize,
723
738
  TOOLS,
724
739
  // Puras, exportadas para o test/desktop.test.js provar a tradução de teclas
725
740
  // e a escolha de elemento sem depender do SO da máquina de teste.
package/lib/streaming.js CHANGED
@@ -19,6 +19,7 @@ const http = require('http');
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
  const os = require('os');
22
+ const { exec } = require('child_process');
22
23
  const desktop = require('./desktop');
23
24
 
24
25
  const DIR = path.join(os.homedir(), '.primocode', 'streaming');
@@ -44,8 +45,68 @@ const state = {
44
45
  erroFoto: null,
45
46
  ultimaFoto: 0,
46
47
  passo: 0,
48
+ // ── vídeo ao vivo ──
49
+ liveSeq: 0, // sobe a cada frame novo; a página troca a imagem quando muda
50
+ liveAt: 0, // quando o último frame de vídeo foi capturado
51
+ viewerAt: 0, // último sinal de que alguém está com a página aberta
52
+ liveTimer: null,
53
+ liveBusy: false, // uma captura em voo por vez — sem isso cada poll da página
54
+ // abria OUTRA cadeia de captura e elas se multiplicavam
47
55
  };
48
56
 
57
+ const LIVE = () => path.join(DIR, 'live.png');
58
+ const LIVE_TMP = () => path.join(DIR, 'live-tmp.png');
59
+
60
+ // ── O vídeo: um loop de captura enquanto alguém assiste ──────────────────
61
+ // "Vídeo" aqui é o jeito honesto de fazer sem dependência: fotografar a tela
62
+ // em sequência (2 a 3 quadros por segundo, conforme a máquina aguenta) e a
63
+ // página trocar a imagem no ritmo — como um VNC simples. O loop é assíncrono
64
+ // (exec, nunca execSync) para não roubar tempo do agente, escreve num tmp e
65
+ // renomeia (troca atômica: o navegador nunca lê um PNG pela metade), e SÓ
66
+ // roda enquanto a página deu sinal de vida há menos de 8s — painel fechado
67
+ // não custa nada.
68
+ function viuAgora() {
69
+ state.viewerAt = Date.now();
70
+ agendarVideo(0);
71
+ }
72
+
73
+ function agendarVideo(ms) {
74
+ if (!state.on || state.liveTimer || state.liveBusy) return;
75
+ state.liveTimer = setTimeout(cicloVideo, ms);
76
+ state.liveTimer.unref();
77
+ }
78
+
79
+ function cicloVideo() {
80
+ state.liveTimer = null;
81
+ if (!state.on || state.liveBusy) return;
82
+ if (Date.now() - state.viewerAt > 8000) return; // ninguém olhando — o próximo /state religa
83
+
84
+ const cmd = desktop.screenshotCommand(LIVE_TMP());
85
+ if (!cmd) { if (!state.erroFoto) state.erroFoto = 'screenshot indisponível nesta máquina'; return; }
86
+
87
+ const t0 = Date.now();
88
+ state.liveBusy = true;
89
+ exec(cmd, { timeout: 8000 }, (err) => {
90
+ state.liveBusy = false;
91
+ const dur = Date.now() - t0;
92
+ if (!err) {
93
+ try {
94
+ // Rename é atômico; no Windows ele pode falhar se o navegador
95
+ // estiver lendo o arquivo NAQUELE instante — aí copia por cima.
96
+ try { fs.renameSync(LIVE_TMP(), LIVE()); }
97
+ catch { fs.copyFileSync(LIVE_TMP(), LIVE()); try { fs.unlinkSync(LIVE_TMP()); } catch {} }
98
+ state.liveSeq++;
99
+ state.liveAt = Date.now();
100
+ state.erroFoto = null;
101
+ } catch {}
102
+ } else if (!state.erroFoto) {
103
+ state.erroFoto = corta(String(err.message || err), 120);
104
+ }
105
+ // Máquina rápida ≈ 3 quadros/s; máquina lenta desacelera sozinha.
106
+ agendarVideo(Math.max(280, Math.min(1200, dur)));
107
+ });
108
+ }
109
+
49
110
  // Ações cujo efeito aparece NA TELA — são as que merecem uma foto nova.
50
111
  const ACOES_NA_TELA = new Set([
51
112
  'desktop_click', 'desktop_double_click', 'desktop_click_element',
@@ -179,6 +240,20 @@ function capturar(entrada) {
179
240
 
180
241
  const id = state.proximoFrame;
181
242
  const arquivo = path.join(DIR, `frame-${id}.png`);
243
+
244
+ // Com o vídeo rodando, o quadro mais novo já está no disco — copiar é
245
+ // grátis e não fotografa duas vezes a mesma tela.
246
+ if (agora - state.liveAt < 900) {
247
+ try { fs.copyFileSync(LIVE(), arquivo); } catch { return null; }
248
+ state.proximoFrame++;
249
+ state.frames.push({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba });
250
+ while (state.frames.length > MAX_FRAMES) {
251
+ const velho = state.frames.shift();
252
+ try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
253
+ }
254
+ return id;
255
+ }
256
+
182
257
  const r = desktop.screenshot(arquivo);
183
258
  if (!r.ok) { if (!state.erroFoto) state.erroFoto = r.error; return null; }
184
259
 
@@ -205,13 +280,13 @@ const MASCOTE_SVG = `<svg viewBox="0 0 9 3.6" aria-hidden="true">
205
280
  <g fill="#0b1220"><rect x="3" y=".35" width=".8" height=".5" rx=".1"/><rect x="5.2" y=".35" width=".8" height=".5" rx=".1"/><rect x="3.9" y="1.25" width="1.2" height=".28" rx=".14"/></g>
206
281
  </svg>`;
207
282
 
208
- // A tela finalizada, a partir do desenho do dono: fundo grafite, mascote
209
- // inclinado, "Primocode" branco no alto; a janela de borda branca grossa com
210
- // as três bolinhas e as abas separadas por "|"; e, novidade combinada, a
211
- // BARRA DE COMANDO — a instrução que você deu no terminal aparece ali,
212
- // autopreenchendo letra por letra, com cursor piscando, enquanto o agente
213
- // executa. Abaixo da moldura, a linha do tempo: miniaturas para voltar em
214
- // qualquer passo e a lista de ações, com cada pedido marcando onde começou.
283
+ // A tela do painel, na identidade do app — a MESMA paleta do design system do
284
+ // CLI (lib/ui.js): primária cyan #22d3ee, neutros slate, rose para falha. A
285
+ // estrutura é a do desenho do dono: mascote inclinado no canto, "Primocode"
286
+ // no alto, a janela com as bolinhas e as abas separadas por "|", a tela
287
+ // embaixo. Dentro da tela roda o VÍDEO ao vivo (o loop de captura acima); as
288
+ // abas em cima são tudo que o agente abriu — clicáveis, cada uma com seus
289
+ // quadros e passos; e a barra de comando autopreenche a instrução digitando.
215
290
  const FAVICON = 'data:image/svg+xml,' + encodeURIComponent(
216
291
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 3.6">${MASCOTE_SVG.replace(/^<svg[^>]*>|<\/svg>$/g, '')}</svg>`,
217
292
  );
@@ -223,94 +298,97 @@ const PAGINA = `<!doctype html>
223
298
  <link rel="icon" href="${FAVICON}">
224
299
  <style>
225
300
  * { box-sizing: border-box; margin: 0; }
226
- ::selection { background: rgba(255,255,255,.2); }
301
+ ::selection { background: rgba(34,211,238,.28); }
227
302
  body {
228
- background: radial-gradient(1200px 500px at 50% -100px, #2e2e2e 0%, #262626 55%, #1f1f1f 100%) fixed #262626;
229
- color: #fff; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
303
+ background: radial-gradient(1100px 480px at 50% -120px, #12263f 0%, #0d1729 46%, #0b1220 100%) fixed #0b1220;
304
+ color: #e2e8f0; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
230
305
  min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 0 18px 34px;
231
306
  }
232
307
 
233
- /* ── Cabeçalho ── */
234
- #topo { position: relative; width: min(1120px, 100%); padding: 46px 0 28px; text-align: center; }
235
- #topo .mascote { position: absolute; left: 4px; top: 28px; width: 100px; height: 42px; transform: rotate(-32deg); filter: drop-shadow(0 6px 14px rgba(34,211,238,.35)); animation: flutua 3.2s ease-in-out infinite alternate; }
308
+ /* ── Cabeçalho: mascote inclinado + nome, como no desenho ── */
309
+ #topo { position: relative; width: min(1120px, 100%); padding: 44px 0 26px; text-align: center; }
310
+ #topo .mascote { position: absolute; left: 4px; top: 26px; width: 100px; height: 42px; transform: rotate(-32deg); filter: drop-shadow(0 8px 20px rgba(34,211,238,.4)); animation: flutua 3.2s ease-in-out infinite alternate; }
236
311
  @keyframes flutua { from { translate: 0 0 } to { translate: 0 5px } }
237
312
  #topo .mascote svg { width: 100%; height: 100%; display: block; }
238
- #topo h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 650; letter-spacing: .5px; text-shadow: 0 3px 22px rgba(0,0,0,.55); }
239
- #topo .vivo { position: absolute; right: 2px; top: 50px; display: inline-flex; align-items: center; gap: 8px; color: #a9a9a9; font-size: 12.5px; background: #1c1c1cd9; border: 1px solid #363636; border-radius: 999px; padding: 6px 14px; }
240
- .dot { width: 9px; height: 9px; border-radius: 50%; background: #46d160; box-shadow: 0 0 8px #46d16088; animation: pulsa 1.4s infinite; }
313
+ #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); }
314
+ #topo h1 span { color: #22d3ee; }
315
+ #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; }
316
+ .pulso { width: 8px; height: 8px; border-radius: 50%; background: #22d3ee; box-shadow: 0 0 10px #22d3ee99; animation: pulsa 1.4s infinite; }
241
317
  @keyframes pulsa { 50% { opacity: .3 } }
242
318
 
243
319
  /* ── A janela ── */
244
320
  #janela {
245
- width: min(1120px, 100%); background: #000; border: 9px solid #f5f5f5; border-radius: 44px;
321
+ width: min(1120px, 100%); background: #000; border: 1px solid #24344d; border-radius: 22px;
246
322
  overflow: hidden; display: flex; flex-direction: column;
247
- box-shadow: 0 34px 90px rgba(0,0,0,.65), 0 0 0 1px rgba(255,255,255,.06), 0 10px 34px rgba(0,0,0,.5);
323
+ 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);
248
324
  }
249
- #barra { position: relative; display: flex; align-items: center; background: linear-gradient(#262626, #212121); padding: 15px 28px; min-height: 60px; border-bottom: 1px solid #0c0c0c; }
250
- #barra .b { flex: none; margin-right: 12px; box-shadow: inset 0 -2px 4px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.5); }
251
- #barra .b.r { width: 17px; height: 17px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #ff5a5a, #d40000); }
252
- #barra .b.y { width: 30px; height: 17px; border-radius: 999px; background: radial-gradient(circle at 35% 30%, #ffe95c, #f5c400); }
253
- #barra .b.g { width: 24px; height: 14px; border-radius: 999px; background: radial-gradient(circle at 35% 30%, #6fe589, #24b53e); }
325
+ #barra { position: relative; display: flex; align-items: center; background: linear-gradient(#152238, #101a2c); padding: 13px 22px; min-height: 54px; border-bottom: 1px solid #0a1120; }
326
+ #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); }
327
+ #barra .b.r { background: #ff5f57; } #barra .b.y { background: #febc2e; } #barra .b.g { background: #28c840; }
254
328
 
255
- #abas { position: absolute; left: 50%; transform: translateX(-50%); max-width: 60%; display: flex; align-items: center; gap: 13px; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font-size: clamp(15px, 2.2vw, 22px); font-weight: 550; }
329
+ /* Abas: tudo que ele abriu, clicável, separado por | como no desenho */
330
+ #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; }
256
331
  #abas::-webkit-scrollbar { display: none; }
257
- .aba { border: 0; background: none; cursor: pointer; font: inherit; color: rgba(255,255,255,.45); padding: 3px 2px; transition: color .18s ease, text-shadow .18s ease; }
258
- .aba:hover { color: rgba(255,255,255,.85); }
259
- .aba.ativa { color: #fff; text-shadow: 0 0 16px rgba(255,255,255,.45); }
260
- .aba .qtd { color: rgba(255,255,255,.35); font-size: .62em; vertical-align: super; margin-left: 4px; font-weight: 600; }
261
- .sep { color: rgba(255,255,255,.28); font-weight: 300; }
332
+ .aba { position: relative; border: 0; background: none; cursor: pointer; font: inherit; color: #64748b; padding: 4px 3px 7px; transition: color .18s ease; }
333
+ .aba:hover { color: #cbd5e1; }
334
+ .aba.ativa { color: #22d3ee; text-shadow: 0 0 18px rgba(34,211,238,.5); }
335
+ .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; }
336
+ .aba .qtd { color: #475569; font-size: .68em; vertical-align: super; margin-left: 4px; font-weight: 600; }
337
+ .sep { color: #334155; font-weight: 300; }
262
338
 
263
339
  /* ── Barra de comando: a instrução autopreenchendo ── */
264
- #comando { display: flex; align-items: center; gap: 12px; background: #141414; border-bottom: 1px solid #0a0a0a; padding: 12px 26px; font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: 14.5px; min-height: 46px; }
265
- #comando .chevron { color: #46d160; font-weight: 700; flex: none; }
266
- #comando .texto { color: #eaeaea; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
267
- #comando .texto.vazio { color: #5c5c5c; font-style: italic; }
268
- #comando .cursor { flex: none; width: 8px; height: 17px; background: #eaeaea; margin-left: 2px; animation: pisca 1s steps(1) infinite; }
340
+ #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; }
341
+ #comando .chevron { color: #22d3ee; font-weight: 700; flex: none; text-shadow: 0 0 10px rgba(34,211,238,.6); }
342
+ #comando .texto { color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
343
+ #comando .texto.vazio { color: #475569; font-style: italic; }
344
+ #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); }
269
345
  @keyframes pisca { 50% { opacity: 0 } }
270
- #comando .hora { margin-left: auto; color: #5c5c5c; font-size: 11.5px; flex: none; }
271
-
272
- /* ── A tela ── */
273
- #tela { position: relative; min-height: 460px; display: flex; align-items: center; justify-content: center; background: #000; }
274
- #tela img { width: 100%; max-height: 62vh; object-fit: contain; display: none; opacity: 0; transition: opacity .28s ease; }
275
- #aviso { color: #fff; text-align: center; padding: 90px 24px; }
276
- #aviso .titulo { font-size: clamp(20px, 3.2vw, 32px); font-weight: 550; }
277
- #aviso .sub { color: #8f8f8f; font-size: 14.5px; margin-top: 14px; line-height: 1.7; }
278
- #carimbo { position: absolute; left: 18px; bottom: 14px; background: rgba(10,10,10,.72); backdrop-filter: blur(6px); border: 1px solid #2e2e2e; color: #c9c9c9; font-size: 12px; padding: 5px 13px; border-radius: 999px; display: none; max-width: 85%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
279
- #carimbo b { color: #fff; font-weight: 600; }
346
+ #comando .hora { margin-left: auto; color: #475569; font-size: 11.5px; flex: none; }
347
+
348
+ /* ── A tela: o vídeo ao vivo ── */
349
+ #tela { position: relative; min-height: 440px; display: flex; align-items: center; justify-content: center; background: #000; }
350
+ #tela img { width: 100%; max-height: 60vh; object-fit: contain; display: none; }
351
+ #aviso { text-align: center; padding: 84px 24px; }
352
+ #aviso .titulo { font-size: clamp(19px, 3vw, 28px); font-weight: 550; color: #f1f5f9; }
353
+ #aviso .sub { color: #64748b; font-size: 14px; margin-top: 13px; line-height: 1.7; }
354
+ #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; }
355
+ #badge .rec { width: 8px; height: 8px; border-radius: 50%; background: #fb7185; box-shadow: 0 0 9px #fb7185; animation: pulsa 1.1s infinite; }
356
+ #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; }
357
+ #carimbo b { color: #22d3ee; font-weight: 600; }
280
358
 
281
359
  /* ── Linha do tempo ── */
282
- #historico { width: min(1120px, 100%); padding-top: 20px; }
283
- #filme { display: flex; gap: 10px; padding: 4px 2px 14px; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #3a3a3a transparent; }
284
- .foto { flex: none; border: 1.5px solid #3a3a3a; border-radius: 10px; background: #000; cursor: pointer; padding: 0; width: 104px; height: 62px; overflow: hidden; opacity: .65; transition: transform .16s ease, opacity .16s ease, border-color .16s ease, box-shadow .16s ease; }
285
- .foto:hover { opacity: 1; transform: translateY(-2px); border-color: #7a7a7a; }
360
+ #historico { width: min(1120px, 100%); padding-top: 18px; }
361
+ #filme { display: flex; gap: 9px; padding: 4px 2px 13px; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
362
+ .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; }
363
+ .foto:hover { opacity: 1; transform: translateY(-2px); border-color: #475569; }
286
364
  .foto img { width: 100%; height: 100%; object-fit: cover; display: block; }
287
- .foto.ativa { border-color: #fff; opacity: 1; box-shadow: 0 0 0 2px rgba(255,255,255,.25), 0 6px 18px rgba(0,0,0,.45); }
288
-
289
- #cartao { background: #1c1c1c; border: 1px solid #2c2c2c; border-radius: 18px; overflow: hidden; }
290
- #cartao header { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-bottom: 1px solid #272727; color: #bdbdbd; font-size: 13px; font-weight: 600; letter-spacing: .3px; }
291
- #cartao header span b { color: #fff; }
292
- #passos { max-height: 240px; overflow-y: auto; padding: 6px 0 10px; scrollbar-width: thin; scrollbar-color: #3a3a3a transparent; }
293
- #passos::-webkit-scrollbar { width: 8px; } #passos::-webkit-scrollbar-thumb { background: #3a3a3a; border-radius: 4px; }
294
- .item { padding: 5px 18px; display: flex; gap: 10px; font-size: 13.5px; align-items: baseline; transition: background .12s ease; }
295
- .item:hover { background: #222; }
296
- .item .n { color: #6b6b6b; min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
297
- .item .ok { color: #46d160; } .item .falha { color: #ff6b6b; }
298
- .item .txt { flex: 1; word-break: break-word; color: #dcdcdc; }
299
- .item .txt .erro { color: #ff8a8a; font-size: 12px; display: block; margin-top: 1px; }
300
- .item .hora { color: #6b6b6b; font-size: 11px; font-variant-numeric: tabular-nums; }
301
- .item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #fff; }
302
- .item.pedido { background: #191919; border-top: 1px solid #262626; border-bottom: 1px solid #262626; margin: 6px 0; padding: 8px 18px; }
303
- .item.pedido .txt { color: #fff; font-weight: 600; }
304
- .item.pedido .chev { color: #46d160; font-weight: 800; }
305
-
306
- footer { color: #6b6b6b; font-size: 12px; padding-top: 18px; }
307
- footer b { color: #9a9a9a; font-weight: 600; }
365
+ .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); }
366
+
367
+ #cartao { background: #0f172a; border: 1px solid #1e293b; border-radius: 16px; overflow: hidden; }
368
+ #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; }
369
+ #cartao header span b { color: #22d3ee; }
370
+ #passos { max-height: 236px; overflow-y: auto; padding: 5px 0 9px; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
371
+ #passos::-webkit-scrollbar { width: 8px; } #passos::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; }
372
+ .item { padding: 5px 17px; display: flex; gap: 10px; font-size: 13.5px; align-items: baseline; transition: background .12s ease; }
373
+ .item:hover { background: #131f35; }
374
+ .item .n { color: #475569; min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
375
+ .item .ok { color: #22d3ee; } .item .falha { color: #fb7185; }
376
+ .item .txt { flex: 1; word-break: break-word; color: #cbd5e1; }
377
+ .item .txt .erro { color: #fb7185; font-size: 12px; display: block; margin-top: 1px; }
378
+ .item .hora { color: #475569; font-size: 11px; font-variant-numeric: tabular-nums; }
379
+ .item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #f1f5f9; }
380
+ .item.pedido { background: #0c1526; border-top: 1px solid #16223a; border-bottom: 1px solid #16223a; margin: 6px 0; padding: 8px 17px; }
381
+ .item.pedido .txt { color: #f1f5f9; font-weight: 600; }
382
+ .item.pedido .chev { color: #22d3ee; font-weight: 800; }
383
+
384
+ footer { color: #475569; font-size: 12px; padding-top: 17px; }
385
+ footer b { color: #64748b; font-weight: 600; }
308
386
  </style></head>
309
387
  <body>
310
388
  <div id="topo">
311
389
  <div class="mascote">${MASCOTE_SVG}</div>
312
- <h1>Primocode</h1>
313
- <span class="vivo"><span class="dot"></span><span id="st">conectando…</span></span>
390
+ <h1>Primo<span>code</span></h1>
391
+ <span class="vivo"><span class="pulso"></span><span id="st">conectando…</span></span>
314
392
  </div>
315
393
 
316
394
  <div id="janela">
@@ -325,11 +403,12 @@ const PAGINA = `<!doctype html>
325
403
  <span class="hora" id="cmdhora"></span>
326
404
  </div>
327
405
  <section id="tela">
328
- <img id="frame" alt="tela que a IA está usando">
406
+ <img id="frame" alt="tela do computador, ao vivo">
329
407
  <div id="aviso">
330
- <div class="titulo">Tela que a IA está usando aparece aqui</div>
331
- <div class="sub">Dê uma instrução no terminal do PrimoCode e veja cada passo, ao vivo.<br>Cada app em que ele mexer vira uma aba — volte nelas para rever o que já foi feito.</div>
408
+ <div class="titulo">A tela do computador aparece aqui, em vídeo</div>
409
+ <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>
332
410
  </div>
411
+ <div id="badge"><span class="rec"></span>AO VIVO</div>
333
412
  <div id="carimbo"></div>
334
413
  </section>
335
414
  </div>
@@ -345,12 +424,45 @@ const PAGINA = `<!doctype html>
345
424
 
346
425
  <script>
347
426
  const VIVO = 0; // id da aba "Ao vivo"
348
- let dados = { abas: [], frames: [], log: [], instrucoes: [] };
427
+ let dados = { abas: [], frames: [], log: [], instrucoes: [], liveSeq: 0 };
349
428
  let abaSel = VIVO; // aba escolhida pelo usuário
350
- let fotoSel = null; // frame fixado no filmstrip (null = mais recente)
429
+ let fotoSel = null; // frame fixado (null = vídeo/mais recente)
351
430
  let ultimoEstado = '';
352
431
 
353
- // ── autopreencher a instrução, letra por letra ──
432
+ function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
433
+
434
+ // ── o vídeo: busca o quadro direto, desde o primeiro segundo ──
435
+ // Não espera o /state confirmar nada: enquanto não há seq, tenta com
436
+ // timestamp (um 404 só significa "primeiro quadro ainda não saiu" — tenta de
437
+ // novo). Assim a tela aparece assim que o painel abre, sem depender de o
438
+ // agente ter feito qualquer coisa.
439
+ let quadroNaTela = '';
440
+ let buscandoQuadro = false;
441
+ function modoVideo() { return abaSel === VIVO && fotoSel == null; }
442
+ function videoTick() {
443
+ if (modoVideo() && !buscandoQuadro) {
444
+ const marca = dados.liveSeq > 0 ? 's' + dados.liveSeq : 't' + Date.now();
445
+ if (marca !== quadroNaTela) {
446
+ buscandoQuadro = true;
447
+ const pre = new Image(); // pré-carrega fora da tela:
448
+ pre.onload = () => { // o quadro só entra inteiro
449
+ buscandoQuadro = false;
450
+ if (!modoVideo()) return;
451
+ const img = document.getElementById('frame');
452
+ img.src = pre.src; img.style.display = 'block';
453
+ document.getElementById('aviso').style.display = 'none';
454
+ document.getElementById('badge').style.display = 'flex';
455
+ document.getElementById('carimbo').style.display = 'none';
456
+ quadroNaTela = marca;
457
+ };
458
+ pre.onerror = () => { buscandoQuadro = false; };
459
+ pre.src = '/live?' + marca;
460
+ }
461
+ }
462
+ setTimeout(videoTick, 300);
463
+ }
464
+
465
+ // ── a instrução autopreenchendo, letra por letra ──
354
466
  let digitando = null, instrucaoNaTela = 0;
355
467
  function autopreencher(inst) {
356
468
  if (!inst || inst.n === instrucaoNaTela) return;
@@ -362,14 +474,12 @@ function autopreencher(inst) {
362
474
  document.getElementById('cmdhora').textContent = inst.hora;
363
475
  let i = 0;
364
476
  digitando = setInterval(() => {
365
- i += 1 + Math.floor(Math.random() * 2); // cadência de gente digitando
477
+ i += 1 + Math.floor(Math.random() * 2); // cadência de gente digitando
366
478
  el.textContent = inst.texto.slice(0, i);
367
479
  if (i >= inst.texto.length) clearInterval(digitando);
368
- }, 34);
480
+ }, 32);
369
481
  }
370
482
 
371
- function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
372
-
373
483
  function framesDaAba(id) {
374
484
  return id === VIVO ? dados.frames : dados.frames.filter((f) => f.aba === id);
375
485
  }
@@ -378,46 +488,52 @@ function logDaAba(id) {
378
488
  }
379
489
 
380
490
  function render() {
381
- // Abas: "Ao vivo" + uma por app/janela, separadas por |
491
+ // Abas: "Ao vivo" + tudo que ele abriu, separadas por |
382
492
  const nav = document.getElementById('abas');
383
493
  const abas = [{ id: VIVO, nome: 'Ao vivo' }, ...dados.abas];
384
494
  nav.innerHTML = abas.map((a) =>
385
495
  '<button class="aba' + (a.id === abaSel ? ' ativa' : '') + '" data-id="' + a.id + '">' + esc(a.nome)
386
496
  + (a.passos ? '<span class="qtd">' + a.passos + '</span>' : '') + '</button>'
387
497
  ).join('<span class="sep">|</span>');
388
- nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => { abaSel = Number(b.dataset.id); fotoSel = null; render(); });
498
+ nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => {
499
+ abaSel = Number(b.dataset.id); fotoSel = null; quadroNaTela = -1; render();
500
+ });
389
501
  const abaObj = abas.find((a) => a.id === abaSel) || abas[0];
390
502
  document.getElementById('abanome').textContent = abaObj.nome;
391
503
 
392
- // Imagem: a fixada no filmstrip, senão a mais nova da aba
504
+ // Fora do modo vídeo (aba antiga ou foto fixada): mostra o quadro daquela aba
393
505
  const fs = framesDaAba(abaSel);
394
- const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
395
506
  const img = document.getElementById('frame');
396
507
  const aviso = document.getElementById('aviso');
508
+ const badge = document.getElementById('badge');
397
509
  const carimbo = document.getElementById('carimbo');
398
- if (atual) {
399
- if (img.dataset.n !== String(atual.id)) {
400
- img.style.opacity = '0';
401
- img.onload = () => { img.style.opacity = '1'; };
402
- img.src = '/frame?n=' + atual.id;
403
- img.dataset.n = String(atual.id);
510
+ if (!modoVideo()) {
511
+ const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
512
+ badge.style.display = 'none';
513
+ if (atual) {
514
+ if (img.dataset.n !== 'f' + atual.id) { img.src = '/frame?n=' + atual.id; img.dataset.n = 'f' + atual.id; }
515
+ img.style.display = 'block'; aviso.style.display = 'none';
516
+ carimbo.style.display = 'block';
517
+ carimbo.innerHTML = '<b>#' + atual.id + '</b> · ' + atual.hora + ' · ' + esc(atual.acao)
518
+ + (fotoSel != null ? ' — <b>clique na miniatura de novo para voltar</b>' : '');
519
+ } else {
520
+ img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
404
521
  }
405
- img.style.display = 'block'; aviso.style.display = 'none';
406
- carimbo.style.display = 'block';
407
- carimbo.innerHTML = '<b>#' + atual.id + '</b> · ' + atual.hora + ' · ' + esc(atual.acao) + (fotoSel != null ? ' — <b>clique na miniatura de novo para voltar ao ao-vivo</b>' : '');
408
522
  } else {
409
- img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
410
- if (dados.erroFoto) aviso.querySelector('.sub').textContent = 'Sem foto da tela: ' + dados.erroFoto;
523
+ carimbo.style.display = 'none';
524
+ if (dados.erroFoto) { aviso.style.display = 'block'; aviso.querySelector('.sub').textContent = 'Sem imagem da tela: ' + dados.erroFoto; }
411
525
  }
412
526
 
413
527
  // Filmstrip da aba (as últimas 16)
414
528
  const filme = document.getElementById('filme');
415
529
  const tira = fs.slice(-16);
530
+ const marcada = fotoSel != null ? fotoSel : -1;
416
531
  filme.innerHTML = tira.map((f) =>
417
- '<button class="foto' + ((atual && f.id === atual.id) ? ' ativa' : '') + '" data-id="' + f.id + '" title="' + esc(f.acao) + '"><img loading="lazy" src="/frame?n=' + f.id + '"></button>').join('');
532
+ '<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('');
418
533
  filme.querySelectorAll('.foto').forEach((b) => b.onclick = () => {
419
534
  const id = Number(b.dataset.id);
420
- fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao "mais recente"
535
+ fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao vídeo
536
+ quadroNaTela = -1;
421
537
  render();
422
538
  });
423
539
  if (fotoSel == null) filme.scrollLeft = filme.scrollWidth;
@@ -436,7 +552,7 @@ function render() {
436
552
  + '<span class="txt">' + esc(e.acao) + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '') + '</span>'
437
553
  + '<span class="hora">' + e.hora + '</span></div>';
438
554
  }).join('');
439
- document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); render(); });
555
+ document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); quadroNaTela = -1; render(); });
440
556
  }
441
557
 
442
558
  async function tick() {
@@ -450,9 +566,10 @@ async function tick() {
450
566
  } catch (e) {
451
567
  document.getElementById('st').textContent = 'reconectando…';
452
568
  }
453
- setTimeout(tick, 700);
569
+ setTimeout(tick, 600);
454
570
  }
455
571
  tick();
572
+ videoTick();
456
573
  </script>
457
574
  </body></html>`;
458
575
 
@@ -461,12 +578,13 @@ function start() {
461
578
  if (state.on) return Promise.resolve(state.url);
462
579
  try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
463
580
  // Fotos de sessões passadas não pertencem a esta linha do tempo.
464
- try { for (const f of fs.readdirSync(DIR)) if (/^frame-\d+\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
581
+ try { for (const f of fs.readdirSync(DIR)) if (/^(frame-\d+|live|live-tmp)\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
465
582
 
466
583
  return new Promise((resolve, reject) => {
467
584
  const server = http.createServer((req, res) => {
468
585
  const u = new URL(req.url || '/', 'http://x');
469
586
  if (u.pathname === '/state') {
587
+ viuAgora(); // página aberta = vídeo ligado
470
588
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
471
589
  res.end(JSON.stringify({
472
590
  erroFoto: state.erroFoto,
@@ -474,9 +592,19 @@ function start() {
474
592
  frames: state.frames,
475
593
  log: state.log.slice(-120),
476
594
  instrucoes: state.instrucoes.slice(-20),
595
+ liveSeq: state.liveSeq,
477
596
  }));
478
597
  return;
479
598
  }
599
+ if (u.pathname === '/live') {
600
+ viuAgora();
601
+ fs.readFile(LIVE(), (err, buf) => {
602
+ if (err) { res.writeHead(404); res.end(); return; }
603
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
604
+ res.end(buf);
605
+ });
606
+ return;
607
+ }
480
608
  if (u.pathname === '/frame') {
481
609
  // O id vem de fora: só serve o arquivo se for número — nada de
482
610
  // path traversal por aqui.
@@ -505,6 +633,10 @@ function start() {
505
633
  state.server = server;
506
634
  state.on = true;
507
635
  state.url = `http://127.0.0.1:${server.address().port}`;
636
+ // O vídeo começa JÁ: quando o navegador terminar de abrir, o
637
+ // primeiro quadro está no ar — nada de tela vazia esperando ação.
638
+ state.viewerAt = Date.now();
639
+ agendarVideo(50);
508
640
  resolve(state.url);
509
641
  });
510
642
  });
@@ -512,6 +644,7 @@ function start() {
512
644
 
513
645
  function stop() {
514
646
  if (state.server) { try { state.server.close(); } catch {} }
647
+ if (state.liveTimer) { clearTimeout(state.liveTimer); state.liveTimer = null; }
515
648
  state.server = null;
516
649
  state.on = false;
517
650
  state.url = '';
package/lib/ui.js CHANGED
@@ -161,9 +161,9 @@ const TIPS = [
161
161
  ];
162
162
 
163
163
  const NEWS = [
164
- 'Agora ele INTERAGE com o computador: janelas, botões e ícones pelo nome',
165
- '/streaming abre um painel ao vivo — veja cada passo dele na tela',
166
- 'Dois provedores em corrente: acabou a cota de um, o outro atende',
164
+ '/streaming agora é VÍDEO ao vivo — assista ele trabalhando, aba por aba',
165
+ 'Comandos se autopreenchem: /str + Tab (ou Enter) vira /streaming',
166
+ 'Ele INTERAGE com o computador: janelas, botões e ícones pelo nome',
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.13.0",
3
+ "version": "7.14.1",
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": {