primocode 7.11.0 → 7.12.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.
Files changed (2) hide show
  1. package/lib/streaming.js +236 -67
  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,99 +130,220 @@ 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
+
121
191
  const PAGINA = `<!doctype html>
122
192
  <html lang="pt-BR"><head><meta charset="utf-8">
123
193
  <meta name="viewport" content="width=device-width, initial-scale=1">
124
194
  <title>PrimoCode · Ao vivo</title>
125
195
  <style>
126
196
  * { 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; }
197
+ body { background: #17181a; color: #e2e8f0; font: 14px/1.5 ui-monospace, 'Cascadia Code', Consolas, monospace; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 18px 14px 26px; }
198
+
199
+ /* Cabeçalho: mascote + nome */
200
+ header { display: flex; align-items: center; gap: 16px; padding: 6px 0 18px; }
201
+ header .mascote { width: 72px; height: 30px; transform: rotate(-8deg); }
202
+ header .mascote svg { width: 100%; height: 100%; display: block; }
203
+ header h1 { font-size: 34px; font-weight: 700; letter-spacing: .5px; }
204
+ header h1 b { color: #22d3ee; font-weight: 700; }
205
+ header .vivo { margin-left: 14px; display: inline-flex; align-items: center; gap: 7px; color: #64748b; font-size: 12px; }
129
206
  .dot { width: 9px; height: 9px; border-radius: 50%; background: #22d3ee; animation: pulsa 1.4s infinite; }
130
207
  @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; }
208
+
209
+ /* A "janela" */
210
+ #janela { width: min(1060px, 100%); background: #000; border: 4px solid #f4f4f4; border-radius: 26px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 18px 60px rgba(0,0,0,.55); }
211
+ #barra { display: flex; align-items: center; gap: 8px; background: #232323; padding: 10px 16px; border-bottom: 1px solid #101010; }
212
+ #barra .b { width: 13px; height: 13px; border-radius: 50%; flex: none; }
213
+ #barra .b.r { background: #e5484d; } #barra .b.y { background: #f5d90a; } #barra .b.g { background: #46d160; }
214
+
215
+ #abas { display: flex; gap: 4px; margin-left: 14px; overflow-x: auto; scrollbar-width: none; }
216
+ #abas::-webkit-scrollbar { display: none; }
217
+ .aba { border: 0; cursor: pointer; white-space: nowrap; font: inherit; font-size: 13px; padding: 5px 14px; border-radius: 9px 9px 0 0; background: transparent; color: #94a3b8; }
218
+ .aba:hover { color: #e2e8f0; }
219
+ .aba.ativa { background: #000; color: #22d3ee; }
220
+ .aba .qtd { color: #475569; font-size: 11px; margin-left: 6px; }
221
+
222
+ #tela { position: relative; min-height: 380px; display: flex; align-items: center; justify-content: center; background: #000; }
223
+ #tela img { width: 100%; max-height: 62vh; object-fit: contain; display: none; }
224
+ #aviso { color: #94a3b8; text-align: center; max-width: 52ch; padding: 70px 20px; font-size: 15px; }
225
+ #carimbo { position: absolute; left: 12px; bottom: 10px; background: rgba(11,18,32,.82); border: 1px solid #1e293b; color: #94a3b8; font-size: 12px; padding: 3px 10px; border-radius: 7px; display: none; max-width: 85%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
226
+
227
+ /* Filmstrip + passos da aba */
228
+ #rodape { background: #0b0c0e; border-top: 1px solid #1c1d20; }
229
+ #filme { display: flex; gap: 6px; padding: 8px 12px 2px; overflow-x: auto; }
230
+ .foto { flex: none; border: 1px solid #26272b; border-radius: 6px; background: #000; cursor: pointer; padding: 0; width: 84px; height: 50px; overflow: hidden; opacity: .75; }
231
+ .foto img { width: 100%; height: 100%; object-fit: cover; display: block; }
232
+ .foto.ativa { border-color: #22d3ee; opacity: 1; }
233
+ #passos { max-height: 150px; overflow-y: auto; padding: 6px 4px 10px; }
234
+ .item { padding: 3px 14px; display: flex; gap: 8px; font-size: 13px; }
140
235
  .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; }
236
+ .item .ok { color: #22d3ee; } .item .falha { color: #fb7185; }
237
+ .item .txt { flex: 1; word-break: break-word; color: #cbd5e1; }
238
+ .item .txt .erro { color: #fb7185; font-size: 12px; display: block; }
144
239
  .item .hora { color: #475569; font-size: 11px; }
145
- .item .erro { color: #fb7185; font-size: 12px; display: block; }
240
+ .item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #22d3ee; }
241
+
242
+ footer { color: #475569; font-size: 12px; padding-top: 14px; }
146
243
  </style></head>
147
244
  <body>
148
- <header><span class="dot"></span><b>PrimoCode</b> · Ao vivo <span id="st">conectando…</span></header>
149
- <main>
245
+ <header>
246
+ <div class="mascote">${MASCOTE_SVG}</div>
247
+ <h1><b>Primo</b>Code</h1>
248
+ <span class="vivo"><span class="dot"></span><span id="st">conectando…</span></span>
249
+ </header>
250
+
251
+ <div id="janela">
252
+ <div id="barra">
253
+ <span class="b r"></span><span class="b y"></span><span class="b g"></span>
254
+ <nav id="abas"></nav>
255
+ </div>
150
256
  <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>
257
+ <img id="frame" alt="tela que a IA está usando">
258
+ <div id="aviso">Tela que a IA está usando aparece aqui, em tempo real.<br><br>Dê uma instrução no terminal do PrimoCode — cada app em que ela mexer vira uma aba, e você pode voltar para ver o que ela já fez.</div>
259
+ <div id="carimbo"></div>
153
260
  </section>
154
- <aside id="log"></aside>
155
- </main>
261
+ <div id="rodape">
262
+ <div id="filme"></div>
263
+ <div id="passos"></div>
264
+ </div>
265
+ </div>
266
+ <footer>só nesta máquina (127.0.0.1) · /streaming desliga</footer>
267
+
156
268
  <script>
157
- let ultimoFrame = -1, ultimoPasso = -1;
269
+ const VIVO = 0; // id da aba "Ao vivo"
270
+ let dados = { abas: [], frames: [], log: [] };
271
+ let abaSel = VIVO; // aba escolhida pelo usuário
272
+ let fotoSel = null; // frame fixado no filmstrip (null = mais recente)
273
+ let ultimoEstado = '';
274
+
275
+ function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
276
+
277
+ function framesDaAba(id) {
278
+ return id === VIVO ? dados.frames : dados.frames.filter((f) => f.aba === id);
279
+ }
280
+ function logDaAba(id) {
281
+ return id === VIVO ? dados.log : dados.log.filter((e) => e.aba === id);
282
+ }
283
+
284
+ function render() {
285
+ // Abas: "Ao vivo" + uma por app/janela
286
+ const nav = document.getElementById('abas');
287
+ const abas = [{ id: VIVO, nome: 'Ao vivo' }, ...dados.abas];
288
+ nav.innerHTML = abas.map((a) =>
289
+ '<button class="aba' + (a.id === abaSel ? ' ativa' : '') + '" data-id="' + a.id + '">'
290
+ + (a.id === VIVO ? '● ' : '') + esc(a.nome)
291
+ + (a.passos ? '<span class="qtd">' + a.passos + '</span>' : '')
292
+ + '</button>').join('');
293
+ nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => { abaSel = Number(b.dataset.id); fotoSel = null; render(); });
294
+
295
+ // Imagem: a fixada no filmstrip, senão a mais nova da aba
296
+ const fs = framesDaAba(abaSel);
297
+ const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
298
+ const img = document.getElementById('frame');
299
+ const aviso = document.getElementById('aviso');
300
+ const carimbo = document.getElementById('carimbo');
301
+ if (atual) {
302
+ const src = '/frame?n=' + atual.id;
303
+ if (img.dataset.n !== String(atual.id)) { img.src = src; img.dataset.n = String(atual.id); }
304
+ img.style.display = 'block'; aviso.style.display = 'none';
305
+ carimbo.style.display = 'block';
306
+ carimbo.textContent = '#' + atual.id + ' · ' + atual.hora + ' · ' + atual.acao + (fotoSel != null ? ' (clique na miniatura de novo para voltar ao mais recente)' : '');
307
+ } else {
308
+ img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
309
+ if (dados.erroFoto) aviso.innerHTML = 'Sem foto da tela: ' + esc(dados.erroFoto);
310
+ }
311
+
312
+ // Filmstrip da aba (as últimas 14)
313
+ const filme = document.getElementById('filme');
314
+ const tira = fs.slice(-14);
315
+ filme.innerHTML = tira.map((f) =>
316
+ '<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('');
317
+ filme.querySelectorAll('.foto').forEach((b) => b.onclick = () => {
318
+ const id = Number(b.dataset.id);
319
+ fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao "mais recente"
320
+ render();
321
+ });
322
+ if (fotoSel == null) filme.scrollLeft = filme.scrollWidth;
323
+
324
+ // Passos da aba (mais novos primeiro)
325
+ const passos = logDaAba(abaSel).slice(-40).reverse();
326
+ document.getElementById('passos').innerHTML = passos.map((e) =>
327
+ '<div class="item' + (e.frame ? ' temfoto" data-frame="' + e.frame : '') + '">'
328
+ + '<span class="n">' + e.n + '</span>'
329
+ + '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
330
+ + '<span class="txt">' + esc(e.acao) + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '') + '</span>'
331
+ + '<span class="hora">' + e.hora + '</span></div>').join('');
332
+ document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); render(); });
333
+ }
334
+
158
335
  async function tick() {
159
336
  try {
160
337
  const s = await (await fetch('/state')).json();
338
+ dados = s;
161
339
  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
- }
340
+ const assinatura = JSON.stringify([s.frames.length, s.log.length, s.abas.length, s.erroFoto]);
341
+ if (assinatura !== ultimoEstado) { ultimoEstado = assinatura; render(); }
185
342
  } catch (e) {
186
343
  document.getElementById('st').textContent = 'reconectando…';
187
344
  }
188
345
  setTimeout(tick, 700);
189
346
  }
190
- function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
191
347
  tick();
192
348
  </script>
193
349
  </body></html>`;
