primocode 10.15.0 → 10.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.15.0
1
+ # PrimoCode v10.15.1
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v10.15.0
12
+ ▐▛███▜▌ Primo Code v10.15.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -2519,6 +2519,7 @@ async function handleSlash(text) {
2519
2519
  break;
2520
2520
  }
2521
2521
  for (const a of prep.avisos) console.log(c.dim(` ${a}`));
2522
+ if (process.platform === 'darwin') { try { vozModo.instalarApp({ python: prep.python }); } catch { /* o app é bônus */ } }
2522
2523
 
2523
2524
  const sessao = vozModo.abrir({
2524
2525
  python: prep.python,
@@ -2922,7 +2923,14 @@ async function main() {
2922
2923
  r.processo.on('close', () => process.exit(0));
2923
2924
  return;
2924
2925
  }
2925
- console.log('Uso: primocode voz nome <Nome> | primocode voz painel (o resto é /voice dentro da sessão)');
2926
+ if (sub === 'instalar-app') {
2927
+ const py = vz.python();
2928
+ if (!py) { console.log('O app precisa do Python 3 (rode /voice uma vez para preparar o ambiente).'); process.exit(1); }
2929
+ const r = vz.instalarApp({ python: py, dock: resto.includes('--dock') });
2930
+ console.log(r.ok ? `Primo.app instalado em ${r.app}. Abra pelo Launchpad, Spotlight ou Finder.` : `Não deu: ${r.erro || 'veja o log'}`);
2931
+ process.exit(r.ok ? 0 : 1);
2932
+ }
2933
+ console.log('Uso: primocode voz nome <Nome> | primocode voz painel | primocode voz instalar-app [--dock] (o resto é /voice dentro da sessão)');
2926
2934
  process.exit(0);
2927
2935
  }
2928
2936
  if (['doutor', 'doctor', 'preparar'].includes(process.argv[2])) {
package/lib/conta.js CHANGED
@@ -213,6 +213,7 @@ async function entrar({ email, senha } = {}, op = {}) {
213
213
  }
214
214
 
215
215
  function abrirNoNavegador(url) {
216
+ if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
216
217
  const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
217
218
  : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
218
219
  : ['xdg-open', [url]];
@@ -156,6 +156,7 @@ function registrar({ id, assunto, caminho, formato, duracao, assinatura, entrega
156
156
  }
157
157
  const listar = (arq) => ler(arq).filter((v) => v.caminho && fs.existsSync(v.caminho)).sort((a, b) => String(b.data).localeCompare(String(a.data)));
158
158
  function abrirNoSistema(alvo) {
159
+ if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
159
160
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
160
161
  const args = process.platform === 'win32' ? ['/c', 'start', '""', alvo] : [alvo];
161
162
  return spawnSync(cmd, args, { stdio: 'ignore' }).status === 0;
package/lib/mcp.js CHANGED
@@ -204,6 +204,7 @@ function conectarHttp(def, { tetoMs = 60000, nome, buscar = globalThis.fetch } =
204
204
  const b64url = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
205
205
 
206
206
  function abrirUrl(url) {
207
+ if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
207
208
  const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
208
209
  : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
209
210
  : ['xdg-open', [url]];
package/lib/tools.js CHANGED
@@ -304,6 +304,7 @@ async function runShell(command, timeout = 60000, cwd, env) {
304
304
  // UM argumento, não texto que um shell reinterpreta — sem isso, um valor tipo
305
305
  // `https://x/"; touch /tmp/pwned; echo "` injetava comando (achado de segurança).
306
306
  function openInBrowser(target) {
307
+ if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return Promise.resolve({ ok: false, error: 'o navegador está bloqueado no modo voz: só abre quando a pessoa clica' }); // app do Primo (voz): navegador só com clique explícito
307
308
  const p = os.platform();
308
309
  const [cmd, args, opener] = p === 'darwin' ? ['open', [target], 'open']
309
310
  : p === 'win32' ? ['cmd.exe', ['/c', 'start', '', target], 'start']
package/lib/voz.js CHANGED
@@ -322,6 +322,7 @@ function abrir({ python: py, servidor, token, voz, aoParar = () => {}, aoOuvir,
322
322
  const OPCIONAIS = () => [
323
323
  ['faster_whisper', 'faster-whisper', 'a transcrição local (Whisper)'],
324
324
  ['pysilero_vad', 'pysilero-vad', 'o fim de fala preciso (Silero VAD)'],
325
+ ['edge_tts', 'edge-tts', 'a voz neural pt-BR (Edge TTS)'],
325
326
  ...(process.platform === 'darwin' ? [
326
327
  ['WebKit', 'pyobjc-framework-WebKit', 'o painel em tela cheia do Primo'],
327
328
  ['EventKit', 'pyobjc-framework-EventKit', 'os compromissos de hoje do app Calendário'],
@@ -466,11 +467,34 @@ async function preparar(aoFalar = () => {}) {
466
467
  return { ok: true, python: py, avisos };
467
468
  }
468
469
 
470
+ /** Primo.app em ~/Applications (Launchpad/Spotlight) + o primo-app.json com os caminhos ABSOLUTOS de node e primocode:
471
+ * o app aberto pelo Finder não tem o PATH do terminal. Chamado no primeiro /voice e por `primocode voz instalar-app`. */
472
+ function instalarApp({ python: py, dock = false } = {}) {
473
+ if (process.platform !== 'darwin') return { ok: false, erro: 'o app do Primo é só para macOS' };
474
+ try {
475
+ let cfg = {};
476
+ try { cfg = require('./config').loadConfig(); } catch { /* usa o padrão do app */ }
477
+ const arq = path.join(os.homedir(), '.primocode', 'primo-app.json');
478
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
479
+ fs.writeFileSync(arq, JSON.stringify({
480
+ node: process.execPath, primocode: path.resolve(__dirname, '..', 'bin', 'primocode.js'),
481
+ servidor: cfg.server || '', path: process.env.PATH || '', voz: cfg.voz || 'antonio', versao: require('../package.json').version,
482
+ }, null, 2));
483
+ const env = { ...process.env, PYTHONPATH: PASTA, ...(dock ? { PRIMOCODE_APP_DOCK: '1' } : {}) };
484
+ const linhas = execFileSync(py, ['-m', 'primo_voz.appmac'], { cwd: PASTA, env, encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n');
485
+ return { ok: !!linhas[0], exe: linhas[0] || null, pythonpath: linhas[1] || PASTA, app: path.join(os.homedir(), 'Applications', 'Primo.app') };
486
+ } catch (e) { return { ok: false, erro: e.message }; }
487
+ }
488
+
469
489
  /** `/voice painel`: só o HUD (sem microfone). Mac com PyObjC = tela cheia; senão, o quadro no terminal. */
470
490
  function abrirPainel({ python: py }) {
471
491
  const mac = process.platform === 'darwin' && temModulo(py, 'WebKit');
472
- const p = spawn(py, ['-m', mac ? 'primo_voz.hud' : 'primo_voz.painel_tui', ...(mac ? ['--livre'] : [])], {
473
- cwd: PASTA, env: { ...process.env, PYTHONPATH: PASTA }, stdio: mac ? 'ignore' : ['pipe', 'inherit', 'inherit'],
492
+ // No Mac o painel roda pelo Primo.app (Dock e Cmd+Tab mostram "Primo" com o ícone); sem ele, o python direto.
493
+ let exe = null, pp = PASTA;
494
+ if (mac) { const r = instalarApp({ python: py }); if (r.exe) { exe = r.exe; pp = r.pythonpath; } }
495
+ const [cmd, args] = exe ? [exe, ['-m', 'primo_voz.hud', '--livre']] : [py, ['-m', mac ? 'primo_voz.hud' : 'primo_voz.painel_tui', ...(mac ? ['--livre'] : [])]];
496
+ const p = spawn(cmd, args, {
497
+ cwd: PASTA, env: { ...process.env, PYTHONPATH: exe ? pp : PASTA }, stdio: mac ? 'ignore' : ['pipe', 'inherit', 'inherit'],
474
498
  });
475
499
  return { processo: p, tela: mac };
476
500
  }
@@ -505,4 +529,4 @@ function linhasDoutor() {
505
529
  ];
506
530
  }
507
531
 
508
- module.exports = { definirNome, VENV, abrirPainel, linhasDoutor, DICA_COMPUTADOR, paraFalar, conferir, instalar, preparar, porqueOPipFalhou, abrir, python, PASTA };
532
+ module.exports = { instalarApp, definirNome, VENV, abrirPainel, linhasDoutor, DICA_COMPUTADOR, paraFalar, conferir, instalar, preparar, porqueOPipFalhou, abrir, python, PASTA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.15.0",
3
+ "version": "10.15.1",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). 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": {
@@ -31,6 +31,7 @@ from __future__ import annotations
31
31
  import json
32
32
  import os
33
33
  import queue
34
+ import re
34
35
  import shutil
35
36
  import subprocess
36
37
  import sys
@@ -42,12 +43,36 @@ from pathlib import Path
42
43
  AQUI = Path(__file__).resolve().parent
43
44
  sys.path.insert(0, str(AQUI.parent))
44
45
 
45
- from primo_voz import conversa, escuta, identidade, painel, transcrever # noqa: E402
46
+ from primo_voz import conversa, escuta, fala, identidade, painel, transcrever # noqa: E402
46
47
  from primo_voz import tela as dados_tela # noqa: E402
47
48
  from primo_voz.luz import Luz # noqa: E402
48
49
  from primo_voz.sons import Sons # noqa: E402
49
50
 
50
51
 
52
+ class _PonteHud:
53
+ """No modo app o painel mora no processo-pai (app.py): as mensagens dele vão pelo stdout do protocolo."""
54
+
55
+ class _Entrada:
56
+ def write(self, linha: str) -> None:
57
+ try:
58
+ falar_para_o_node({"tipo": "hud", "msg": json.loads(linha)})
59
+ except ValueError:
60
+ pass
61
+
62
+ def flush(self) -> None:
63
+ pass
64
+
65
+ def close(self) -> None:
66
+ pass
67
+
68
+ def __init__(self) -> None:
69
+ self.stdin = self._Entrada()
70
+ self.stdout = iter(())
71
+
72
+ def poll(self):
73
+ return None
74
+
75
+
51
76
  class Bolinha:
52
77
  """A orb (orbe.py) num processo à parte: este só diz o estado a ela.
53
78
 
@@ -62,6 +87,9 @@ class Bolinha:
62
87
  if os.environ.get("PRIMOCODE_SEM_ORB") == "1":
63
88
  return
64
89
  self.ao_acao = None
90
+ if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
91
+ self.modulo, self.p = "primo_voz.hud", _PonteHud()
92
+ return
65
93
  # No Mac com PyObjC o rosto é o HUD (hud.py); senão, a orbe de antes.
66
94
  modulo = "primo_voz.orbe"
67
95
  if sys.platform == "darwin" and os.environ.get("PRIMOCODE_HUD") != "0":
@@ -72,11 +100,19 @@ class Bolinha:
72
100
  pass
73
101
  self.modulo = modulo
74
102
  try:
75
- self.p = subprocess.Popen([sys.executable, "-m", modulo], cwd=str(AQUI.parent),
103
+ cmd = [sys.executable, "-m", modulo]
104
+ env_extra = {"PYTHONPATH": str(AQUI.parent)}
105
+ if modulo.endswith("hud"):
106
+ from primo_voz import appmac # Primo.app: nome e ícone no Dock; cai no python se não der
107
+ exe = appmac.preparar()
108
+ if exe:
109
+ cmd = [exe, "-m", "primo_voz.hud"]
110
+ env_extra = {"PYTHONPATH": appmac.pythonpath()}
111
+ self.p = subprocess.Popen(cmd, cwd=str(AQUI.parent),
76
112
  stdin=subprocess.PIPE,
77
113
  stdout=subprocess.PIPE if modulo.endswith("hud") else subprocess.DEVNULL,
78
114
  stderr=subprocess.DEVNULL,
79
- env={**os.environ, "PYTHONPATH": str(AQUI.parent)}, text=True)
115
+ env={**os.environ, **env_extra}, text=True)
80
116
  if modulo.endswith("hud"):
81
117
  threading.Thread(target=self._ler_acoes, daemon=True).start()
82
118
  except Exception:
@@ -189,6 +225,10 @@ class Voz:
189
225
  self.local = transcrever.disponivel()
190
226
  if self.local:
191
227
  transcrever.aquecer()
228
+ self.edge = fala.Edge()
229
+ self.voz_id = self.cfg.get("voz") if self.cfg.get("voz") in fala.VOZES else "francisca"
230
+ self._t_acabou = None
231
+ threading.Thread(target=self._pre_aquecer_frases, daemon=True).start()
192
232
  self.gravador = escuta.Gravador()
193
233
  self.gravador.vad = escuta.criar_vad()
194
234
  self.corte = escuta.Corte()
@@ -209,6 +249,10 @@ class Voz:
209
249
  self._ultimo_ok = ""
210
250
  self.bolinha.enviar({"nome": self.nome})
211
251
 
252
+ def lat(self, texto: str) -> None:
253
+ if os.environ.get("PRIMOCODE_LAT") == "1":
254
+ avisar("lat: " + texto)
255
+
212
256
  # ── o corpo: painel, luz e som reagem ao mesmo estado ───────────────
213
257
 
214
258
  def _estado(self, estado: str, nivel: float = None) -> None:
@@ -270,7 +314,7 @@ class Voz:
270
314
 
271
315
  def _parcial(self) -> None:
272
316
  """Transcrição ao vivo: a cada ~1,6 s de fala, uma passada no que já foi dito."""
273
- if not self.local or getattr(self, "_parcial_rodando", False):
317
+ if not self.local or getattr(self, "_parcial_rodando", False) or os.environ.get("PRIMOCODE_SEM_PARCIAL") == "1":
274
318
  return
275
319
  copia = list(self.gravador._blocos)
276
320
  if not copia:
@@ -332,7 +376,9 @@ class Voz:
332
376
  wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
333
377
  if not wav:
334
378
  continue
379
+ self._t_acabou = time.time()
335
380
  texto = transcrever.limpar(self._transcrever(wav))
381
+ self.lat(f"transcrição {time.time() - self._t_acabou:.2f}s (a frase fechou após {escuta.RABO_VAD if self.gravador.vad else escuta.RABO_SILENCIO:.1f}s de silêncio) -> {texto!r}")
336
382
  self._tratar(texto)
337
383
  mic.fechar()
338
384
 
@@ -365,6 +411,12 @@ class Voz:
365
411
  else:
366
412
  self._dizer(f"Prazer, {novo['pessoa']}! Vou lembrar.")
367
413
  return
414
+ nova = fala.voz_pedida(texto, self.voz_id)
415
+ if nova:
416
+ self.voz_id = nova
417
+ self.cfg = identidade.salvar(voz=nova)
418
+ self._dizer("Fechado. Agora estou com a voz do Antonio." if nova == "antonio" else f"Fechado. Agora estou com a voz da {nova.capitalize()}.")
419
+ return
368
420
  cmd = conversa.comando(texto)
369
421
  if cmd == "esconder":
370
422
  self.bolinha.enviar({"painel": "esconder"})
@@ -450,28 +502,86 @@ class Voz:
450
502
  except queue.Full:
451
503
  continue
452
504
 
505
+ def _cache_de(self, frase: str) -> "Path | None":
506
+ """Onde fica a versão em cache de uma frase curta (None se for longa demais para valer a pena)."""
507
+ if len(frase) > 90 or self.cfg.get("tts", "edge") == "elevenlabs":
508
+ return None
509
+ import hashlib
510
+ pasta = Path.home() / ".primocode" / "voz-cache"
511
+ pasta.mkdir(parents=True, exist_ok=True)
512
+ if re.match(r"^São \d+ (?:e \d\d|horas em ponto)\.$", frase):
513
+ return pasta / ("hora-" + self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:10] + ".mp3")
514
+ return pasta / (self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:16] + ".mp3")
515
+
516
+ def _cache_horas(self) -> None:
517
+ agora = time.time()
518
+ for dm in (0, 1, 2):
519
+ f = fala.hora_falada(time.localtime(agora + 60 * dm))
520
+ c = self._cache_de(f)
521
+ if c is not None and not c.exists():
522
+ self.edge.sintetizar(f, self.voz_id, c)
523
+
524
+ def _pre_aquecer_frases(self) -> None:
525
+ """As frases que o Primo mais diz entram no cache em segundo plano: a confirmação sai na hora."""
526
+ self.edge.aquecer()
527
+ self._cache_horas()
528
+ for f in ("Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar.", "Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
529
+ "Oi! Fala.", "Pois não?", "Tô aqui.", "Não consegui uma resposta agora."):
530
+ c = self._cache_de(f)
531
+ if c is not None and not c.exists():
532
+ self.edge.sintetizar(f, self.voz_id, c)
533
+ while self._vivo: # a hora do minuto atual e dos próximos: "que horas são?" sai sem esperar a rede
534
+ self._cache_horas()
535
+ for velho in (Path.home() / ".primocode" / "voz-cache").glob("hora-*.mp3"): # só os minutos que já passaram
536
+ try:
537
+ if time.time() - velho.stat().st_mtime > 600:
538
+ velho.unlink()
539
+ except OSError:
540
+ pass
541
+ time.sleep(20)
542
+
453
543
  def _sintetizar(self, frase: str) -> "Path | None":
454
544
  limpo = " ".join(str(frase).split())[:600]
455
545
  if not limpo:
456
546
  return None
547
+ t0 = time.time()
457
548
  mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
549
+ # 0. frase curta já dita antes (ou pré-aquecida): sai do cache, sem rede
550
+ guardada = self._cache_de(limpo)
551
+ if guardada is not None and guardada.exists():
552
+ shutil.copyfile(guardada, mp3)
553
+ self.lat(f"voz do cache ({self.voz_id}): {time.time() - t0:.3f}s")
554
+ return mp3
555
+ # 1. Edge TTS neural pt-BR, no próprio processo (sem subprocesso por frase)
556
+ if self.cfg.get("tts", "edge") != "elevenlabs" and self.edge.sintetizar(limpo, self.voz_id, mp3) and mp3.exists():
557
+ self.lat(f"voz edge/{self.voz_id}: {time.time() - t0:.2f}s para {len(limpo)} caracteres")
558
+ if guardada is not None:
559
+ try:
560
+ shutil.copyfile(mp3, guardada)
561
+ except OSError:
562
+ pass
563
+ return mp3
564
+ # 2. ElevenLabs, só se a pessoa pediu
458
565
  narrar = AQUI.parent.parent / "studio" / "narrar.py"
459
- try:
460
- r = subprocess.run(
461
- [sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz],
462
- capture_output=True, timeout=180)
463
- except Exception as e:
464
- avisar(f"não deu para gerar a voz: {e}")
465
- return None
466
- if r.returncode != 0 or not mp3.exists():
467
- if sys.platform == "darwin" and shutil.which("say"): # reserva: a voz do sistema
468
- aiff = mp3.with_suffix(".aiff")
469
- if subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
470
- return aiff
471
- avisar(motivo_da_narracao(r))
472
- self._erro("Não consegui gerar a voz. O texto está no painel.")
473
- return None
474
- return mp3
566
+ r = None
567
+ if self.cfg.get("tts") == "elevenlabs":
568
+ try:
569
+ r = subprocess.run([sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz_id],
570
+ capture_output=True, timeout=60)
571
+ except Exception as e:
572
+ avisar(f"não deu para gerar a voz: {e}")
573
+ if r is not None and r.returncode == 0 and mp3.exists():
574
+ return mp3
575
+ # 3. offline: a voz do sistema
576
+ if sys.platform == "darwin" and shutil.which("say"):
577
+ aiff = mp3.with_suffix(".aiff")
578
+ if subprocess.run(["say", "-v", "Luciana", "-o", str(aiff), limpo], capture_output=True).returncode == 0 or \
579
+ subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
580
+ self.lat(f"voz offline (say): {time.time() - t0:.2f}s")
581
+ return aiff
582
+ avisar("não deu para gerar a voz (sem internet e sem voz do sistema). O texto está no painel.")
583
+ self._erro("Não consegui gerar a voz. O texto está no painel.")
584
+ return None
475
585
 
476
586
  def _tocar_em_fila(self) -> None:
477
587
  while self._vivo:
@@ -528,6 +638,11 @@ class Voz:
528
638
  except (FileNotFoundError, OSError):
529
639
  continue
530
640
  t0 = time.time()
641
+ if self._t_acabou:
642
+ self.lat(f"fim da fala -> primeira palavra falada: {t0 - self._t_acabou:.2f}s (+ o silêncio que fechou a frase)")
643
+ self._t_acabou = None
644
+ if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
645
+ avisar(f"falando ({cmd[0]}): {arquivo.name}")
531
646
  while p.poll() is None:
532
647
  if env:
533
648
  i = int((time.time() - t0) / 0.05)
@@ -541,6 +656,8 @@ class Voz:
541
656
  pass
542
657
  return
543
658
  time.sleep(0.05)
659
+ if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
660
+ avisar(f"falou por {time.time() - t0:.1f}s (saída {p.returncode})")
544
661
  if p.returncode == 0:
545
662
  return
546
663
  # INICIAR não é TOCAR: o tocador que abriu e morreu não conta, e o
@@ -592,6 +709,8 @@ class Voz:
592
709
  ("dormindo" if est == "esperando" else est))
593
710
  if est == "esperando":
594
711
  self.sons.tocar("pronto", falando=self._falando)
712
+ elif tipo == "acao":
713
+ self._acao_do_painel(str(msg.get("acao") or ""))
595
714
  elif tipo == "painel":
596
715
  self.bolinha.enviar({"painel": msg.get("acao", "alternar")})
597
716
  elif tipo == "fim":