File size: 38,924 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
# openTARS — nucleo/controle.py
# Terminal, informações do PC, mouse (mira, arrasto, conferência) e teclado.
#
# Parte do núcleo: o tars.py executa este arquivo DENTRO do próprio
# namespace (ver _carregar_nucleo), então tudo aqui é tars.<nome> e as
# partes enxergam umas às outras sem import. Os testes que trocam
# tars.<função> por uma falsa continuam valendo pra todas as partes.
# flake8: noqa

# ============================================================
# OUTRAS FERRAMENTAS
# ============================================================

# Padrões de comando destrutivo o suficiente pra merecer uma
# confirmação antes de rodar — a IA (inclusive um modelo pequeno, que
# erra mais) manda qualquer comando pro terminal sem nenhuma trava
# hoje, e um comando mal interpretado nessa lista pode apagar dados ou
# derrubar o sistema sem chance de desfazer. Não é uma lista exaustiva
# de tudo que pode dar errado — é uma rede de segurança pros casos mais
# óbvios e mais caros.
_PADROES_COMANDO_PERIGOSO = [
    r"\brm\b.*\s-[a-zA-Z]*[rR]",  # rm -r/-rf/-fr/-R: apaga pastas inteiras
    r"\brm\b.*--recursive",
    r"\bfind\b.*\s-delete\b",
    r"\bfind\b.*-exec\s+(sudo\s+)?rm\b",    # find ... -exec rm
    r"\brm\b[^|;&\n]*[*?\[]",                  # rm com curinga (*.pdf): apaga vários de uma vez
    r"\bxargs\b.*\brm\b",
    r"\bshred\b",
    r"\bgit\b.*\b(reset\s+--hard|clean\s+-[a-z]*f)",
    r"\bdd\b.*\bif=",
    r"\bmkfs\b",
    r"\bwipefs\b",
    r"\bfdisk\b",
    r"\bparted\b",
    r">\s*/dev/(sd|nvme|hd)",
    r"\bshutdown\b",
    r"\breboot\b",
    r"\bpoweroff\b",
    r"\bhalt\b",
    r"\bchmod\b.*-R.*\s/(\s|$)",
    r"\bchown\b.*-R.*\s/(\s|$)",
    r":\(\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;\s*:",  # fork bomb
    r"\bmv\b[^|;&\n]*\s/dev/null\b",                      # mv algo /dev/null: some com o arquivo
    r"\bmv\b\s+(-\S+\s+)*(/|/\*|~|~/|\$HOME)\s",            # mv da raiz ou da pasta pessoal inteira pra outro lugar
]

_CONFIRMAR_COMANDOS_PERIGOSOS = (
    os.environ.get("TARS_SEM_CONFIRMACAO_PERIGOSOS", "0") != "1"
)

# Função (comando) -> bool que a interface gráfica registra pra
# perguntar numa janela. None = pergunta no terminal com input().
confirmar_comando_hook = None


# 5.0: rotina agendada em andamento (dict da agenda) ou None. Dentro de uma rotina NÃO há pergunta na tela: o comando
# arriscado só roda se a pessoa pré-aprovou essa categoria ao criar a rotina; senão a rotina PARA e avisa.
ROTINA_ATUAL = None
ROTINA_BLOQUEOU = []        # comandos que pararam a rotina atual

# 5.1: modo de permissão da conversa no terminal. "perguntar" (o padrão), "plano" (só descreve: nada que mude alguma coisa
# roda; ferramentas que só olham continuam) e "automatico" (edições de arquivo dentro da pasta do projeto sem perguntar).
# NENHUM modo dispensa a pergunta antes de apagar, enviar ou instalar.
MODOS_PERMISSAO = ("perguntar", "plano", "automatico")
MODO_PERMISSAO = "perguntar"
PASSOS_DO_PLANO = []          # o que o modo plano deixou de fazer, mostrado no fim do pedido
APROVADOS_NA_SESSAO = set()   # comandos (texto exato) liberados "sempre nesta sessão"; nunca os de apagar em massa


def _comando_e_perigoso(comando):
    comando = str(comando or "")
    if any(re.search(padrao, comando, re.IGNORECASE | re.DOTALL) for padrao in _PADROES_COMANDO_PERIGOSO):
        return True
    # 5.0: enviar/publicar e instalar/mudar o sistema também perguntam antes (apagar um arquivo nomeado, `rm a.txt`,
    # segue sem pergunta de propósito; apagar em massa já está acima/abaixo); numa rotina, qualquer ação dessas
    # categorias passa pela pré-aprovação.
    categorias = agenda_lib.categorias_do_comando(comando)
    if categorias & {"enviar", "instalar"}:
        return True
    return ROTINA_ATUAL is not None and bool(categorias)


def pode_aprovar_na_sessao(comando):
    """"Sempre nesta sessão" só vale para o que pergunta por ser envio ou instalação (git push, apt install...). O que bate
    com um padrão destrutivo (rm -rf, mkfs, dd...) pergunta toda vez."""
    comando = str(comando or "")
    return not any(re.search(p, comando, re.IGNORECASE | re.DOTALL) for p in _PADROES_COMANDO_PERIGOSO)


