primocode 11.1.0 → 11.3.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 (47) hide show
  1. package/README.md +16 -2
  2. package/bin/primocode-rpc.js +100 -0
  3. package/lib/agentes-pessoais.js +120 -0
  4. package/lib/app-server/cli.js +64 -0
  5. package/lib/app-server/enderecos.js +43 -0
  6. package/lib/app-server/index.js +3 -0
  7. package/lib/app-server/pareamento.js +61 -0
  8. package/lib/app-server/protocolo.js +86 -0
  9. package/lib/app-server/servidor.js +805 -0
  10. package/lib/app-server/websocket.js +87 -0
  11. package/lib/avisos.js +65 -0
  12. package/lib/cli/sessao.js +202 -17
  13. package/lib/cli-nova.js +237 -0
  14. package/lib/config/config.js +22 -2
  15. package/lib/diff.js +12 -2
  16. package/lib/motor/aprovacao.js +114 -0
  17. package/lib/motor/eventos.js +77 -0
  18. package/lib/motor/index.js +8 -0
  19. package/lib/motor/tela-terminal.js +64 -0
  20. package/lib/motor/transcricao.js +57 -0
  21. package/lib/motor/turno.js +43 -0
  22. package/lib/motor/voz.js +65 -0
  23. package/lib/piloto.js +39 -0
  24. package/lib/plugin/entregaveis/nucleo.js +2 -2
  25. package/lib/plugin/estudio/criar.js +113 -0
  26. package/lib/plugin/estudio/gosto.js +3 -0
  27. package/lib/plugin/estudio/historico.js +60 -0
  28. package/lib/plugin/estudio/index.js +14 -1
  29. package/lib/plugin/estudio/kits/tela.html +3 -1
  30. package/lib/plugin/estudio/miniatura.js +56 -0
  31. package/lib/plugin/video/auto.js +1 -1
  32. package/lib/plugin/video/catalogo.js +20 -0
  33. package/lib/plugin/video/mutar.js +28 -0
  34. package/lib/plugin/video/ops-fx.js +6 -6
  35. package/lib/plugin/video/ops.js +2 -13
  36. package/lib/plugin/video/pedido.js +1 -1
  37. package/lib/plugin/video/zoom.js +1 -1
  38. package/lib/projetos-meta.js +107 -0
  39. package/lib/qr.js +209 -0
  40. package/lib/session/runner/act.js +127 -68
  41. package/lib/skill/catalogo.js +3 -1
  42. package/lib/tool/tools.js +7 -0
  43. package/lib/tui/entrada.js +143 -18
  44. package/lib/tui/tela.js +312 -49
  45. package/lib/tui/ui.js +78 -4
  46. package/package.json +10 -2
  47. package/voz/primo_voz/transcrever.py +12 -0
