primocode 7.11.0 → 7.12.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.
Files changed (2) hide show
  1. package/lib/streaming.js +249 -71
  2. package/package.json +1 -1
package/lib/streaming.js CHANGED
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * O agente age no desktop do usuário (clica, digita, abre janela), mas rápido
5
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 mostra, no
7
- * navegador, cada ação em ordem + uma foto da tela tirada logo depois dela.
8
- * Você dá a instrução no terminal e VÊ o agente fazendo, passo a passo.
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.
9
11
  *
10
12
  * Zero dependências: http nativo, uma página HTML embutida que consulta
11
13
  * /state a cada 700ms. Só escuta em 127.0.0.1 — nada disso sai da máquina.
@@ -20,8 +22,11 @@ const os = require('os');
20
22
  const desktop = require('./desktop');
21
23
 
22
24
  const DIR = path.join(os.homedir(), '.primocode', 'streaming');
23
- const FRAME = path.join(DIR, 'frame.png');
24
- const MAX_LOG = 200;
25
+ const MAX_LOG = 300;
26
+ // Fotos guardadas em disco para o "voltar no tempo" das abas. Além disso a
27
+ // pasta viraria um álbum infinito — as mais velhas saem.
28
+ const MAX_FRAMES = 80;
29
+ const MAX_ABAS = 12;
25
30
  // Uma foto por ação no máximo a cada 600ms: digitar letra por letra não pode
26
31
  // virar uma rajada de screenshots.
27
32
  const INTERVALO_FOTO_MS = 600;
@@ -30,7 +35,10 @@ const state = {
30
35
  on: false,
31
36
  server: null,
32
37
  url: '',
33
- frame: 0, // contador: a página troca a imagem quando ele muda
38
+ frames: [], // [{ id, hora, acao, aba }] — id é o nome do arquivo
39
+ proximoFrame: 1,
40
+ abas: [], // [{ id, nome, passos: 0 }] — "Ao vivo" é virtual, não entra aqui
41
+ abaAtual: null,
34
42
  log: [],
35
43
  erroFoto: null,
36
44
  ultimaFoto: 0,
@@ -88,6 +96,33 @@ function descrever(name, args = {}) {
88
96
  }
89
97
  }
90
98
 