def _texto_visivel(comando, limite=600):
    """O comando inteiro como vai ser mostrado na confirmação: sem caracteres de controle (\\r, ESC) que escondem
    o resto, e sem cortar no meio (um ';' depois do 200º caractere ficava fora da tela)."""
    vis = "".join(ch if ch.isprintable() or ch == "\n" else f"\\x{ord(ch):02x}" for ch in str(comando))
    return vis if len(vis) <= limite else vis[:limite] + f" … (+{len(vis) - limite})"


_CLASSES_TERMINAL = ("terminal", "xterm", "konsole", "alacritty", "kitty", "tilix", "terminator", "urxvt", "rxvt",
                     "foot", "wezterm", "st-256color", "xfce4-terminal", "lxterminal", "qterminal", "guake", "yakuake",
                     "ptyxis", "console", "tmux")


def _janela_e_terminal(janela=None):
    """A janela (a pedida, ou a que está em foco) é um terminal? Texto digitado nela vira comando de shell."""
    try:
        todas = listar_janelas_x() or []
        if janela:
            alvo = encontrar_janela_por_nome(janela, todas) if "encontrar_janela_por_nome" in globals() else None
            achadas = [alvo] if alvo else []
        else:
            ativa = janela_ativa_x()
            achadas = [j for j in todas if j["id"] == ativa]
        return any(any(c in (j.get("classe", "") + " " + j.get("titulo", "")).lower() for c in _CLASSES_TERMINAL)
                   for j in achadas if j)
    except Exception:
        return False


def _barrar_texto_para_terminal(texto, janela=None):
    """Digitar `rm -rf ~` numa janela de terminal e apertar Enter é o mesmo que executar_terminal: passa pela
    mesma pergunta. Devolve um resultado de erro se a pessoa negou; None se pode seguir."""
    texto = str(texto or "")
    if not texto.strip() or not _comando_e_perigoso(texto) or not _janela_e_terminal(janela):
        return None
    if _confirmar_comando_perigoso(texto):
        return None
    return {"sucesso": False, "mensagem": "Typing cancelled: it would run a risky command in a terminal and the user did not confirm it."}


def _confirmar_comando_perigoso(comando):
    """Pede confirmação explícita no terminal antes de rodar um
    comando que bateu com um padrão destrutivo conhecido. Pode ser
    desligado (por conta e risco do usuário) com a variável de
    ambiente TARS_SEM_CONFIRMACAO_PERIGOSOS=1, útil pra rodar o TARS
    de forma não-interativa."""

    if not _CONFIRMAR_COMANDOS_PERIGOSOS:
        return True

    if ROTINA_ATUAL is not None:
        liberado = agenda_lib.autorizado(ROTINA_ATUAL, comando)
        LOG.info("rotina=%s comando_perigoso liberado=%s comando=%s", ROTINA_ATUAL.get("nome"), liberado,
                 _resumir_para_log(comando))
        if not liberado:
            ROTINA_BLOQUEOU.append(comando)
            cancelar_resposta()      # a rotina para aqui: não tenta outro jeito
        return liberado

    if comando in APROVADOS_NA_SESSAO:
        LOG.info("comando_perigoso liberado_na_sessao=True comando=%s", _resumir_para_log(comando))
        return True

    parar_girando()          # o símbolo de "trabalhando" não pode redesenhar por cima da pergunta
    imprimir_caixa(
        "⚠ " + t("perigo.titulo"),
        [_texto_visivel(comando), t("perigo.explicacao")],
        cor_titulo=Cor.VERMELHO,
    )

    if confirmar_comando_hook is not None:
        # Interface gráfica: não há terminal pra digitar "s", então ela
        # registra aqui uma função que pergunta numa caixa de diálogo.
        try:
            confirmado = bool(confirmar_comando_hook(comando))
        except Exception:
            confirmado = False
    elif threading.current_thread() is not threading.main_thread():
        # Pedido rodando fora da thread principal, sem janela: o input()
        # brigaria com o do prompt. Sem ninguém pra confirmar: não roda.
        print(c(t("perigo.sem_confirmacao_rotina"), Cor.AMARELO))
        confirmado = False
    else:
        sessao = pode_aprovar_na_sessao(comando)
        try:
            resposta = input(c(t("perigo.pergunta_sessao" if sessao else "perigo.pergunta") + " ", Cor.AMARELO)).strip()
        except (EOFError, OSError):
            resposta = ""
        confirmado = normalizar(resposta) in _vocab()["respostas_sim"]
        if sessao and normalizar(resposta) in _vocab()["respostas_sempre"]:
            APROVADOS_NA_SESSAO.add(comando)
            confirmado = True

    LOG.info(
        "comando_perigoso confirmado=%s comando=%s",
        confirmado,
        _resumir_para_log(comando),
    )

    return confirmado


def _env_filho(**extra):
    """Ambiente de um programa lançado por nós: a chave da nuvem (TARS_CHAVE_API) não vai junto, senão
    `env` no terminal a mostraria à IA — e ao que ela conversa."""
    e = {k: v for k, v in os.environ.items() if k != "TARS_CHAVE_API"}
    e.update(extra)
    return e