@@ -0,0 +1,805 @@
1
+ // App-server local: JSON-RPC 2.0 que expõe o motor às telas (docs/estrutura.md, seção 3).
2
+ // Dois transportes: stdio (uma mensagem JSON por linha) e WebSocket em 127.0.0.1
3
+ // com token de sessão. Com `rede: true` (primocode server --ws --rede) escuta na rede local e aceita
4
+ // também tokens de PAREAMENTO (um por celular, revogável, com poderes limitados). Node puro, sem dependência.
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const http = require('http');
11
+ const crypto = require('crypto');
12
+ const P = require('./protocolo');
13
+ const WS = require('./websocket');
14
+ const { criarPareamentos } = require('./pareamento');
15
+ const { criarPortao } = require('../motor/aprovacao');
16
+
17
+ const { ERRO, erro } = P;
18
+ const MAX_LEITURA = 1024 * 1024;
19
+ const BLOQUEADOS = /(^|[\\/])(\.ssh|\.aws|\.gnupg|\.env[^\\/]*|app-server\.json|pareamentos\.json|config\.json)$/;
20
+ const CAMPOS_CONFIG = ['effort', 'model', 'mode', 'permissoes', 'userName', 'mascot', 'reacoes', 'theme', 'autoApply', 'piloto', 'grupo', 'inicioFeito', 'preferencias'];
21
+ const PILOTO_MS = Number(process.env.PRIMOCODE_PILOTO_MS) || 15 * 60000;
22
+
23
+ const RAIZ_TELA = path.join(__dirname, '..', '..', 'app', 'renderer');
24
+ const TIPOS_ESTATICOS = {
25
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
26
+ '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2',
27
+ };
28
+
29
+ /* O que um celular pareado pode pedir ao motor. Desde 02/11 o app do celular tem TODAS as telas do desktop (Início, Studio, Projetos,
30
+ Agentes, Kit da marca, Avisos, Configurações, busca, voz), então as telas ganharam os pedidos delas, com limites:
31
+ config/write só nos campos de preferência (nunca permissões, piloto, modelo ou servidor), voice/start sem texto (não gera fala no
32
+ computador), fs/list e fs/read continuam de fora (arquivo do computador não vai para o celular).
33
+ Fica de fora: arquivos, permissões, piloto automático, abrir coisas no computador, conta (entrar/sair), pareamento, telas do Estúdio. */
34
+ const PAREADO_PODE = new Set([
35
+ 'thread/start', 'thread/list', 'thread/resume', 'thread/rename', 'thread/archive', 'turn/start', 'turn/interrupt', 'turn/queue',
36
+ 'approval/respond', 'question/respond', 'notice/list', 'notice/read', 'studio/history', 'project/list', 'agent/list', 'config/read',
37
+ 'account/status', // só ver se há conta; entrar, sair e o login pelo navegador são do computador
38
+ 'project/create', 'project/update', 'project/delete', 'agent/create', 'agent/update', 'agent/delete', 'agent/toggle',
39
+ 'brand/read', 'brand/write', 'config/write', 'voice/start', 'voice/stop', 'voice/transcribe',
40
+ ]);
41
+ const CAMPOS_CONFIG_PAREADO = ['effort', 'userName', 'mascot', 'reacoes', 'theme', 'preferencias'];
42
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
43
+
44
+ const texto = (v) => (typeof v === 'string' ? v : '');
45
+
46
+ /* Abre um arquivo ou pasta no programa do sistema. Só caminho já validado chega aqui; sem shell. */
47
+ function abrirNoSistema(abs) {
48
+ // Teste de tela: PRIMOCODE_ABRIR_LOG troca o programa do sistema por uma linha no arquivo.
49
+ if (process.env.PRIMOCODE_ABRIR_LOG) { try { fs.appendFileSync(process.env.PRIMOCODE_ABRIR_LOG, `${abs}\n`); } catch { /* sem log */ } return; }
50
+ const { spawn } = require('child_process');
51
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [abs]] : process.platform === 'win32' ? ['explorer.exe', [abs]] : ['xdg-open', [abs]];
52
+ try { const f = spawn(cmd, args, { detached: true, stdio: 'ignore' }); f.on('error', () => {}); f.unref(); } catch { /* sem programa para abrir */ }
53
+ }
54
+ const MIDIAS = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
55
+
56
+ /* O login do GitHub, lido do hosts.yml do `gh` (sem rodar o gh). Devolve o usuário ou null. */
57
+ function githubConectado() {
58
+ try {
59
+ const t = fs.readFileSync(path.join(os.homedir(), '.config', 'gh', 'hosts.yml'), 'utf8');
60
+ const m = /^\s+user:\s*(\S+)/m.exec(t);
61
+ return m ? m[1] : null;
62
+ } catch { return null; }
63
+ }
64
+
65
+ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {}, transcritor } = {}) {
66
+ const cfg = require('../config/config');
67
+ const m = motor || require('../motor');
68
+ const base = casa || cfg.CONFIG_DIR;
69
+ const pastaThreads = path.join(base, 'threads');
70
+ const arquivoInfo = path.join(base, 'app-server.json');
71
+ const token = crypto.randomBytes(24).toString('hex');
72
+ const Pareamentos = criarPareamentos({ arquivo: path.join(base, 'pareamentos.json') });
73
+ let modoRede = !!rede;
74
+ const permitidas = (raizes || [os.homedir()]).map((r) => path.resolve(r));
75
+
76
+ const dirAgentes = path.join(base, 'agentes');
77
+ const arqAvisos = path.join(base, 'avisos.json');
78
+ const Conta = require('../config/conta');
79
+ const Agentes = require('../agentes-pessoais');
80
+ const Avisos = require('../avisos');
81
+ const Projetos = require('../projetos-meta');
82
+ const Marca = require('../plugin/marca-kit');
83
+ const Piloto = require('../piloto');
84
+ const Voz = voz || require('../motor/voz').criarVoz({ pasta: path.join(base, 'voz') });
85
+ let loginNavegador = null;
86
+ const statusConta = () => {
87
+ const a = Conta.atual();
88
+ return a ? { logado: true, email: a.email, plano: a.plano, planoAte: a.planoAte, pro: !!a.pro }
89
+ : { logado: false, email: null, plano: null, planoAte: null, pro: false };
90
+ };
91
+ let timerPiloto = null;
92
+ let pilotoRodando = false;
93
+
94
+ const pastaTela = telaDir === undefined ? RAIZ_TELA : telaDir;
95
+ const conexoes = new Set();
96
+ const threads = new Map(); // id → { dados, dono, assinantes, rodando }: dono = quem mandou por último; assinantes = todos que abriram a conversa (recebem os eventos)
97
+ let http_ = null;
98
+ let porta = null;
99
+
100
+ /* A linha `primocode:abrir-gosto <url>` que o motor escreve no stderr (só com PRIMOCODE_APP=1, que o app liga) vira screen/open
101
+ para as telas do computador. O resto do stderr passa igual. */
102
+ let restoDoStderr = '';
103
+ const escreverStderr = process.stderr.write.bind(process.stderr);
104
+ const olharStderr = process.env.PRIMOCODE_APP === '1' && !process.env.PRIMOCODE_SEM_TELA_NO_APP;
105
+ function filtrarStderr(chunk) {
106
+ restoDoStderr = (restoDoStderr + String(chunk)).slice(-4000);
107
+ const linhas = restoDoStderr.split('\n');
108
+ restoDoStderr = linhas.pop();
109
+ for (const l of linhas) {
110
+ const url = require('../plugin/estudio/gosto').urlDaLinha(l);
111
+ if (url) for (const c of conexoes) { if (!c.pareado) { try { c.enviar(P.notificacao('screen/open', { url })); } catch { /* caiu */ } } }
112
+ }
113
+ }
114
+ if (olharStderr) {
115
+ process.stderr.write = (chunk, ...resto) => { try { filtrarStderr(chunk); } catch { /* nunca quebra o log */ } return escreverStderr(chunk, ...resto); };
116
+ }
117
+
118
+ /* ── Conversas: um JSON por thread em ~/.primocode/threads ── */
119
+ const arquivoDe = (id) => path.join(pastaThreads, `${id}.json`);
120
+ function salvar(t) {
121
+ try {
122
+ fs.mkdirSync(pastaThreads, { recursive: true, mode: 0o700 });
123
+ fs.writeFileSync(arquivoDe(t.dados.id), JSON.stringify(t.dados), { mode: 0o600 });
124
+ } catch { /* falhar em guardar não derruba o turno */ }
125
+ }
126
+ function carregar(id) {
127
+ if (!/^[\w-]{1,64}$/.test(String(id || ''))) throw erro(ERRO.PARAMS, 'thread inválida');
128
+ if (threads.has(id)) return threads.get(id);
129
+ let dados;
130
+ try { dados = JSON.parse(fs.readFileSync(arquivoDe(id), 'utf8')); } catch { throw erro(ERRO.NAO_ACHEI, `não achei a conversa ${id}`); }
131
+ const t = { dados, dono: null, assinantes: new Set(), rodando: false };
132
+ threads.set(id, t);
133
+ return t;
134
+ }
135
+ const resumoDe = (d) => ({
136
+ id: d.id, titulo: d.titulo, criada: d.criada, atualizada: d.atualizada,
137
+ projeto: d.projeto || null, arquivada: !!d.arquivada, mensagens: (d.historico || []).length,
138
+ });
139
+ function listarDoDisco() {
140
+ let nomes = [];
141
+ try { nomes = fs.readdirSync(pastaThreads).filter((n) => n.endsWith('.json')); } catch { /* ainda não há */ }
142
+ const out = [];
143
+ for (const n of nomes) {
144
+ const id = n.slice(0, -5);
145
+ try { out.push(resumoDe((threads.get(id) || carregar(id)).dados)); } catch { /* arquivo quebrado: pula */ }
146
+ }
147
+ return out.sort((a, b) => (b.atualizada || 0) - (a.atualizada || 0));
148
+ }
149
+
150
+ /* ── Avisos: gravam em avisos.json e chegam a todas as telas abertas ── */
151
+ const difundir = (metodo, params) => { for (const c of conexoes) { try { c.enviar(P.notificacao(metodo, params)); } catch { /* conexão caiu */ } } };
152
+ const contagemAvisos = () => Avisos.contagem(Avisos.ler({ arquivo: arqAvisos }));
153
+ function novoAviso(dados) {
154
+ const av = Avisos.criar(dados, { arquivo: arqAvisos });
155
+ difundir('notice/created', { ...av, ...contagemAvisos(), estado: av.nivel === 'erro' ? 'erro' : 'ok', aviso: av });
156
+ return av;
157
+ }
158
+ const pilotoLigado = () => cfg.loadConfig().piloto === true;
159
+ /* O emissor de um turno: o que o motor emite vai à tela dona da conversa. Com o piloto ligado
160
+ (ou num turno do piloto) a aprovação vira aviso pendente, em vez de cartão no compositor;
161
+ todo notice/created do motor é gravado em avisos.json. */
162
+ function emissorDe(t) {
163
+ return (ev) => {
164
+ if (ev.evento === 'notice/created') {
165
+ novoAviso({ tipo: 'aviso', nivel: ev.estado === 'erro' ? 'erro' : 'ok', texto: ev.texto || (ev.erro && ev.erro.message) || '', thread: t.dados.id, agente: t.agente });
166
+ return;
167
+ }
168
+ if (ev.evento === 'approval/requested' && (t.agente || pilotoLigado())) {
169
+ novoAviso({ tipo: 'aprovacao', texto: ev.texto, thread: t.dados.id, aprovacao: ev.id, agente: t.agente });
170
+ return;
171
+ }
172
+ // Desktop e celular acompanham o MESMO turno: todo assinante da conversa (quem fez thread/resume ou mandou) recebe.
173
+ const msg = P.notificacao(ev.evento, { thread: t.dados.id, ...ev });
174
+ const para = new Set(t.assinantes);
175
+ if (t.dono) para.add(t.dono);
176
+ for (const c of para) { try { c.enviar(msg); } catch { /* conexão caiu */ } }
177
+ };
178
+ }
179
+
180
+ /* Título da conversa (bug 2): depois do primeiro turno de uma conversa nova, quem leu a troca escreve o título
181
+ (lib/session/titulo-conversa.js) e a tela recebe thread/renamed. Falha ou demora: fica o provisório. */
182
+ function perguntarTitulo(prompt) {
183
+ if (typeof m.titular === 'function') return m.titular(prompt);
184
+ const c = cfg.loadConfig();
185
+ let resposta = '';
186
+ return require('../provider/api').requestChat(c.server, { prompt, history: [], model: c.model, token: c.token }, (evt) => { if (evt.delta) resposta += evt.delta; }).then(() => resposta);
187
+ }
188
+ function batizar(t, pedido, resposta) {
189
+ const provisorio = t.provisorio;
190
+ if (!provisorio) return;
191
+ t.provisorio = null;
192
+ const texto_ = typeof resposta === 'string' ? resposta : Array.isArray(resposta) ? resposta.map((x) => (x && x.text) || '').join('') : '';
193
+ require('../session/titulo-conversa').escolher({ pedido, resposta: texto_, perguntar: perguntarTitulo }).then((titulo) => {
194
+ if (!titulo || t.dados.titulo !== provisorio) return; // veio lixo, ou a pessoa já renomeou
195
+ t.dados.titulo = titulo.slice(0, 120);
196
+ salvar(t);
197
+ difundir('thread/renamed', { thread: t.dados.id, titulo: t.dados.titulo });
198
+ }).catch(() => { /* fica o provisório */ });
199
+ }
200
+
201
+ /* ── Caminhos: só dentro das raízes permitidas, sem segredos ── */
202
+ function caminhoSeguro(p) {
203
+ const abs = path.resolve(texto(p));
204
+ if (!texto(p)) throw erro(ERRO.PARAMS, 'falta path');
205
+ const dentro = permitidas.some((r) => abs === r || abs.startsWith(r + path.sep));
206
+ if (!dentro) throw erro(ERRO.NEGADO, 'caminho fora das pastas permitidas');
207
+ if (BLOQUEADOS.test(abs)) throw erro(ERRO.NEGADO, 'arquivo protegido');
208
+ return abs;
209
+ }
210
+
211
+ /* ── Métodos. Cada um: (params, conn) → resultado, ou lança erro(). ── */
212
+ const metodos = {
213
+ 'thread/start'(p, conn) {
214
+ const agora = Date.now();
215
+ const dados = {
216
+ id: crypto.randomBytes(8).toString('hex'),
217
+ titulo: texto(p.titulo) || 'Nova conversa',
218
+ criada: agora, atualizada: agora,
219
+ projeto: texto(p.projeto) || null, arquivada: false,
220
+ historico: [],
221
+ };
222
+ const t = { dados, dono: conn, assinantes: new Set(conn ? [conn] : []), rodando: false };
223
+ threads.set(dados.id, t);
224
+ salvar(t);
225
+ return resumoDe(dados);
226
+ },
227
+ 'thread/list'(p) {
228
+ const todas = listarDoDisco();
229
+ return { threads: p.arquivadas ? todas : todas.filter((x) => !x.arquivada) };
230
+ },
231
+ 'thread/resume'(p, conn) {
232
+ const t = carregar(p.thread);
233
+ if (conn) { t.assinantes.add(conn); if (!t.rodando) t.dono = conn; }
234
+ return { ...resumoDe(t.dados), mensagens: t.dados.historico, rodando: t.rodando };
235
+ },
236
+ 'thread/rename'(p) {
237
+ const t = carregar(p.thread);
238
+ const titulo = texto(p.titulo).trim();
239
+ if (!titulo) throw erro(ERRO.PARAMS, 'falta titulo');
240
+ t.dados.titulo = titulo.slice(0, 120);
241
+ salvar(t);
242
+ return resumoDe(t.dados);
243
+ },
244
+ 'thread/archive'(p) {
245
+ const t = carregar(p.thread);
246
+ t.dados.arquivada = p.arquivar !== false;
247
+ salvar(t);
248
+ return resumoDe(t.dados);
249
+ },
250
+
251
+ async 'turn/start'(p, conn) {
252
+ const pedido = texto(p.texto).trim();
253
+ if (!pedido) throw erro(ERRO.PARAMS, 'falta texto');
254
+ if (conn && conn.pareado) {
255
+ // O celular não lê arquivos nem escolhe pasta: só usa um projeto já registrado, sem menções nem anexos.
256
+ if (p.projeto && !Projetos.listar().some((x) => x.caminho === p.projeto)) throw erro(ERRO.NEGADO, 'projeto desconhecido');
257
+ p = { ...p, mencoes: undefined, anexos: undefined };
258
+ }
259
+ let t;
260
+ if (p.thread) t = carregar(p.thread);
261
+ else { const novo = metodos['thread/start']({ projeto: p.projeto }, conn); t = threads.get(novo.id); }
262
+ if (t.rodando) throw erro(ERRO.OCUPADO, 'já há um turno rodando nesta conversa; use turn/queue');
263
+ if (pilotoRodando) throw erro(ERRO.OCUPADO, 'o piloto automático está trabalhando; espere ou desligue-o');
264
+ t.dono = conn;
265
+ if (conn) t.assinantes.add(conn);
266
+ t.rodando = true;
267
+ const dados = t.dados;
268
+ const c = cfg.loadConfig();
269
+ const projeto = texto(p.projeto) || dados.projeto || undefined;
270
+ if (projeto) dados.projeto = projeto;
271
+ // O título provisório é o começo do pedido; quando o primeiro turno termina, o modelo escreve o de verdade (batizar).
272
+ if (dados.titulo === 'Nova conversa' && !dados.historico.length) { dados.titulo = pedido.replace(/\s+/g, ' ').slice(0, 60); t.provisorio = dados.titulo; }
273
+
274
+ // Menções (@arquivo) e anexos entram como instrução: não viram a fala da pessoa.
275
+ const partes = [];
276
+ if (Array.isArray(p.mencoes) && p.mencoes.length && projeto) {
277
+ const { arquivos } = require('../mencoes').mencoes(p.mencoes.map((x) => `@"${x}"`).join(' '), projeto);
278
+ for (const a of arquivos) {
279
+ try { partes.push(`Arquivo mencionado ${a.rel}:\n${fs.readFileSync(a.abs, 'utf8')}`); } catch { /* sumiu */ }
280
+ }
281
+ }
282
+ if (Array.isArray(p.anexos) && p.anexos.length) partes.push(`Anexos do pedido: ${p.anexos.map(String).join(', ')}`);
283
+ if (p.tipo) partes.push(require('../plugin/estudio/criar').instrucaoDoTipo(texto(p.tipo)));
284
+ // @agente chama na hora; os ligados vão de cardápio (a IA escolhe, não a regex).
285
+ const ag = Agentes.instrucao(pedido, p.agentes, { dir: dirAgentes });
286
+ if (ag.texto) partes.push(ag.texto);
287
+
288
+ const emitir = emissorDe(t);
289
+ t.portao = criarPortao({ emitir });
290
+ const memoria = require('../session/memoria-conversa');
291
+ const opts = {
292
+ texto: pedido, emitir, portao: t.portao,
293
+ autorizar: t.portao.autorizar, perguntar: t.portao.perguntar,
294
+ server: c.server, model: c.model, effort: texto(p.esforco) || c.effort, token: c.token,
295
+ history: memoria.paraEnviar(memoria.nova(), dados.historico),
296
+ projectDir: projeto, projectName: projeto ? path.basename(projeto) : undefined,
297
+ permissoes: c.permissoes, semTerminal: false,
298
+ instrucao: partes.join('\n\n'),
299
+ };
300
+ // O turno roda solto: o cliente recebe turn/* por notificação, não pela resposta.
301
+ Promise.resolve().then(() => m.iniciarTurno(opts)).then((r) => {
302
+ if (r && r.userMessage && !r.parado) {
303
+ dados.historico.push(r.userMessage);
304
+ if (r.assistantMessage) dados.historico.push(r.assistantMessage);
305
+ batizar(t, pedido, r.assistantMessage && r.assistantMessage.content);
306
+ }
307
+ }).catch((e) => {
308
+ // O motor já emite turn/interrupted com o erro; aqui só garante que a tela saiba.
309
+ emitir({ evento: 'notice/created', estado: 'erro', texto: e && e.message });
310
+ }).finally(() => {
311
+ t.rodando = false;
312
+ if (t.portao) t.portao.cancelar();
313
+ dados.atualizada = Date.now();
314
+ salvar(t);
315
+ });
316
+ return { thread: dados.id };
317
+ },
318
+ 'turn/interrupt'(p) {
319
+ const t = p.thread ? carregar(p.thread) : null;
320
+ if (t && !t.rodando) return { interrompido: false };
321
+ // Quem espera uma resposta é solto como NÃO antes de parar o turno.
322
+ for (const x of t ? [t] : threads.values()) if (x.portao) x.portao.cancelar();
323
+ m.interromper();
324
+ return { interrompido: true };
325
+ },
326
+ 'turn/queue'(p) {
327
+ const pedido = texto(p.texto).trim();
328
+ if (!pedido) throw erro(ERRO.PARAMS, 'falta texto');
329
+ return { enfileirado: !!m.mandarDuranteATarefa(pedido) };
330
+ },
331
+
332
+ // Respostas ao que o motor pediu (approval/requested, question/requested).
333
+ 'approval/respond'(p) {
334
+ const id = texto(p.id);
335
+ if (!id) throw erro(ERRO.PARAMS, 'falta id');
336
+ if (!p.aceitar && !p.recusar && !p.sempre) throw erro(ERRO.PARAMS, 'diga aceitar, recusar ou sempre');
337
+ for (const t of p.thread ? [carregar(p.thread)] : threads.values()) {
338
+ if (t.portao && t.portao.responderAprovacao(id, p)) {
339
+ Avisos.resolverAprovacao(id, p.recusar ? 'nao' : p.sempre ? 'sessao' : 'agora', { arquivo: arqAvisos });
340
+ difundir('notice/changed', contagemAvisos());
341
+ return { ok: true, ...contagemAvisos() };
342
+ }
343
+ }
344
+ throw erro(ERRO.NAO_ACHEI, `não há aprovação pendente ${id}`);
345
+ },
346
+ 'question/respond'(p) {
347
+ const id = texto(p.id);
348
+ if (!id) throw erro(ERRO.PARAMS, 'falta id');
349
+ if (!Array.isArray(p.respostas)) throw erro(ERRO.PARAMS, 'falta respostas (lista)');
350
+ for (const t of p.thread ? [carregar(p.thread)] : threads.values()) {
351
+ if (t.portao && t.portao.responderPergunta(id, p.respostas)) return { ok: true };
352
+ }
353
+ throw erro(ERRO.NAO_ACHEI, `não há pergunta pendente ${id}`);
354
+ },
355
+
356
+ /* ── Projetos (pasta + ícone + cor + descrição) ── */
357
+ 'project/list'(p) { return { projetos: Projetos.listar({ arquivados: !!p.arquivados }) }; },
358
+ 'project/create'(p) {
359
+ try { const r = Projetos.criar(p); difundir('project/changed', { id: r.id }); return r; } catch (e) { throw erro(ERRO.PARAMS, e.message); }
360
+ },
361
+ 'project/update'(p) {
362
+ try { const r = Projetos.atualizar(p.id, p); difundir('project/changed', { id: r.id }); return r; } catch (e) { throw erro(/não achei/.test(e.message) ? ERRO.NAO_ACHEI : ERRO.PARAMS, e.message); }
363
+ },
364
+ 'project/delete'(p) {
365
+ try { const r = Projetos.arquivar(p.id); difundir('project/changed', { id: r.id }); return r; } catch (e) { throw erro(ERRO.NAO_ACHEI, e.message); }
366
+ },
367
+
368
+ /* ── Kit da marca (lib/marca-kit.js) ── */
369
+ 'brand/read'(p) {
370
+ const vista = (k) => ({
371
+ nome: k.nome, cores: k.cores || {}, fontes: k.fontes || {}, tom: k.tom || '', slogan: k.slogan || '',
372
+ site: k.site || '', redes: k.redes || '', temLogo: !!k.logo, logo: k.logo || null, contraste: Marca.validarContraste(k.cores || {}),
373
+ });
374
+ const ativa = (() => { try { return fs.readFileSync(path.join(Marca.raiz(), '.ativa'), 'utf8').trim() || null; } catch { return null; } })();
375
+ if (p.nome) { const k = Marca.ler(p.nome); if (!k) throw erro(ERRO.NAO_ACHEI, `não há kit "${p.nome}"`); return { kit: vista(k), ativa }; }
376
+ return { kits: Marca.listar().map(vista), ativa };
377
+ },
378
+ 'brand/write'(p) {
379
+ const nome = texto(p.nome).trim();
380
+ if (!nome) throw erro(ERRO.PARAMS, 'falta nome');
381
+ if (p.apagar) { const r = Marca.apagar(nome); if (!r.ok) throw erro(ERRO.NAO_ACHEI, r.error); return { apagado: true }; }
382
+ const atual = Marca.ler(nome) || {};
383
+ const m = { ...atual, nome };
384
+ for (const k of ['tom', 'slogan', 'site', 'redes']) if (k in p) m[k] = texto(p[k]).slice(0, 300);
385
+ if (p.cores && typeof p.cores === 'object') m.cores = { ...(atual.cores || {}), ...Object.fromEntries(Object.entries(p.cores).map(([k, v]) => [k, Marca.normHex(v) || v])) };
386
+ if (p.fontes && typeof p.fontes === 'object') m.fontes = { ...(atual.fontes || {}), titulo: texto(p.fontes.titulo || (atual.fontes || {}).titulo), texto: texto(p.fontes.texto || (atual.fontes || {}).texto) };
387
+ // Logo vindo da tela: um data URL png ou svg de até 1 MB, gravado num arquivo temporário e copiado para o kit.
388
+ let logoArquivo = null;
389
+ if (p.logo && typeof p.logo.dataUrl === 'string') {
390
+ const mm = /^data:image\/(png|svg\+xml);base64,([A-Za-z0-9+/=]+)$/.exec(p.logo.dataUrl);
391
+ if (!mm) throw erro(ERRO.PARAMS, 'o logo tem que ser png ou svg');
392
+ const bytes = Buffer.from(mm[2], 'base64');
393
+ if (bytes.length > 1048576) throw erro(ERRO.PARAMS, 'o logo passa de 1 MB');
394
+ logoArquivo = path.join(os.tmpdir(), `primo-logo-${process.pid}-${Date.now()}.${mm[1] === 'png' ? 'png' : 'svg'}`);
395
+ fs.writeFileSync(logoArquivo, bytes, { mode: 0o600 });
396
+ }
397
+ let r;
398
+ try { r = Marca.salvar(m, { logoArquivo }); } finally { if (logoArquivo) { try { fs.unlinkSync(logoArquivo); } catch { /* já saiu */ } } }
399
+ if (!r.ok) throw erro(ERRO.PARAMS, r.error);
400
+ if (p.ativar) Marca.usar(nome);
401
+ return metodos['brand/read']({ nome });
402
+ },
403
+
404
+ /* ── Agentes ── */
405
+ 'agent/list'() { return { agentes: Agentes.listar({ dir: dirAgentes }), piloto: pilotoEstado() }; },
406
+ 'agent/create'(p) { return agenteOp(() => Agentes.criar(p, { dir: dirAgentes })); },
407
+ 'agent/update'(p) { return agenteOp(() => Agentes.atualizar(p.nome, p, { dir: dirAgentes })); },
408
+ 'agent/delete'(p) { return agenteOp(() => Agentes.apagar(p.nome, { dir: dirAgentes })); },
409
+ 'agent/toggle'(p) { return agenteOp(() => Agentes.alternar(p.nome, typeof p.ligado === 'boolean' ? p.ligado : undefined, { dir: dirAgentes })); },
410
+
411
+ /* ── Piloto automático e avisos ── */
412
+ 'autopilot/set'(p) {
413
+ const mudar = {};
414
+ if ('ligado' in p) mudar.piloto = p.ligado === true;
415
+ if ('grupo' in p) mudar.grupo = p.grupo === true;
416
+ if (!Object.keys(mudar).length) throw erro(ERRO.PARAMS, 'diga ligado e/ou grupo');
417
+ cfg.saveConfig(mudar);
418
+ agendarPiloto();
419
+ const r = pilotoEstado();
420
+ difundir('agent/changed', { piloto: r });
421
+ return r;
422
+ },
423
+ async 'autopilot/run'() {
424
+ if (pilotoRodando) throw erro(ERRO.OCUPADO, 'o piloto já está num ciclo');
425
+ return rodarCiclo();
426
+ },
427
+ 'notice/list'() { return Avisos.listar({ arquivo: arqAvisos }); },
428
+ 'notice/read'(p) {
429
+ if (!p.id && !p.todos) throw erro(ERRO.PARAMS, 'diga id ou todos');
430
+ try { const r = Avisos.marcarLido({ id: p.id, todos: !!p.todos }, { arquivo: arqAvisos }); difundir('notice/changed', contagemAvisos()); return r; } catch (e) { throw erro(ERRO.NAO_ACHEI, e.message); }
431
+ },
432
+
433
+ /* ── Pareamento do celular (só o computador, nunca um celular pareado) ── */
434
+ 'pairing/info'() { return { rede: modoRede, porta: portaRede || porta, enderecos: modoRede ? enderecosLocais() : [], pareamentos: Pareamentos.listar() }; },
435
+ 'pairing/create'(p) {
436
+ if (!modoRede) throw erro(ERRO.NEGADO, 'o servidor está só em 127.0.0.1. Suba com `primocode server --ws --rede` para parear um celular');
437
+ const ips = enderecosLocais();
438
+ if (!ips.length) throw erro(ERRO.NEGADO, 'não achei um endereço de rede local neste computador');
439
+ const r = Pareamentos.criar(p.nome);
440
+ const urls = ips.map((ip) => `ws://${ip}:${portaRede || porta}`);
441
+ // O código leva TODOS os endereços: o primeiro vai no ws://, os outros em `&h=` (mesma porta). O app do celular tenta um por um.
442
+ const codigo = `${urls[0]}/?token=${r.token}${ips.length > 1 ? `&h=${ips.slice(1).join(',')}` : ''}`;
443
+ const q = require('../qr').gerarQR(codigo, { ecc: 'M' });
444
+ // A tela desenha o QR com estas linhas ('1' = módulo escuro); nenhum HTML vem do motor.
445
+ return { ...r, urls, codigo, qr: { tamanho: q.tamanho, linhas: q.modulos.map((l) => l.map((b) => (b ? '1' : '0')).join('')) } };
446
+ },
447
+ /* Telas do Estúdio dentro do app (estilos e gosto): o motor escreve a linha de protocolo no stderr (lib/plugin/estudio/gosto.js),
448
+ o servidor a vira `screen/open` e a tela mostra a página num quadro. `screen/dismiss` fecha pelo botão Fechar do quadro. */
449
+ async 'screen/dismiss'(p, conn) {
450
+ if (conn && conn.pareado) throw erro(ERRO.NEGADO, 'só o computador');
451
+ const url = require('../plugin/estudio/gosto').urlDaLinha(`primocode:abrir-gosto ${texto(p.url)}`);
452
+ if (!url) throw erro(ERRO.PARAMS, 'url inválida');
453
+ const u = new URL(url);
454
+ await new Promise((ok) => {
455
+ const rq = http.request({ host: '127.0.0.1', port: u.port, path: '/fechar', method: 'POST', headers: { 'x-gosto-token': u.searchParams.get('t'), 'content-length': 0 } }, (r) => { r.resume(); r.on('end', ok); });
456
+ rq.on('error', ok); rq.setTimeout(3000, () => { rq.destroy(); ok(); }); rq.end();
457
+ });
458
+ return { ok: true };
459
+ },
460
+ /* Liga/desliga o modo rede sem reiniciar (botão "Conectar celular"). Só o computador. */
461
+ async 'pairing/enable'(p, conn) {
462
+ if (conn && conn.pareado) throw erro(ERRO.NEGADO, 'só o computador');
463
+ const r = await mudarRede(p.ligar !== false);
464
+ return { ...r, enderecos: modoRede ? enderecosLocais() : [] };
465
+ },
466
+ 'pairing/list'() { return { pareamentos: Pareamentos.listar() }; },
467
+ 'pairing/revoke'(p) {
468
+ const id = texto(p.id);
469
+ if (!Pareamentos.revogar(id)) throw erro(ERRO.NAO_ACHEI, 'não existe esse pareamento');
470
+ for (const c of [...conexoes]) if (c.pareado && c.pareado.id === id && c.fechar) c.fechar();
471
+ return { revogado: id, pareamentos: Pareamentos.listar() };
472
+ },
473
+
474
+ 'config/read'(p, conn) {
475
+ const c = cfg.loadConfig();
476
+ const out = {};
477
+ for (const k of CAMPOS_CONFIG) out[k] = c[k];
478
+ out.servidor = c.server;
479
+ out.temToken = !!c.token;
480
+ out.projetoAtual = c.currentProject;
481
+ out.github = githubConectado();
482
+ if (conn && conn.pareado) out.pareado = true;
483
+ return out;
484
+ },
485
+ 'config/write'(p, conn) {
486
+ const mudar = {};
487
+ const campos = conn && conn.pareado ? CAMPOS_CONFIG_PAREADO : CAMPOS_CONFIG;
488
+ for (const k of campos) if (k in p) mudar[k] = p[k];
489
+ if (conn && conn.pareado && !Object.keys(mudar).length && CAMPOS_CONFIG.some((k) => k in p)) throw erro(ERRO.NEGADO, 'o celular só muda as preferências; o resto é do computador');
490
+ if (!Object.keys(mudar).length) throw erro(ERRO.PARAMS, `nada para gravar (campos: ${CAMPOS_CONFIG.join(', ')})`);
491
+ cfg.saveConfig(mudar);
492
+ return metodos['config/read']({}, conn);
493
+ },
494
+
495
+ /* ── Conta (login do app): só as funções de lib/config/conta.js. Nunca devolve token. ── */
496
+ 'account/status'() { return statusConta(); },
497
+ async 'account/login'(p) {
498
+ const r = await Conta.entrar({ email: texto(p.email), senha: texto(p.senha) }, { buscar: contaOp.buscar });
499
+ if (!r.ok) return { ok: false, error: r.error };
500
+ difundir('account/changed', statusConta());
501
+ return { ok: true, ...statusConta() };
502
+ },
503
+ async 'account/login-browser'() {
504
+ if (loginNavegador) return { ok: true, emAndamento: true, url: loginNavegador.url };
505
+ let urlPronta;
506
+ const aUrl = new Promise((res) => { urlPronta = res; });
507
+ const estado = { url: null };
508
+ loginNavegador = estado;
509
+ Conta.entrarPeloNavegador({ abrir: contaOp.abrir, buscar: contaOp.buscar, aviso: (u) => { estado.url = u; urlPronta(u); } })
510
+ .then((r) => difundir('account/changed', { ...statusConta(), ...(r && r.ok ? {} : { erro: (r && r.error) || 'o login não concluiu.' }) }))
511
+ .catch((e) => difundir('account/changed', { ...statusConta(), erro: e.message }))
512
+ .finally(() => { loginNavegador = null; urlPronta(null); });
513
+ const url = await aUrl;
514
+ return { ok: true, iniciado: true, url };
515
+ },
516
+ 'account/logout'() {
517
+ Conta.sair();
518
+ difundir('account/changed', statusConta());
519
+ return { ok: true, ...statusConta() };
520
+ },
521
+
522
+ /* ── Voz (6.9): provedor trocável em lib/motor/voz.js; só gera arquivo, nunca toca ── */
523
+ async 'voice/start'(p, conn) { return Voz.iniciar({ texto: conn && conn.pareado ? undefined : p && p.texto }); }, // o celular não manda o computador falar
524
+ 'voice/stop'() { return Voz.parar(); },
525
+ /* Escuta (bug 3): o renderer grava (MediaRecorder), manda o WAV em base64 e recebe o texto. Whisper local; sem rede. */
526
+ async 'voice/transcribe'(p) {
527
+ const T = require('../motor/transcricao');
528
+ const r = await T.transcreverBase64(p && p.audio, { pasta: path.join(base, 'voz'), ...(transcritor ? { transcritor } : {}) });
529
+ if (!r.ok) return { ok: false, error: r.error, comoResolver: r.comoResolver };
530
+ return { ok: true, texto: r.texto };
531
+ },
532
+
533
+ /* ── Studio: histórico do que o Estúdio gerou e abrir no sistema ── */
534
+ 'studio/history'(p) {
535
+ const r = require('../plugin/estudio/historico').historico({ busca: texto(p.busca), limite: p.limite });
536
+ return { ...r, tipos: Object.keys(require('../plugin/estudio/criar').QUADRADOS) };
537
+ },
538
+ 'studio/open'(p) {
539
+ let abs = caminhoSeguro(p.path);
540
+ if (!fs.existsSync(abs)) throw erro(ERRO.NAO_ACHEI, 'não achei o arquivo');
541
+ if (p.pasta && !fs.statSync(abs).isDirectory()) abs = path.dirname(abs);
542
+ (abrir || abrirNoSistema)(abs);
543
+ return { aberto: abs };
544
+ },
545
+
546
+ 'fs/list'(p) {
547
+ const abs = caminhoSeguro(p.path);
548
+ let itens;
549
+ try { itens = fs.readdirSync(abs, { withFileTypes: true }); } catch (e) { throw erro(ERRO.NAO_ACHEI, `não consegui listar: ${e.code || e.message}`); }
550
+ return {
551
+ path: abs,
552
+ itens: itens.filter((d) => !BLOQUEADOS.test(path.join(abs, d.name)))
553
+ .map((d) => ({ nome: d.name, tipo: d.isDirectory() ? 'pasta' : 'arquivo' }))
554
+ .sort((a, b) => (a.tipo === b.tipo ? a.nome.localeCompare(b.nome) : a.tipo === 'pasta' ? -1 : 1)),
555
+ };
556
+ },
557
+ 'fs/read'(p) {
558
+ const abs = caminhoSeguro(p.path);
559
+ let st;
560
+ try { st = fs.statSync(abs); } catch (e) { throw erro(ERRO.NAO_ACHEI, `não achei: ${e.code || e.message}`); }
561
+ if (!st.isFile()) throw erro(ERRO.PARAMS, 'não é arquivo');
562
+ if (st.size > MAX_LEITURA) throw erro(ERRO.PARAMS, `grande demais (limite ${MAX_LEITURA / 1024} KB)`);
563
+ return { path: abs, bytes: st.size, conteudo: fs.readFileSync(abs, 'utf8') };
564
+ },
565
+ };
566
+
567
+
568
+ /* ── Agentes e piloto: helpers ── */
569
+ function agenteOp(fn) {
570
+ try { const r = fn(); difundir('agent/changed', { nome: r.nome }); return r; } catch (e) { throw erro(/não achei/.test(e.message) ? ERRO.NAO_ACHEI : ERRO.PARAMS, e.message); }
571
+ }
572
+ const pilotoEstado = () => { const c = cfg.loadConfig(); return { ligado: c.piloto === true, grupo: c.grupo === true, rodando: pilotoRodando }; };
573
+ const algumRodando = () => [...threads.values()].some((x) => x.rodando);
574
+
575
+ /* Um agente do piloto roda como uma conversa própria, com o ofício na frente. Aprovações viram aviso. */
576
+ async function executarAgente(agente, pedido) {
577
+ const c = cfg.loadConfig();
578
+ const dados = { id: `piloto-${agente.nome}-${Date.now().toString(36)}`, titulo: `@${agente.nome} (piloto)`, criada: Date.now(), atualizada: Date.now(), projeto: null, arquivada: true, historico: [] };
579
+ const t = { dados, dono: null, assinantes: new Set(), rodando: true, agente: agente.nome };
580
+ threads.set(dados.id, t);
581
+ const emitir = emissorDe(t);
582
+ t.portao = criarPortao({ emitir });
583
+ const proj = agente.onde && agente.onde !== 'fora' ? Projetos.pastaDe(agente.onde) : null;
584
+ try {
585
+ const r = await m.iniciarTurno({
586
+ texto: pedido, emitir, portao: t.portao, autorizar: t.portao.autorizar, perguntar: t.portao.perguntar,
587
+ server: c.server, model: c.model, effort: c.effort, token: c.token, history: [],
588
+ projectDir: proj || undefined, projectName: proj ? path.basename(proj) : undefined,
589
+ permissoes: 'perguntar', semTerminal: false,
590
+ instrucao: `Seu ofício:\n${agente.oficio}`,
591
+ });
592
+ return r && r.assistantMessage ? textoDe(r.assistantMessage.content) : '';
593
+ } finally {
594
+ t.rodando = false;
595
+ if (t.portao) t.portao.cancelar();
596
+ threads.delete(dados.id);
597
+ }
598
+ }
599
+ const textoDe = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => (x && x.text) || '').join('') : '');
600
+
601
+ async function rodarCiclo() {
602
+ pilotoRodando = true;
603
+ difundir('agent/changed', { piloto: pilotoEstado() });
604
+ try {
605
+ const c = cfg.loadConfig();
606
+ const r = await Piloto.ciclo({
607
+ agentes: Agentes.listar({ dir: dirAgentes }), grupo: c.grupo === true,
608
+ executar: executarAgente, ocupado: () => [...threads.values()].some((x) => x.rodando && !x.agente),
609
+ freio: opcoesPiloto.freio,
610
+ });
611
+ for (const e of r.executados) novoAviso({ tipo: 'aviso', nivel: e.ok ? 'ok' : 'erro', texto: `@${e.agente}: ${e.resumo || (e.ok ? 'terminou' : 'falhou')}`, agente: e.agente });
612
+ return r;
613
+ } finally { pilotoRodando = false; difundir('agent/changed', { piloto: pilotoEstado() }); }
614
+ }
615
+ const opcoesPiloto = { freio: undefined };
616
+ function agendarPiloto() {
617
+ if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
618
+ if (!pilotoLigado()) return;
619
+ timerPiloto = setInterval(() => { if (!pilotoRodando && !algumRodando()) rodarCiclo().catch(() => {}); }, PILOTO_MS);
620
+ timerPiloto.unref();
621
+ }
622
+ agendarPiloto();
623
+
624
+ /* ── Despacho: uma mensagem de uma conexão ── */
625
+ async function tratar(linha, conn) {
626
+ const r = P.interpretar(linha);
627
+ if (r.vazio || r.resposta) return;
628
+ if (r.erro) { conn.enviar(r.erro); return; }
629
+ const { msg } = r;
630
+ const temId = 'id' in msg;
631
+ const fn = Object.prototype.hasOwnProperty.call(metodos, msg.method) ? metodos[msg.method] : null;
632
+ if (fn && conn.pareado && !PAREADO_PODE.has(msg.method)) {
633
+ if (temId) conn.enviar(P.respostaErro(msg.id, ERRO.NEGADO, `um celular pareado não pode usar ${msg.method}`));
634
+ return;
635
+ }
636
+ if (fn && conn.pareado) Pareamentos.marcarUso(conn.pareado.id);
637
+ if (!fn) { if (temId) conn.enviar(P.respostaErro(msg.id, ERRO.METODO, `método não existe: ${msg.method}`)); return; }
638
+ try {
639
+ const res = await fn(msg.params || {}, conn);
640
+ if (temId) conn.enviar(P.resposta(msg.id, res));
641
+ } catch (e) {
642
+ if (!temId) return;
643
+ if (e && e.codigo) conn.enviar(P.respostaErro(msg.id, e.codigo, e.message, e.data));
644
+ else conn.enviar(P.respostaErro(msg.id, ERRO.INTERNO, e && e.message));
645
+ }
646
+ }
647
+
648
+ function soltar(conn) {
649
+ conexoes.delete(conn);
650
+ for (const t of threads.values()) { if (t.dono === conn) t.dono = null; t.assinantes.delete(conn); }
651
+ }
652
+
653
+ /* ── Transporte stdio ── */
654
+ function stdio(entrada = process.stdin, saida = process.stdout) {
655
+ const conn = { enviar: (obj) => { try { saida.write(P.serializar(obj)); } catch { /* pipe fechou */ } } };
656
+ conexoes.add(conn);
657
+ const corta = P.cortador();
658
+ entrada.setEncoding && entrada.setEncoding('utf8');
659
+ entrada.on('data', (d) => { for (const l of corta.push(d)) tratar(l, conn); });
660
+ entrada.on('end', () => { const s = corta.sobra(); if (s) tratar(s, conn); soltar(conn); });
661
+ return conn;
662
+ }
663
+
664
+ /* ── Transporte WebSocket: token na URL (?token=) ou em Authorization: Bearer ──
665
+ O token de sessão só vale vindo do próprio computador; da rede só entra token de pareamento. */
666
+ const iguais = (a, b) => { const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && crypto.timingSafeEqual(x, y); };
667
+ function autenticar(req) {
668
+ let dado = '';
669
+ try { dado = new URL(req.url, 'http://127.0.0.1').searchParams.get('token') || ''; } catch { /* url torta */ }
670
+ const auth = String(req.headers.authorization || '');
671
+ if (!dado && /^Bearer /i.test(auth)) dado = auth.slice(7).trim();
672
+ if (!dado) return null;
673
+ if (iguais(dado, token)) return LOOPBACK.has(req.socket.remoteAddress) ? { tipo: 'mestre' } : null;
674
+ const r = Pareamentos.validar(dado);
675
+ return r ? { tipo: 'pareado', pareado: r } : null;
676
+ }
677
+ const tokenBate = (req) => !!autenticar(req);
678
+ function enderecosLocais() {
679
+ if (enderecos) return require('./enderecos').ordenar(enderecos()); // teste: endereços de mentira
680
+ return require('./enderecos').locais(os.networkInterfaces()); // LAN primeiro, VPN (100.64/10, utun) por último
681
+ }
682
+ /* Anti-rebind: no padrão só 127.0.0.1/localhost; com --rede também o IP (literal) de uma interface do computador. Nome de host nunca. */
683
+ function hostPermitido(cabecalho) {
684
+ const host = String(cabecalho || '').replace(/:\d+$/, '');
685
+ if (host === '127.0.0.1' || host === 'localhost') return true;
686
+ return modoRede && /^\d{1,3}(\.\d{1,3}){3}$/.test(host) && enderecosLocais().includes(host);
687
+ }
688
+
689
+ /* GET / e arquivos de app/renderer: só 127.0.0.1 (Host conferido contra rebind). O token
690
+ continua exigido no WebSocket; a página só entrega o HTML e o JS. */
691
+ function estatico(req, res) {
692
+ const nega = (c, m) => { res.writeHead(c, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(m); };
693
+ if (!hostPermitido(req.headers.host)) return nega(403, 'host não permitido');
694
+ if (req.method !== 'GET' && req.method !== 'HEAD') return nega(426, 'use WebSocket');
695
+ if (/^\/midia(\?|$)/.test(req.url)) return midia(req, res, nega);
696
+ // O app do celular pergunta se o token ainda vale (revogado?). Só diz sim ou não ao dono do token; CORS aberto porque a página do celular é de outra origem.
697
+ if (/^\/pareamento\/valido(\?|$)/.test(req.url)) {
698
+ const q = autenticar(req);
699
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*' });
700
+ return res.end(JSON.stringify({ valido: !!(q && q.tipo === 'pareado') }));
701
+ }
702
+ if (!pastaTela) return nega(426, 'use WebSocket');
703
+ let rel;
704
+ try { rel = decodeURIComponent(new URL(req.url, 'http://127.0.0.1').pathname); } catch { return nega(400, 'url torta'); }
705
+ if (rel === '/') rel = '/index.html';
706
+ const abs = path.join(pastaTela, rel);
707
+ if (abs !== pastaTela && !abs.startsWith(pastaTela + path.sep)) return nega(403, 'fora da pasta');
708
+ fs.readFile(abs, (e, dados) => {
709
+ if (e) return nega(404, 'não achei');
710
+ res.writeHead(200, {
711
+ 'Content-Type': TIPOS_ESTATICOS[path.extname(abs)] || 'application/octet-stream',
712
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
713
+ });
714
+ res.end(req.method === 'HEAD' ? undefined : dados);
715
+ });
716
+ }
717
+
718
+ /* GET /midia?path=…&token=…: a prévia do que o Estúdio gerou (vídeo, PDF, imagem). Exige o token,
719
+ só serve dentro das raízes permitidas e só as extensões de mídia; vídeo aceita Range. */
720
+ function midia(req, res, nega) {
721
+ if (!tokenBate(req)) return nega(401, 'sem token');
722
+ let abs;
723
+ try { abs = caminhoSeguro(new URL(req.url, 'http://127.0.0.1').searchParams.get('path')); } catch { return nega(403, 'caminho não permitido'); }
724
+ const tipo = MIDIAS[path.extname(abs).toLowerCase()];
725
+ if (!tipo) return nega(403, 'tipo não permitido');
726
+ let st; try { st = fs.statSync(abs); if (!st.isFile()) throw new Error('x'); } catch { return nega(404, 'não achei'); }
727
+ const cab = { 'Content-Type': tipo, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
728
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
729
+ let ini = 0; let fim = st.size - 1; let codigo = 200;
730
+ if (m && (m[1] || m[2])) {
731
+ if (m[1]) { ini = Number(m[1]); if (m[2]) fim = Math.min(fim, Number(m[2])); } else { ini = Math.max(0, st.size - Number(m[2])); }
732
+ if (ini > fim || ini >= st.size) { res.writeHead(416, { 'Content-Range': `bytes */${st.size}` }); return res.end(); }
733
+ codigo = 206; cab['Content-Range'] = `bytes ${ini}-${fim}/${st.size}`;
734
+ }
735
+ cab['Content-Length'] = st.size ? fim - ini + 1 : 0;
736
+ res.writeHead(codigo, cab);
737
+ if (req.method === 'HEAD' || !st.size) return res.end();
738
+ fs.createReadStream(abs, { start: ini, end: fim }).on('error', () => res.destroy()).pipe(res);
739
+ }
740
+
741
+ /* Um servidor HTTP+WebSocket com as regras do motor. Um só em 127.0.0.1; com a rede ligada, um segundo em 0.0.0.0 (porta própria). */
742
+ function criarHttp() {
743
+ const srv = http.createServer(estatico);
744
+ srv.on('upgrade', (req, socket) => {
745
+ const recusar = (codigo, frase) => { socket.write(`HTTP/1.1 ${codigo} ${frase}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
746
+ const quem = autenticar(req);
747
+ if (!quem) return recusar(401, 'Unauthorized');
748
+ if (String(req.headers.upgrade || '').toLowerCase() !== 'websocket' || !req.headers['sec-websocket-key']) return recusar(400, 'Bad Request');
749
+ const conn = quem.pareado ? { pareado: quem.pareado } : {};
750
+ const sock = WS.aceitar(req, socket, { aoTexto: (t) => tratar(t, conn), aoFechar: () => soltar(conn) });
751
+ conn.enviar = (obj) => sock.enviar(JSON.stringify(obj, (k, v) => (v instanceof Error ? { mensagem: v.message, nome: v.name } : v)));
752
+ conn.fechar = sock.fechar;
753
+ conexoes.add(conn);
754
+ });
755
+ return srv;
756
+ }
757
+
758
+ function ws({ porta: pedida = 0, rede: pedirRede } = {}) {
759
+ if (pedirRede !== undefined) modoRede = !!pedirRede;
760
+ return new Promise((resolve, reject) => {
761
+ http_ = criarHttp();
762
+ http_.on('error', reject);
763
+ http_.listen(pedida, modoRede ? '0.0.0.0' : '127.0.0.1', () => {
764
+ porta = http_.address().port;
765
+ try {
766
+ fs.mkdirSync(base, { recursive: true });
767
+ fs.writeFileSync(arquivoInfo, JSON.stringify({ porta, token, pid: process.pid, rede: modoRede }), { mode: 0o600 });
768
+ fs.chmodSync(arquivoInfo, 0o600);
769
+ } catch { /* sem gravar, o cliente ainda recebe porta e token pelo retorno */ }
770
+ resolve({ porta, token });
771
+ });
772
+ });
773
+ }
774
+
775
+ /* Liga ou desliga o modo rede em tempo de execução (o mesmo do --rede), sem reiniciar o app: sobe um segundo servidor em 0.0.0.0
776
+ (porta própria, a do QR). O de 127.0.0.1 não muda. Desligar derruba os celulares conectados. */
777
+ let redeSrv = null;
778
+ let portaRede = null;
779
+ async function mudarRede(ligar) {
780
+ if (ligar && !modoRede) {
781
+ if (!http_) throw erro(ERRO.NEGADO, 'o servidor ainda não está no ar');
782
+ await new Promise((ok, falha) => { const s2 = criarHttp(); s2.once('error', falha); s2.listen(0, '0.0.0.0', () => { redeSrv = s2; portaRede = s2.address().port; ok(); }); });
783
+ modoRede = true;
784
+ } else if (!ligar && modoRede && redeSrv) {
785
+ for (const c of [...conexoes]) if (c.pareado && c.fechar) c.fechar();
786
+ const s2 = redeSrv; redeSrv = null; portaRede = null; modoRede = false;
787
+ await new Promise((ok) => { s2.close(() => ok()); if (s2.closeAllConnections) s2.closeAllConnections(); setTimeout(ok, 500); });
788
+ }
789
+ return { rede: modoRede, porta: portaRede || porta };
790
+ }
791
+
792
+ function fechar() {
793
+ if (olharStderr) process.stderr.write = escreverStderr;
794
+ if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
795
+ for (const c of [...conexoes]) if (c.fechar) c.fechar();
796
+ conexoes.clear();
797
+ try { if (porta && JSON.parse(fs.readFileSync(arquivoInfo, 'utf8')).token === token) fs.rmSync(arquivoInfo, { force: true }); } catch { /* nada a limpar */ }
798
+ if (redeSrv) { try { redeSrv.close(); redeSrv.closeAllConnections && redeSrv.closeAllConnections(); } catch { /* já fechou */ } redeSrv = null; }
799
+ return new Promise((res) => { if (http_) { http_.close(() => res()); http_.closeAllConnections && http_.closeAllConnections(); } else res(); });
800
+ }
801
+
802
+ return { stdio, ws, fechar, tratar, metodos: Object.keys(metodos), token, arquivoInfo, pastaThreads, opcoesPiloto };
803
+ }
804
+
805
+ module.exports = { criarServidor };