99
+ // ── Abas: uma por app/janela em que o agente mexeu ────────────────────────
100
+ // Abrir um app, focar uma janela ou abrir um site começa (ou reencontra) a
101
+ // aba daquele contexto. As ações seguintes caem nela — é o que deixa o
102
+ // "o que a IA já fez" navegável depois, em vez de um rolo único.
103
+
104
+ function nomeDeAba(name, args = {}) {
105
+ if (name === 'desktop_open_app') return String(args.app || args.name || '').trim();
106
+ if (name === 'desktop_focus') return String(args.window || args.title || '').trim();
107
+ if (name === 'browser_open') {
108
+ try { return new URL(String(args.url)).hostname || 'navegador'; } catch { return 'navegador'; }
109
+ }
110
+ if (args && args.window) return String(args.window).trim();
111
+ return '';
112
+ }
113
+
114
+ function entrarNaAba(nome) {
115
+ const alvo = corta(nome, 24);
116
+ if (!alvo) return;
117
+ let aba = state.abas.find((a) => a.nome.toLowerCase() === alvo.toLowerCase());
118
+ if (!aba) {
119
+ aba = { id: state.abas.length + 1, nome: alvo, passos: 0 };
120
+ state.abas.push(aba);
121
+ if (state.abas.length > MAX_ABAS) state.abas.shift();
122
+ }
123
+ state.abaAtual = aba;
124
+ }
125
+
91
126
  /**
92
127
  * Chamada pelo loop do agente depois de CADA tool. Quando o streaming está
93
128
  * desligado é um no-op — o caminho normal não paga nada por isto.
@@ -95,119 +130,262 @@ function descrever(name, args = {}) {
95
130
  function step(name, args, result) {
96
131
  if (!state.on) return;
97
132
 
133
+ const troca = nomeDeAba(name, args || {});
134
+ if (troca) entrarNaAba(troca);
135
+ if (!state.abaAtual && ACOES_NA_TELA.has(name)) entrarNaAba('Tela');
136
+
98
137
  state.passo++;
99
- state.log.push({
138
+ if (state.abaAtual) state.abaAtual.passos++;
139
+
140
+ const entrada = {
100
141
  n: state.passo,
101
142
  hora: new Date().toTimeString().slice(0, 8),
102
143
  acao: descrever(name, args || {}),
103
144
  ok: !(result && result.ok === false),
104
145
  erro: result && result.ok === false ? corta(result.error, 120) : undefined,
105
- });
106
- if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
146
+ aba: state.abaAtual ? state.abaAtual.id : null,
147
+ };
107
148
 
108
- if (ACOES_NA_TELA.has(name)) capturar();
149
+ if (ACOES_NA_TELA.has(name)) {
150
+ const frame = capturar(entrada);
151
+ if (frame) entrada.frame = frame;
152
+ }
153
+
154
+ state.log.push(entrada);
155
+ if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
109
156
  }
110
157
 
111
- function capturar() {
158
+ function capturar(entrada) {
112
159
  const agora = Date.now();
113
- if (agora - state.ultimaFoto < INTERVALO_FOTO_MS) return;
160
+ if (agora - state.ultimaFoto < INTERVALO_FOTO_MS) return null;
114
161
  state.ultimaFoto = agora;
115
- const r = desktop.screenshot(FRAME);
116
- if (r.ok) { state.frame++; state.erroFoto = null; }
117
- else if (!state.erroFoto) state.erroFoto = r.error;
162
+
163
+ const id = state.proximoFrame;
164
+ const arquivo = path.join(DIR, `frame-${id}.png`);
165
+ const r = desktop.screenshot(arquivo);
166
+ if (!r.ok) { if (!state.erroFoto) state.erroFoto = r.error; return null; }
167
+
168
+ state.erroFoto = null;
169
+ state.proximoFrame++;
170
+ state.frames.push({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba });
171
+ while (state.frames.length > MAX_FRAMES) {
172
+ const velho = state.frames.shift();
173
+ try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
174
+ }
175
+ return id;
118
176
  }
119
177
 
120
178
  // ── Página ────────────────────────────────────────────────────────────────
179
+ // O desenho combinado: mascote + PrimoCode no cabeçalho; abaixo, uma "janela"
180
+ // de borda branca com as três bolinhas e as ABAS; dentro, a tela que a IA
181
+ // está usando (ou usou, nas abas antigas), com os passos daquela aba embaixo.
182
+
183
+ // O mascote do CLI (▐▛███▜▌ …) redesenhado em SVG, célula a célula, na mesma
184
+ // paleta: bloco cheio na cor viva, contorno na apagada.
185
+ const MASCOTE_SVG = `<svg viewBox="0 0 9 3.6" aria-hidden="true">
186
+ <g fill="#0891b2"><rect x="1" y="0" width="1" height="1"/><rect x="7" y="0" width="1" height="1"/><rect x="0" y="1" width="1" height="1" opacity=".7"/><rect x="8" y="1" width="1" height="1" opacity=".7"/><rect x="1" y="1" width="1" height="1"/><rect x="7" y="1" width="1" height="1"/></g>
187
+ <g fill="#22d3ee"><rect x="2" y="0" width="5" height="1" rx=".12"/><rect x="2" y="1" width="5" height="1"/><rect x="1.9" y="2.2" width=".9" height=".5" rx=".1"/><rect x="3.4" y="2.2" width=".9" height=".5" rx=".1"/><rect x="4.9" y="2.2" width=".9" height=".5" rx=".1"/><rect x="6.4" y="2.2" width=".9" height=".5" rx=".1"/></g>
188
+ <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>
189
+ </svg>`;
190
+
191
+ // A página segue o desenho do dono, traço a traço: fundo grafite, mascote
192
+ // inclinado no canto, "Primocode" branco no alto; a janela de borda branca
193
+ // grossa e canto bem redondo; barra #232323 com as três bolinhas e as abas em
194
+ // texto branco centralizado, separadas por "|"; dentro, a tela preta com a
195
+ // imagem ao vivo. O histórico (miniaturas + passos) fica ABAIXO da janela,
196
+ // discreto — a moldura fica idêntica ao desenho.
121
197
  const PAGINA = `<!doctype html>
122
198
  <html lang="pt-BR"><head><meta charset="utf-8">
123
199
  <meta name="viewport" content="width=device-width, initial-scale=1">
124
200
  <title>PrimoCode · Ao vivo</title>
125
201
  <style>
126
202
  * { box-sizing: border-box; margin: 0; }
127
- body { background: #0b1220; color: #e2e8f0; font: 14px/1.5 ui-monospace, 'Cascadia Code', Consolas, monospace; height: 100vh; display: flex; flex-direction: column; }
128
- header { display: flex; align-items: center; gap: 10px; padding: 12px 18px; border-bottom: 1px solid #1e293b; }
129
- .dot { width: 9px; height: 9px; border-radius: 50%; background: #22d3ee; animation: pulsa 1.4s infinite; }
203
+ body { background: #262626; color: #fff; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 0 16px 30px; }
204
+
205
+ /* Cabeçalho: mascote inclinado no canto, nome centrado */
206
+ #topo { position: relative; width: min(1100px, 100%); padding: 44px 0 30px; text-align: center; }
207
+ #topo .mascote { position: absolute; left: 6px; top: 30px; width: 96px; height: 40px; transform: rotate(-32deg); }
208
+ #topo .mascote svg { width: 100%; height: 100%; display: block; }
209
+ #topo h1 { font-size: clamp(34px, 6vw, 58px); font-weight: 600; letter-spacing: .5px; }
210
+ #topo .vivo { position: absolute; right: 6px; top: 46px; display: inline-flex; align-items: center; gap: 7px; color: #8a8a8a; font-size: 12px; }
211
+ .dot { width: 9px; height: 9px; border-radius: 50%; background: #46d160; animation: pulsa 1.4s infinite; }
130
212
  @keyframes pulsa { 50% { opacity: .25 } }
131
- header b { color: #22d3ee; font-weight: 600; }
132
- header span { color: #64748b; font-size: 12px; margin-left: auto; }
133
- main { flex: 1; display: flex; min-height: 0; }
134
- #tela { flex: 1; display: flex; align-items: center; justify-content: center; padding: 14px; min-width: 0; background: #0f172a; }
135
- #tela img { max-width: 100%; max-height: 100%; border: 1px solid #1e293b; border-radius: 8px; display: none; }
136
- #aviso { color: #64748b; text-align: center; max-width: 46ch; }
137
- aside { width: 360px; max-width: 45%; border-left: 1px solid #1e293b; overflow-y: auto; padding: 10px 0; }
138
- .item { padding: 6px 14px; display: flex; gap: 8px; border-left: 2px solid transparent; }
139
- .item.novo { border-left-color: #22d3ee; background: #0f172a; }
140
- .item .n { color: #475569; min-width: 3ch; text-align: right; }
141
- .item .ok { color: #22d3ee; }
142
- .item .falha { color: #fb7185; }
143
- .item .txt { flex: 1; word-break: break-word; }
144
- .item .hora { color: #475569; font-size: 11px; }
145
- .item .erro { color: #fb7185; font-size: 12px; display: block; }
213
+
214
+ /* A janela — borda branca grossa, canto bem redondo */
215
+ #janela { width: min(1100px, 100%); background: #000; border: 10px solid #f5f5f5; border-radius: 46px; overflow: hidden; display: flex; flex-direction: column; }
216
+ #barra { position: relative; display: flex; align-items: center; background: #232323; padding: 14px 26px; min-height: 58px; }
217
+ #barra .b { flex: none; margin-right: 12px; }
218
+ #barra .b.r { width: 17px; height: 17px; border-radius: 50%; background: #d40000; }
219
+ #barra .b.y { width: 30px; height: 17px; border-radius: 50%; background: #ffdd00; }
220
+ #barra .b.g { width: 24px; height: 14px; border-radius: 50%; background: #33cc33; }
221
+
222
+ /* Abas: texto branco centralizado na barra, separadas por | */
223
+ #abas { position: absolute; left: 50%; transform: translateX(-50%); max-width: 62%; display: flex; align-items: center; gap: 12px; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font-size: clamp(16px, 2.4vw, 24px); font-weight: 500; }
224
+ #abas::-webkit-scrollbar { display: none; }
225
+ .aba { border: 0; background: none; cursor: pointer; font: inherit; color: rgba(255,255,255,.5); padding: 2px 2px; }
226
+ .aba:hover { color: rgba(255,255,255,.8); }
227
+ .aba.ativa { color: #fff; }
228
+ .sep { color: rgba(255,255,255,.45); font-weight: 400; }
229
+
230
+ /* A tela */
231
+ #tela { position: relative; min-height: 470px; display: flex; align-items: center; justify-content: center; background: #000; }
232
+ #tela img { width: 100%; max-height: 64vh; object-fit: contain; display: none; }
233
+ #aviso { color: #fff; text-align: center; font-size: clamp(20px, 3.4vw, 34px); font-weight: 500; padding: 110px 20px; }
234
+ #carimbo { position: absolute; left: 16px; bottom: 12px; background: rgba(0,0,0,.75); border: 1px solid #2e2e2e; color: #bdbdbd; font-size: 12px; padding: 3px 10px; border-radius: 8px; display: none; max-width: 85%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
235
+
236
+ /* Histórico: fora da moldura, discreto, abaixo da janela */
237
+ #historico { width: min(1100px, 100%); padding-top: 14px; }
238
+ #filme { display: flex; gap: 6px; padding: 2px 2px 8px; overflow-x: auto; }
239
+ .foto { flex: none; border: 1px solid #3a3a3a; border-radius: 6px; background: #000; cursor: pointer; padding: 0; width: 84px; height: 50px; overflow: hidden; opacity: .7; }
240
+ .foto img { width: 100%; height: 100%; object-fit: cover; display: block; }
241
+ .foto.ativa { border-color: #fff; opacity: 1; }
242
+ #passos { max-height: 170px; overflow-y: auto; }
243
+ .item { padding: 3px 6px; display: flex; gap: 8px; font-size: 13px; }
244
+ .item .n { color: #6f6f6f; min-width: 3ch; text-align: right; }
245
+ .item .ok { color: #46d160; } .item .falha { color: #e5484d; }
246
+ .item .txt { flex: 1; word-break: break-word; color: #d6d6d6; }
247
+ .item .txt .erro { color: #e5484d; font-size: 12px; display: block; }
248
+ .item .hora { color: #6f6f6f; font-size: 11px; }
249
+ .item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #fff; }
250
+
251
+ footer { color: #6f6f6f; font-size: 12px; padding-top: 14px; }
146
252
  </style></head>
147
253
  <body>
148
- <header><span class="dot"></span><b>PrimoCode</b> · Ao vivo <span id="st">conectando…</span></header>
149
- <main>
254
+ <div id="topo">
255
+ <div class="mascote">${MASCOTE_SVG}</div>
256
+ <h1>Primocode</h1>
257
+ <span class="vivo"><span class="dot"></span><span id="st">conectando…</span></span>
258
+ </div>
259
+
260
+ <div id="janela">
261
+ <div id="barra">
262
+ <span class="b r"></span><span class="b y"></span><span class="b g"></span>
263
+ <nav id="abas"></nav>
264
+ </div>
150
265
  <section id="tela">
151
- <img id="frame" alt="tela do computador">
152
- <div id="aviso">Aguardando a primeira ação na tela…<br><br>Dê uma instrução no terminal do PrimoCode e acompanhe cada passo aqui.</div>
266
+ <img id="frame" alt="tela que a IA está usando">
267
+ <div id="aviso">Tela que a IA está</div>
268
+ <div id="carimbo"></div>
153
269
  </section>
154
- <aside id="log"></aside>
155
- </main>
270
+ </div>
271
+
272
+ <div id="historico">
273
+ <div id="filme"></div>
274
+ <div id="passos"></div>
275
+ </div>
276
+ <footer>só nesta máquina (127.0.0.1) · /streaming desliga</footer>
277
+
156
278
  <script>
157
- let ultimoFrame = -1, ultimoPasso = -1;
279
+ const VIVO = 0; // id da aba "Ao vivo"
280
+ let dados = { abas: [], frames: [], log: [] };
281
+ let abaSel = VIVO; // aba escolhida pelo usuário
282
+ let fotoSel = null; // frame fixado no filmstrip (null = mais recente)
283
+ let ultimoEstado = '';
284
+
285
+ function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
286
+
287
+ function framesDaAba(id) {
288
+ return id === VIVO ? dados.frames : dados.frames.filter((f) => f.aba === id);
289
+ }
290
+ function logDaAba(id) {
291
+ return id === VIVO ? dados.log : dados.log.filter((e) => e.aba === id);
292
+ }
293
+
294
+ function render() {
295
+ // Abas: "Ao vivo" + uma por app/janela, texto separado por |
296
+ const nav = document.getElementById('abas');
297
+ const abas = [{ id: VIVO, nome: 'Ao vivo' }, ...dados.abas];
298
+ nav.innerHTML = abas.map((a) =>
299
+ '<button class="aba' + (a.id === abaSel ? ' ativa' : '') + '" data-id="' + a.id + '">' + esc(a.nome) + '</button>'
300
+ ).join('<span class="sep">|</span>');
301
+ nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => { abaSel = Number(b.dataset.id); fotoSel = null; render(); });
302
+
303
+ // Imagem: a fixada no filmstrip, senão a mais nova da aba
304
+ const fs = framesDaAba(abaSel);
305
+ const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
306
+ const img = document.getElementById('frame');
307
+ const aviso = document.getElementById('aviso');
308
+ const carimbo = document.getElementById('carimbo');
309
+ if (atual) {
310
+ const src = '/frame?n=' + atual.id;
311
+ if (img.dataset.n !== String(atual.id)) { img.src = src; img.dataset.n = String(atual.id); }
312
+ img.style.display = 'block'; aviso.style.display = 'none';
313
+ carimbo.style.display = 'block';
314
+ carimbo.textContent = '#' + atual.id + ' · ' + atual.hora + ' · ' + atual.acao + (fotoSel != null ? ' (clique na miniatura de novo para voltar ao mais recente)' : '');
315
+ } else {
316
+ img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
317
+ if (dados.erroFoto) aviso.textContent = 'Sem foto da tela: ' + dados.erroFoto;
318
+ }
319
+
320
+ // Filmstrip da aba (as últimas 14)
321
+ const filme = document.getElementById('filme');
322
+ const tira = fs.slice(-14);
323
+ filme.innerHTML = tira.map((f) =>
324
+ '<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('');
325
+ filme.querySelectorAll('.foto').forEach((b) => b.onclick = () => {
326
+ const id = Number(b.dataset.id);
327
+ fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao "mais recente"
328
+ render();
329
+ });
330
+ if (fotoSel == null) filme.scrollLeft = filme.scrollWidth;
331
+
332
+ // Passos da aba (mais novos primeiro)
333
+ const passos = logDaAba(abaSel).slice(-40).reverse();
334
+ document.getElementById('passos').innerHTML = passos.map((e) =>
335
+ '<div class="item' + (e.frame ? ' temfoto" data-frame="' + e.frame : '') + '">'
336
+ + '<span class="n">' + e.n + '</span>'
337
+ + '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
338
+ + '<span class="txt">' + esc(e.acao) + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '') + '</span>'
339
+ + '<span class="hora">' + e.hora + '</span></div>').join('');
340
+ document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); render(); });
341
+ }
342
+
158
343
  async function tick() {
159
344
  try {
160
345
  const s = await (await fetch('/state')).json();
161
- document.getElementById('st').textContent = 'ao vivo · porta ' + location.port;
162
- if (s.frame !== ultimoFrame && s.frame > 0) {
163
- ultimoFrame = s.frame;
164
- const img = document.getElementById('frame');
165
- img.src = '/frame?' + s.frame;
166
- img.style.display = 'block';
167
- document.getElementById('aviso').style.display = 'none';
168
- }
169
- if (s.erroFoto) {
170
- document.getElementById('aviso').textContent = 'Sem foto da tela: ' + s.erroFoto;
171
- }
172
- const fim = s.log.length ? s.log[s.log.length - 1].n : -1;
173
- if (fim !== ultimoPasso) {
174
- ultimoPasso = fim;
175
- const el = document.getElementById('log');
176
- el.innerHTML = s.log.slice().reverse().map((e, i) =>
177
- '<div class="item' + (i === 0 ? ' novo' : '') + '">'
178
- + '<span class="n">' + e.n + '</span>'
179
- + '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
180
- + '<span class="txt">' + esc(e.acao)
181
- + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '')
182
- + '</span><span class="hora">' + e.hora + '</span></div>'
183
- ).join('');
184
- }
346
+ dados = s;
347
+ document.getElementById('st').textContent = 'ao vivo';
348
+ const assinatura = JSON.stringify([s.frames.length, s.log.length, s.abas.length, s.erroFoto]);
349
+ if (assinatura !== ultimoEstado) { ultimoEstado = assinatura; render(); }
185
350
  } catch (e) {
186
351
  document.getElementById('st').textContent = 'reconectando…';
187
352
  }
188
353
  setTimeout(tick, 700);
189
354
  }
190
- function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
191
355
  tick();
192
356
  </script>
193
357
  </body></html>`;
194
358
 
359
+
195
360
  function start() {
196
361
  if (state.on) return Promise.resolve(state.url);
197
362
  try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
363
+ // Fotos de sessões passadas não pertencem a esta linha do tempo.
364
+ try { for (const f of fs.readdirSync(DIR)) if (/^frame-\d+\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
198
365
 
199
366
  return new Promise((resolve, reject) => {
200
367
  const server = http.createServer((req, res) => {
201
- const rota = (req.url || '/').split('?')[0];
202
- if (rota === '/state') {
368
+ const u = new URL(req.url || '/', 'http://x');
369
+ if (u.pathname === '/state') {
203
370
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
204
- res.end(JSON.stringify({ frame: state.frame, erroFoto: state.erroFoto, log: state.log.slice(-60) }));
371
+ res.end(JSON.stringify({
372
+ erroFoto: state.erroFoto,
373
+ abas: state.abas,
374
+ frames: state.frames,
375
+ log: state.log.slice(-120),
376
+ }));
205
377
  return;
206
378
  }
207
- if (rota === '/frame') {
208
- fs.readFile(FRAME, (err, buf) => {
379
+ if (u.pathname === '/frame') {
380
+ // O id vem de fora: só serve o arquivo se for número — nada de
381
+ // path traversal por aqui.
382
+ const id = Math.round(Number(u.searchParams.get('n')));
383
+ const conhecido = Number.isFinite(id) && state.frames.some((f) => f.id === id);
384
+ if (!conhecido) { res.writeHead(404); res.end(); return; }
385
+ fs.readFile(path.join(DIR, `frame-${id}.png`), (err, buf) => {
209
386
  if (err) { res.writeHead(404); res.end(); return; }
210
- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
387
+ // Cada frame é imutável: o navegador pode guardar à vontade.
388
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=3600' });
211
389
  res.end(buf);
212
390
  });
213
391
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "7.11.0",
3
+ "version": "7.12.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": {