def executar_terminal(comando):

    if _comando_e_perigoso(comando) and not _confirmar_comando_perigoso(comando):
        return {
            "sucesso": False,
            "mensagem": (
                "Command cancelled: it matched a potentially destructive pattern "
                "and the user did not confirm it."
            ),
        }

    return _executar_terminal_de_fato(comando)


# Programas de tela cheia: precisam de um terminal de verdade. Rodando sem terminal (stdin em /dev/null, saída num
# arquivo) eles falham com "Error opening terminal" e o modelo concluía que o programa não estava instalado (16/10, htop).
_PROGRAMAS_DE_TELA = {"htop", "btop", "top", "atop", "nvtop", "iotop", "glances", "vim", "vi", "nvim", "nano", "emacs",
                      "less", "more", "man", "watch", "mc", "ranger", "nmtui", "alsamixer", "tmux", "screen", "cmatrix",
                      "ncdu", "bpytop", "nethogs", "iftop"}
_TERMINAIS = (("x-terminal-emulator", ["-e", "bash", "-c"]), ("gnome-terminal", ["--", "bash", "-c"]),
              ("kgx", ["--", "bash", "-c"]), ("konsole", ["-e", "bash", "-c"]), ("xfce4-terminal", ["-x", "bash", "-c"]),
              ("tilix", ["-e", "bash", "-c"]), ("xterm", ["-e", "bash", "-c"]))


def programa_de_tela(comando):
    """O nome do programa se o comando é um programa de tela cheia rodado sozinho (htop, vim arquivo, sudo nano x),
    ou None. Com pipe, redirecionamento, ';' ou '&&' não é: aí a saída vai para outro lugar. 'top -b' é texto."""
    if re.search(r"[|<>;&`$]", comando):
        return None
    try:
        partes = shlex.split(comando)
    except ValueError:
        return None
    while partes and (partes[0] in ("sudo", "env", "nice", "ionice") or re.match(r"^\w+=", partes[0]) or
                      (partes[0].startswith("-") and len(partes) > 1)):
        partes = partes[1:]
    if not partes:
        return None
    nome = os.path.basename(partes[0])
    if nome not in _PROGRAMAS_DE_TELA:
        return None
    if nome == "top" and any(a.startswith("-") and "b" in a for a in partes[1:]):
        return None
    return nome


def _abrir_em_janela_de_terminal(comando, nome):
    if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
        return {"sucesso": False, "mensagem": (
            f"{nome} is a full-screen terminal program and there is no graphical session to open a terminal window. "
            "Use a non-interactive command instead (for example 'top -bn1', 'free -h', 'ps aux --sort=-%mem | head').")}
    # fica aberta se der erro, para a pessoa ler; se o programa sair normalmente (q no htop), a janela fecha
    roteiro = f"{comando}; codigo=$?; [ $codigo -ne 0 ] && read -rp 'Enter: ' _; exit $codigo"
    for programa, argumentos in _TERMINAIS:
        if shutil.which(programa):
            try:
                subprocess.Popen([programa] + argumentos + [roteiro], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                                 stderr=subprocess.DEVNULL, start_new_session=True, env=_env_filho())
            except OSError:
                continue
            return {"sucesso": True, "mensagem": (
                f"{nome} needs a terminal, so it was opened in a terminal window ({programa}) where the user can see and "
                "use it. Its screen is not captured here. Tell the user it is open in that window.")}
    return {"sucesso": False, "mensagem": (
        f"{nome} is a full-screen terminal program and no terminal emulator was found to open it. "
        "Use a non-interactive command instead (for example 'top -bn1' or 'free -h').")}