@@ -195,19 +351,32 @@ tick();
195
351
  function start() {
196
352
  if (state.on) return Promise.resolve(state.url);
197
353
  try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
354
+ // Fotos de sessões passadas não pertencem a esta linha do tempo.
355
+ try { for (const f of fs.readdirSync(DIR)) if (/^frame-\d+\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
198
356
 
199
357
  return new Promise((resolve, reject) => {
200
358
  const server = http.createServer((req, res) => {
201
- const rota = (req.url || '/').split('?')[0];
202
- if (rota === '/state') {
359
+ const u = new URL(req.url || '/', 'http://x');
360
+ if (u.pathname === '/state') {
203
361
  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) }));
362
+ res.end(JSON.stringify({
363
+ erroFoto: state.erroFoto,
364
+ abas: state.abas,
365
+ frames: state.frames,
366
+ log: state.log.slice(-120),
367
+ }));
205
368
  return;
206
369
  }
207
- if (rota === '/frame') {
208
- fs.readFile(FRAME, (err, buf) => {
370
+ if (u.pathname === '/frame') {
371
+ // O id vem de fora: só serve o arquivo se for número — nada de
372
+ // path traversal por aqui.
373
+ const id = Math.round(Number(u.searchParams.get('n')));
374
+ const conhecido = Number.isFinite(id) && state.frames.some((f) => f.id === id);
375
+ if (!conhecido) { res.writeHead(404); res.end(); return; }
376
+ fs.readFile(path.join(DIR, `frame-${id}.png`), (err, buf) => {
209
377
  if (err) { res.writeHead(404); res.end(); return; }
210
- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
378
+ // Cada frame é imutável: o navegador pode guardar à vontade.
379
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=3600' });
211
380
  res.end(buf);
212
381
  });
213
382
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "7.11.0",
3
+ "version": "7.12.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": {