def _executar_terminal_de_fato(comando):

    # 5.2: fora do Linux, os comandos de bash que o Gargantua aprendeu rodam no mini-shell em Python (tars_shell), com a
    # saída no formato GNU do treino. No Windows o resto não roda; no macOS vai para o shell do sistema.
    if plataforma.sistema() != "linux":
        if shell_lib.suportado(comando):
            return shell_lib.executar(comando)
        if plataforma.sistema() == "windows":
            return {"sucesso": False, "mensagem": (
                "This command is not available on Windows in openTARS. Only these work: "
                + ", ".join(sorted(shell_lib.COMANDOS)) + " (with ~, quotes, &&, |, > and >>). "
                "Use one of them, or another tool (open_application, search_web).")}

    nome = programa_de_tela(comando)
    if nome:
        return _abrir_em_janela_de_terminal(comando, nome)

    # "firefox &" ou "nohup x &": o programa fica rodando e segurava a
    # saída aberta, e o openTARS esperava o tempo limite inteiro à toa.
    if re.search(r"&\s*$", comando.strip()) and not comando.strip().endswith("&&"):
        try:
            filho = subprocess.Popen(
                comando, shell=True, stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                start_new_session=True, env=_env_filho(),
            )
            threading.Thread(target=filho.wait, daemon=True).start()     # recolhe o filho quando ele terminar (sem zumbi)
            time.sleep(0.4)           # morreu na hora (programa que não existe)? então não foi iniciado
            codigo = filho.poll()
            if codigo not in (None, 0):
                return {"sucesso": False, "codigo": codigo,
                        "mensagem": f"The command exited right away with code {codigo}, so it was not started."}
            return {"sucesso": True, "mensagem": "Command started in the background."}
        except Exception as e:
            return {"sucesso": False, "mensagem": str(e)}

    try:
        # A saída vai pra ARQUIVOS e só o shell é esperado: um programa que o comando abriu (xdg-open, chrome,
        # code .) herda a saída e a segurava aberta até o tempo limite, que então o matava; e uma saída enorme
        # (`yes`) ficava toda na memória até o fim.
        with tempfile.TemporaryFile("w+b") as arq_saida, tempfile.TemporaryFile("w+b") as arq_erro:
            processo = subprocess.Popen(
                comando,
                shell=True,
                stdout=arq_saida,
                stderr=arq_erro,
                # Sem terminal pra digitar: sudo/apt que pedem senha ou "S/n"
                # falham na hora em vez de travar até o tempo limite.
                stdin=subprocess.DEVNULL,
                start_new_session=True,
                env=_env_filho(DEBIAN_FRONTEND="noninteractive"),
            )
            limite = time.time() + TIMEOUT_TERMINAL_COMANDO
            while True:
                try:
                    processo.wait(timeout=0.25)
                    break
                except subprocess.TimeoutExpired:
                    if _cancelar.is_set() or time.time() > limite:
                        try:
                            os.killpg(processo.pid, 15)
                        except Exception:
                            processo.kill()
                        try:
                            processo.wait(timeout=3)
                        except subprocess.TimeoutExpired:  # ignorou o SIGTERM: SIGKILL
                            try:
                                os.killpg(processo.pid, 9)
                            except Exception:
                                processo.kill()
                            try:
                                processo.wait(timeout=3)
                            except subprocess.TimeoutExpired:
                                pass
                        if _cancelar.is_set():
                            return {"sucesso": False, "mensagem": "Command interrupted by the user."}
                        raise subprocess.TimeoutExpired(comando, TIMEOUT_TERMINAL_COMANDO)

            def _ponta(arq, limite_chars):
                tamanho = arq.seek(0, os.SEEK_END)
                arq.seek(max(0, tamanho - limite_chars * 4))     # só o final (até 4 bytes por caractere)
                return arq.read().decode("utf-8", errors="replace")[-limite_chars:]

            saida, erro_saida = _ponta(arq_saida, LIMITE_STDOUT_CHARS), _ponta(arq_erro, LIMITE_STDERR_CHARS)

        resultado = subprocess.CompletedProcess(comando, processo.returncode, saida, erro_saida)

        return {
            "sucesso": resultado.returncode == 0,
            "codigo": resultado.returncode,
            "stdout": resultado.stdout[-LIMITE_STDOUT_CHARS:],
            "stderr": resultado.stderr[-LIMITE_STDERR_CHARS:],
            "mensagem": (
                "Command finished."
                if resultado.returncode == 0
                else "Command finished with an error."
            ),
        }

    except subprocess.TimeoutExpired:

        return {
            "sucesso": False,
            "mensagem": (
                f"The command ran for more than {TIMEOUT_TERMINAL_COMANDO}s and was stopped. "
                "If it needs to run for a long time, suggest that the user runs it in a terminal."
            ),
        }

    except Exception as e:

        return {
            "sucesso": False,
            "mensagem": str(e),
        }


# O print enviado à IA é reduzido (ex: 2560x1440 -> 1280x720) e ela
# responde com coordenadas DESSA imagem. Sem converter de volta, o
# clique cairia no lugar errado (metade do caminho, numa tela 2x maior).
# Atualizado a cada take_screenshot; 1.0 enquanto não houver print.
# Com print de uma janela só, ox/oy guardam onde ela começa na tela.
_escala_print = {"x": 1.0, "y": 1.0, "ox": 0, "oy": 0, "largura": None, "altura": None, "definido": False}


def _escala_tela_inteira():
    """Quanto o print da tela inteira é reduzido (ex: 2.0 numa tela de
    2560px com prints de 1280px). Não depende de já ter tirado print."""
    try:
        largura = pyautogui.size()[0]
    except Exception:
        return 1.0
    return largura / min(largura, LARGURA_MAXIMA_SCREENSHOT) if largura else 1.0


def _usar_quadro_tela_inteira():
    """Volta as coordenadas de clique pro sistema do print de tela
    inteira. Chamado sempre que o openTARS informa posições nesse
    sistema (janelas abertas, tamanho da tela) ou quando a tela muda
    (abrir/focar janela): um recorte de janela antigo deixaria o clique
    com deslocamento errado."""
    escala = _escala_tela_inteira()
    _escala_print.update(x=escala, y=escala, ox=0, oy=0, largura=None, altura=None, definido=True)


def _para_tela(x, y):
    """Coordenadas do último print (as que a IA vê) -> pixels reais.
    Sem print nenhum ainda, vale o sistema do print de tela inteira (o
    tamanho de tela que o prompt informa à IA)."""
    if not _escala_print["definido"]:
        _usar_quadro_tela_inteira()
    return (
        int(round(_escala_print["ox"] + float(x) * _escala_print["x"])),
        int(round(_escala_print["oy"] + float(y) * _escala_print["y"])),
    )


def _para_print(x, y):
    """Pixels reais da tela -> coordenadas do último print."""
    if not _escala_print["definido"]:
        _usar_quadro_tela_inteira()
    return (
        int(round((x - _escala_print["ox"]) / _escala_print["x"])),
        int(round((y - _escala_print["oy"]) / _escala_print["y"])),
    )


# ------------------------------------------------------------
# 2.9.2: mouse preciso (mirar, conferir, arrastar) — lógica em tars_mouse.py
# ------------------------------------------------------------
PAUSA_HOVER_SEG = 0.12          # o app desenha o "hover" antes da captura de antes
ESPERA_DEPOIS_CLIQUE_SEG = 0.45
RAIO_VERIFICAR_CLIQUE = 140     # px reais em volta do clique
DURACAO_PASSO_ARRASTO_SEG = 0.018
PAUSA_SEGURAR_SEG = 0.18        # botão apertado antes de sair do lugar
PAUSA_SOLTAR_SEG = 0.3          # no destino, antes de soltar (o app mostra onde vai cair)


def _capturar_regiao(bbox=None):
    """Captura rápida pra comparar antes/depois (bbox em px reais), ou
    None se não der (Wayland, sem tela). Só serve pra conferir."""
    if SESSAO_GRAFICA == "wayland":
        return None
    try:
        from PIL import ImageGrab
        return ImageGrab.grab(bbox=bbox) if bbox else ImageGrab.grab()
    except Exception:
        return None


def _tamanho_real():
    try:
        largura, altura = pyautogui.size()
        return int(largura), int(altura)
    except Exception:
        return None


def _conferir(acao, x, y):
    """Faz a ação (clique, arrasto) e diz o que mudou na tela: "perto" do
    ponto, "longe" dele, "nada", ou None se não deu pra conferir."""
    tamanho = _tamanho_real()
    bbox = mouse.recorte_em_volta(int(x), int(y), RAIO_VERIFICAR_CLIQUE, *tamanho) if tamanho else None
    antes_perto = _capturar_regiao(bbox) if bbox else None
    antes_tudo = _capturar_regiao() if antes_perto is not None else None
    acao()
    if antes_perto is None:
        return None
    # Em vez de esperar o tempo todo, olha em 3 momentos e sai assim que a tela reagir.
    perto = None
    for fatia in (0.3, 0.3, 0.4):
        time.sleep(ESPERA_DEPOIS_CLIQUE_SEG * fatia)
        perto = mouse.mudanca(antes_perto, _capturar_regiao(bbox))
        if mouse.mudou(perto):
            break
    if mouse.mudou(perto):
        return "perto"
    if mouse.mudou(mouse.mudanca(antes_tudo, _capturar_regiao())):
        return "longe"
    return "nada" if perto is not None else None


def _ponto_por_acessibilidade(nome, janela):
    """Centro do elemento na tela pela árvore de acessibilidade (sem
    clicar), ou None. Só no X11 e com coordenadas de tela confiáveis."""
    if SESSAO_GRAFICA not in ("x11", "windows", "aqua") or acess.indisponivel():
        return None
    try:
        alvo, erro = _janela_acessivel(janela)
        if erro or not alvo or not acess.coordenadas_confiaveis(alvo):
            return None
        achado = acess.localizar(alvo, nome)
    except Exception:
        return None
    if not achado:
        return None
    rotulo, (x, y, largura, altura) = achado
    tamanho = _tamanho_real()
    cx, cy = x + largura // 2, y + altura // 2
    if tamanho and not (0 <= cx < tamanho[0] and 0 <= cy < tamanho[1]):
        return None
    return cx, cy, f"found {rotulo} through accessibility"


def _mirar_bruto(x=None, y=None, alvo=None, janela=None):
    """Onde clicar, em px REAIS da tela: (tx, ty, como) ou (None, None, erro).

    - só x/y: o ponto do último print, convertido;
    - x/y + alvo: a visão (o Gargantua 2) olha a tela e acha o alvo exato perto do ponto
      (sem ele, o ponto vai pro centro do elemento mais perto);
    - só alvo: acessibilidade e, por fim, a visão."""
    alvo = (alvo or "").strip()
    tem_ponto = x is not None and y is not None
    if tem_ponto and not alvo:
        tx, ty = _para_tela(x, y)
        return tx, ty, ""
    if not tem_ponto and not alvo:
        return None, None, "ERROR: pass x/y (pixels of the last screenshot) or 'target' (what to click)."

    if not tem_ponto:
        achado = _ponto_por_acessibilidade(alvo, janela)
        if achado:
            return achado

    try:
        if janela:
            capturada = _capturar_janela(janela)
            if not capturada:
                return None, None, f"No open window looks like '{janela}'. Use list_windows."
            imagem, ox, oy, janela_x = capturada
        else:
            imagem, ox, oy = _capturar_tela().convert("RGB"), 0, 0
    except Exception as e:
        if tem_ponto:
            tx, ty = _para_tela(x, y)
            return tx, ty, ""
        return None, None, f"ERROR capturing the screen: {e}"

    if CLIQUE_POR_VISAO:
        # A visão é a única a olhar a tela; com x/y dados pela IA, só vale perto deles
        perto = None
        if tem_ponto:
            tx0, ty0 = _para_tela(x, y)
            perto = (tx0 - ox, ty0 - oy)
        achado_cooper = mirar_com_visao(alvo, imagem, perto)
        if achado_cooper == "ausente" and not tem_ponto:
            return None, None, (f"Looked at the screen and '{alvo}' is not there. Check list_windows / "
                                "list_elements, or bring the right window to the front.")
        if achado_cooper and achado_cooper != "ausente":
            return ox + achado_cooper[0], oy + achado_cooper[1], achado_cooper[2]
        _avisar_falha_da_visao()
    if tem_ponto:
        tx, ty = _para_tela(x, y)
        lx, ly = tx - ox, ty - oy
        nx, ny = mouse.encaixar(imagem, lx, ly)
        como = "moved onto the nearest element" if (nx, ny) != (lx, ly) else ""
        return ox + nx, oy + ny, como
    if not visao_instalada():
        return None, None, (f"Could not find '{alvo}': it is not in the accessibility tree and no vision model is "
                            "installed (ollama pull ProjectOpenTARS/Gargantua-2). Pass x/y from a screenshot.")
    return None, None, (f"The vision model could not find '{alvo}' ({motivo_da_falha_da_visao()}). "
                        "Pass x/y from a screenshot.")


def _frase_conferencia(resultado):
    if resultado == "perto":
        return " The screen changed where it clicked."
    if resultado == "longe":
        return " The screen changed (not right at the click point)."
    if resultado == "nada":
        return (" WARNING: nothing changed on screen after the click, so it probably MISSED. Don't say "
                "it worked: call it again with target='<what you want to click>' (the vision model aims), "
                "or use click_element.")
    return ""


MARGEM_QUINA_PX = 3  # o pyautogui trava tudo se o ponteiro encostar numa das 4 quinas (fail-safe)


def _longe_da_quina(px, py):
    """Mantém o ponto a MARGEM_QUINA_PX da borda: coordenada inventada ou fora da tela
    mandava o ponteiro pra uma quina, e dali em diante TODO comando de mouse/teclado
    falhava com FailSafeException até alguém mexer o mouse."""
    tamanho = _tamanho_real()
    if not tamanho or px is None or py is None:
        return px, py
    m = MARGEM_QUINA_PX
    return max(m, min(tamanho[0] - 1 - m, px)), max(m, min(tamanho[1] - 1 - m, py))


def _mirar(x=None, y=None, alvo=None, janela=None):
    """_mirar_bruto com o ponto sempre dentro da tela e longe das quinas."""
    tx, ty, como = _mirar_bruto(x, y, alvo, janela)
    if tx is not None:
        tx, ty = _longe_da_quina(tx, ty)
    return tx, ty, como


def clicar_mouse(botao="left", x=None, y=None, alvo=None, janela=None, duplo=False):
    """Handler de click_mouse (clicks=2: duplo clique): mira (tars_mouse), clica
    e confere se a tela mudou."""
    botao = str(botao or "left").strip().lower().replace("-click", "").replace("_click", "")
    botao = botao if botao in ("left", "right", "middle") else "left"
    try:
        if x is None and y is None and not (alvo or "").strip():
            if duplo:
                pyautogui.doubleClick()
            else:
                pyautogui.click(button=botao)
            return {"sucesso": True, "mensagem": ("Double click done." if duplo else
                                                  f"Clicked the {botao} mouse button.")}
        tx, ty, como = _mirar(x, y, alvo, janela)
        if tx is None:
            return {"sucesso": False, "mensagem": como}
        pyautogui.moveTo(tx, ty, duration=DURACAO_MOVER_MOUSE_SEG)
        time.sleep(PAUSA_HOVER_SEG)
        if duplo:
            conferido = _conferir(lambda: pyautogui.doubleClick(x=tx, y=ty), tx, ty)
        else:
            conferido = _conferir(lambda: pyautogui.click(x=tx, y=ty, button=botao), tx, ty)
        px, py = _para_print(tx, ty)
        o_que = "Double-clicked" if duplo else f"Clicked the {botao} mouse button"
        mensagem = f"{o_que} at ({px}, {py})" + (f": {como}" if como else "") + "."
        resultado = {"sucesso": True, "mensagem": mensagem + _frase_conferencia(conferido), "x": px, "y": py}
        if conferido == "nada":
            resultado["sem_efeito"] = True
        return resultado
    except Exception as e:
        return {"sucesso": False, "mensagem": str(e)}


def _ponto_do_arrasto(x, y, elemento, janela, lugar=None):
    """(tx, ty, como) em px reais pra origem/destino de um arrasto."""
    if lugar:
        tamanho = _tamanho_real()
        ancorado = mouse.ancora(lugar, *tamanho) if tamanho else None
        if ancorado:
            return ancorado[0], ancorado[1], f"'{lugar}' of the screen"
        elemento = elemento or lugar  # não é um lugar da tela: é um elemento
    elemento = (elemento or "").strip()
    if elemento and (x is None or y is None):
        achado = _ponto_por_acessibilidade(elemento, janela)
        if achado:
            return achado
    return _mirar(x, y, elemento or None, janela)


def _arrastar_real(x0, y0, x1, y1, botao):
    pyautogui.moveTo(x0, y0, duration=DURACAO_MOVER_MOUSE_SEG)
    time.sleep(PAUSA_HOVER_SEG)
    pyautogui.mouseDown(x=x0, y=y0, button=botao)
    try:
        time.sleep(PAUSA_SEGURAR_SEG)
        for i, (px, py) in enumerate(mouse.caminho(x0, y0, x1, y1)):
            pyautogui.moveTo(px, py)
            # os 3 primeiros (o "empurrão") devagar: aí o app vê um arrasto
            time.sleep(DURACAO_PASSO_ARRASTO_SEG * (4 if i < 3 else 1))
        time.sleep(PAUSA_SOLTAR_SEG)
    finally:
        pyautogui.mouseUp(x=x1, y=y1, button=botao)


def _janela_do_arrasto(a):
    """Nome da janela, se o que se quer arrastar é a PRÓPRIA janela ("a
    janela do openTARS", a barra de título, ou só o nome de um app aberto,
    sem ponto de origem), senão None. Arrastar uma aba ou um arquivo DENTRO
    da janela continua sendo arrasto de mouse."""
    elemento = (a.get("from_element") or "").strip()
    janela = (a.get("window") or "").strip()
    if a.get("to_element"):
        return None  # soltar em cima de algo: arrasto de verdade
    if elemento and mouse.fala_de_janela(elemento):
        return mouse.sem_palavras_de_janela(elemento) or janela or None
    if a.get("from_x") is not None and a.get("from_y") is not None:
        return None
    if elemento and set(normalizar(elemento).split()) & mouse._PALAVRAS_ABA:
        return None
    if elemento and (not janela or normalizar(janela) == normalizar(elemento)):
        achada = encontrar_janela_por_nome(elemento)
        # o nome é de um app aberto (classe/binário), não só um texto do título
        if achada and _pontuar_janela(achada, _termos_para_janela(elemento)) >= 2:
            return elemento
    if not elemento and janela:
        return janela
    return None


def arrastar_mouse(a):
    """Handler de drag_mouse: segura o botão na origem, leva até o destino
    como uma pessoa faria e solta. Confere se a tela mudou."""
    if ERRO_PYAUTOGUI:
        return {"sucesso": False, "mensagem": f"Cannot control the mouse: {ERRO_PYAUTOGUI}."}
    janela_inteira = _janela_do_arrasto(a)
    if janela_inteira and (a.get("to") or (a.get("to_x") is not None and a.get("to_y") is not None)):
        # "arraste a janela do openTARS pro topo esquerdo": mover janela é com
        # o gerenciador de janelas (antes o mouse ia no TEXTO "openTARS").
        return mover_janela(janela_inteira, lugar=a.get("to"), x=a.get("to_x"), y=a.get("to_y"))
    janela = a.get("window") or None
    botao = a.get("button") if a.get("button") in ("left", "right", "middle") else "left"
    tem_origem = a.get("from_element") or (a.get("from_x") is not None and a.get("from_y") is not None)
    tem_destino = a.get("to") or a.get("to_element") or (a.get("to_x") is not None and a.get("to_y") is not None)
    if not tem_destino:
        return {"sucesso": False, "mensagem": (
            "ERROR: say where to drag it: to='top-left' (or 'right', 'center', 'bottom-right'...), "
            "to_element='<what to drop it on>', or to_x/to_y (pixels of the last screenshot).")}
    try:
        if tem_origem:
            x0, y0, como0 = _ponto_do_arrasto(a.get("from_x"), a.get("from_y"), a.get("from_element"), janela)
            if x0 is None:
                return {"sucesso": False, "mensagem": f"Could not find what to drag: {como0}"}
        else:
            x0, y0 = pyautogui.position()
            como0 = "from where the mouse is"
        # O destino fica fora da janela de origem muitas vezes ("pro topo
        # esquerdo da tela"): só procura DENTRO da janela se for um elemento.
        x1, y1, como1 = _ponto_do_arrasto(a.get("to_x"), a.get("to_y"), a.get("to_element"),
                                          janela, lugar=a.get("to"))
        if x1 is None:
            return {"sucesso": False, "mensagem": f"Could not find where to drop it: {como1}"}
        if abs(x1 - x0) + abs(y1 - y0) < 3:
            return {"sucesso": False, "mensagem": "ERROR: the start and the end of the drag are the same point."}
        conferido = _conferir(lambda: _arrastar_real(x0, y0, x1, y1, botao), x1, y1)
    except Exception as e:
        return {"sucesso": False, "mensagem": f"ERROR dragging: {e}"}
    p0, p1 = _para_print(x0, y0), _para_print(x1, y1)
    detalhes = "; ".join(x for x in (f"start: {como0}" if como0 else "", f"end: {como1}" if como1 else "") if x)
    mensagem = (f"Dragged with the {botao} button held from ({p0[0]}, {p0[1]}) to ({p1[0]}, {p1[1]})"
                + (f" ({detalhes})" if detalhes else "") + ".")
    if conferido == "nada":
        # Pode ser legítimo (a aba já era a primeira), por isso não é erro;
        # mas quase sempre quer dizer que não pegou o item: a IA é avisada.
        return {"sucesso": True, "sem_efeito": True, "mensagem": mensagem + (
            " WARNING: NOTHING changed on screen, so it probably did not grab the item. Unless it was "
            "already there, take a screenshot and call drag_mouse again with from_element='<what to "
            "drag>' (openTARS finds it) or corrected from_x/from_y. Don't say it worked without checking.")}
    return {"sucesso": True, "mensagem": "SUCCESS: " + mensagem + (
        " The screen changed." if conferido in ("perto", "longe") else "")}


def scroll_mouse(valor, x=None, y=None):
    try:
        if x is not None and y is not None:
            pyautogui.moveTo(*_para_tela(x, y), duration=DURACAO_MOVER_PARA_SCROLL_SEG)

        pyautogui.scroll(int(valor))
        return {"sucesso": True, "mensagem": f"Scrolled {valor}."}
    except Exception as e:
        return {"sucesso": False, "mensagem": str(e)}


def _definir_clipboard(texto):
    """Coloca texto na área de transferência (tars_plataforma). Usado pra digitar texto multilíngue: o
    pyautogui simula teclas físicas e não digita de forma confiável
    acentos, cedilha ou alfabetos fora do layout US (japonês, árabe,
    cirílico etc.); colar via clipboard funciona pra qualquer idioma
    independente do layout de teclado."""
    try:
        plataforma.escrever_area_de_transferencia(texto)
        return True
    except OSError:
        return False


def digitar_texto(texto, _ja_confirmado=False):
    if not _ja_confirmado:
        barrado = _barrar_texto_para_terminal(texto)
        if barrado:
            return barrado
    try:
        if texto.isascii():
            # Caminho rápido de sempre — nenhuma mudança de
            # comportamento ou desempenho pro caso comum (ASCII).
            pyautogui.write(texto, interval=INTERVALO_DIGITACAO_SEG)
            return {"sucesso": True, "mensagem": "Text typed."}

        # Texto com acentos/outros idiomas: cola via clipboard em vez
        # de tecla-a-tecla, o que também é mais rápido pra textos
        # longos além de mais confiável.
        if _definir_clipboard(texto):
            pyautogui.hotkey(plataforma.tecla_comando(), "v")
            return {"sucesso": True, "mensagem": "Text typed (pasted through the clipboard)."}

        # Sem xclip/xsel disponível: melhor esforço mesmo assim.
        pyautogui.write(texto, interval=INTERVALO_DIGITACAO_SEG)
        return {
            "sucesso": True,
            "mensagem": (
                "Text typed, but xclip/xsel is not installed: special characters "
                "may not have come out right."
            ),
        }

    except Exception as e:
        return {"sucesso": False, "mensagem": str(e)}


_APELIDOS_TECLAS = {
    "page down": "pagedown", "page up": "pageup", "windows": "win", "super": "win", "meta": "win",
    "arrowdown": "down", "arrowup": "up", "arrowleft": "left", "arrowright": "right",
    "arrow down": "down", "arrow up": "up", "arrow left": "left", "arrow right": "right",
    "control": "ctrl", "del": "delete", "return": "enter", "escape": "esc", "spacebar": "space",
}


def _tecla_valida(tecla):
    """Nome de tecla que o pyautogui conhece; ValueError se não existir (o pyautogui,
    sozinho, ignora o nome desconhecido e a ferramenta dizia que apertou)."""
    t_ = _APELIDOS_TECLAS.get(str(tecla or "").strip().lower(), str(tecla or "").strip().lower())
    ok = getattr(pyautogui, "isValidKey", None)
    if not t_ or (ok and not ok(t_)):
        raise ValueError(f"unknown key '{tecla}'. Examples: enter, esc, tab, up, down, pagedown, f5, ctrl, alt, shift, win.")
    return t_


def pressionar_tecla(tecla):
    try:
        if "+" in str(tecla) and len(str(tecla).strip()) > 1:
            return atalho_teclado(str(tecla))  # "ctrl+s" não é uma tecla: é um atalho
        pyautogui.press(_tecla_valida(tecla))
        return {"sucesso": True, "mensagem": f"Pressed '{tecla}'."}
    except Exception as e:
        return {"sucesso": False, "mensagem": str(e)}


def atalho_teclado(teclas):
    try:

        if isinstance(teclas, str):
            teclas = [x.strip() for x in teclas.split("+")]

        if not teclas:
            raise ValueError("no keys given")
        pyautogui.hotkey(*[_tecla_valida(k) for k in teclas])

        return {
            "sucesso": True,
            "mensagem": f"Pressed {'+'.join(teclas)}.",
        }

    except Exception as e:
        return {"sucesso": False, "mensagem": str(e)}