import sys
import os
import time
import datetime

def setup_logging():
    try:
        # Use a pasta do próprio script para máxima confiabilidade
        script_dir = os.path.dirname(os.path.abspath(__file__))
        log_file = os.path.join(script_dir, 'agent_system.log')
        if os.path.exists(log_file) and os.path.getsize(log_file) > 5 * 1024 * 1024:
            try:
                if os.path.exists(log_file + '.bak'):
                    os.remove(log_file + '.bak')
                os.rename(log_file, log_file + '.bak')
            except Exception:
                try:
                    os.remove(log_file)
                except Exception:
                    pass
        log_stream = open(log_file, 'a', encoding='utf-8', buffering=1)
        sys.stdout = log_stream
        sys.stderr = log_stream
        print(f"\n--- AGENT STARTUP: {time.strftime('%Y-%m-%d %H:%M:%S')} ---")
    except Exception as e:
        try:
            import tempfile
            temp_log = os.path.join(tempfile.gettempdir(), 'agent_startup_fatal.log')
            with open(temp_log, 'a', encoding='utf-8') as f:
                f.write(f"Fatal logging setup error: {e}\n")
        except Exception:
            pass

setup_logging()

import socketio
import json
import re
import signal
import threading
import subprocess
import urllib.parse
import socket as sys_socket
import requests
import platform

IS_WINDOWS = platform.system() == "Windows"
IS_MAC = platform.system() == "Darwin"

# No macOS/Linux (especialmente quando iniciado pelo launchd) o PATH é mínimo
# e não inclui os diretórios onde claude/agy/node costumam estar instalados.
if not IS_WINDOWS:
    _extra_path_dirs = [
        '/usr/local/bin',
        '/opt/homebrew/bin',
        os.path.expanduser('~/.local/bin'),
        os.path.expanduser('~/.antigravity/bin'),
        os.path.expanduser('~/.npm-global/bin'),
        os.path.expanduser('~/.codex/bin'),
        os.path.expanduser('~/bin'),
    ]
    _path_parts = os.environ.get('PATH', '').split(os.pathsep)
    for _d in _extra_path_dirs:
        if os.path.isdir(_d) and _d not in _path_parts:
            _path_parts.append(_d)
    os.environ['PATH'] = os.pathsep.join([p for p in _path_parts if p])

# Dependências de interface gráfica são opcionais: no macOS (principalmente em modo
# headless via launchd) pystray/Pillow/tkinter podem não estar disponíveis e o agente
# precisa continuar funcionando mesmo assim.
try:
    import pystray
    from PIL import Image, ImageDraw
except Exception as _gui_err:
    pystray = None
    Image = None
    ImageDraw = None
    print(f"[*] Bandeja do sistema indisponível ({_gui_err}). Rodando sem tray.")

try:
    import tkinter as tk
    from tkinter import messagebox
except Exception as _tk_err:
    tk = None
    messagebox = None
    print(f"[*] Tkinter indisponível ({_tk_err}). Janela de login desativada.")

# Caminhos de dados do CLI e credenciais
CLI_DIR = os.path.join(os.path.expanduser('~'), '.gemini', 'antigravity-cli')
CREDENTIALS_FILE = os.path.join(CLI_DIR, 'cache', 'credentials.json')
LAST_CONV_FILE = os.path.join(CLI_DIR, 'cache', 'last_conversations.json')

# Caminho do executável do Antigravity CLI
def _find_agy_path():
    import shutil
    if IS_WINDOWS:
        candidates = [
            os.path.join(os.path.expandvars('%LOCALAPPDATA%'), 'agy', 'bin', 'agy.exe'),
            r"C:\Users\danie\AppData\Local\agy\bin\agy.exe",
        ]
    else:
        candidates = [
            os.path.expanduser('~/.local/bin/agy'),
            os.path.expanduser('~/.antigravity/bin/agy'),
            os.path.expanduser('~/.agy/bin/agy'),
            os.path.expanduser('~/Library/Application Support/agy/bin/agy'),
            '/opt/homebrew/bin/agy',
            '/usr/local/bin/agy',
            '/Applications/Antigravity.app/Contents/Resources/app/bin/agy',
        ]
    for cand in candidates:
        if os.path.exists(cand):
            return cand
    found = shutil.which('agy')
    return found if found else candidates[0]

AGY_PATH = _find_agy_path()

# Caminho do executável do Claude CLI (Claude Code)
def _find_claude_path():
    candidates = [
        os.path.join(os.path.expanduser('~'), '.local', 'bin', 'claude.exe'),
        os.path.join(os.path.expanduser('~'), '.local', 'bin', 'claude'),
        os.path.expanduser('~/.claude/local/claude'),
        '/opt/homebrew/bin/claude',
        '/usr/local/bin/claude',
        os.path.expanduser('~/.npm-global/bin/claude'),
    ]
    if IS_WINDOWS:
        candidates.insert(2, os.path.join(os.path.expandvars('%APPDATA%'), 'npm', 'claude.cmd'))
    for cand in candidates:
        if os.path.exists(cand):
            return cand
    import shutil
    found = shutil.which('claude')
    return found if found else candidates[0]

CLAUDE_PATH = _find_claude_path()

# Caminho do executavel do Codex CLI
def _find_codex_path():
    import glob as glob_module
    import shutil
    env_path = os.environ.get('CODEX_CLI_PATH')
    candidates = []
    if env_path:
        candidates.append(env_path)
    if IS_WINDOWS:
        local_appdata = os.path.expandvars('%LOCALAPPDATA%')
        appdata = os.path.expandvars('%APPDATA%')
        # O Codex Desktop inclui uma CLI atualizada em uma pasta versionada. Ela
        # precisa ter prioridade sobre a instalacao global do npm, que pode estar
        # desatualizada e rejeitar os modelos usados pelo app (ex.: GPT-5.6).
        desktop_candidates = glob_module.glob(
            os.path.join(local_appdata, 'OpenAI', 'Codex', 'bin', '*', 'codex.exe')
        )
        desktop_candidates.sort(
            key=lambda path: os.path.getmtime(path),
            reverse=True
        )
        candidates.extend([
            os.path.join(local_appdata, 'OpenAI', 'Codex', 'codex.exe'),
        ])
        candidates.extend(desktop_candidates)
        candidates.extend([
            os.path.join(appdata, 'npm', 'codex.cmd'),
            os.path.join(os.path.expanduser('~'), '.local', 'bin', 'codex.exe'),
        ])
    else:
        candidates.extend([
            os.path.expanduser('~/.local/bin/codex'),
            os.path.expanduser('~/.codex/bin/codex'),
            '/opt/homebrew/bin/codex',
            '/usr/local/bin/codex',
            os.path.expanduser('~/.npm-global/bin/codex'),
        ])
    for cand in candidates:
        if cand and os.path.exists(cand):
            return cand
    found = shutil.which('codex')
    return found if found else (candidates[0] if candidates else 'codex')

CODEX_PATH = _find_codex_path()

# Diretório de sessões do Claude CLI (transcripts ficam em projects/<pasta-codificada>/<session_id>.jsonl)
CLAUDE_DIR = os.path.join(os.path.expanduser('~'), '.claude')
CODEX_DIR = os.path.join(os.path.expanduser('~'), '.codex')

# Nomes amigáveis das ferramentas do Claude CLI (espelha o mapeamento do Antigravity)
CLAUDE_TOOL_NAMES = {
    "Read": "Lendo arquivo",
    "Write": "Criando arquivo",
    "Edit": "Modificando arquivo",
    "NotebookEdit": "Modificando notebook",
    "Bash": "Executando comando",
    "PowerShell": "Executando comando",
    "Glob": "Buscando arquivos",
    "Grep": "Buscando no projeto",
    "WebFetch": "Lendo conteúdo web",
    "WebSearch": "Buscando na web",
    "Task": "Executando subagente",
    "Agent": "Executando subagente",
    "TodoWrite": "Atualizando lista de tarefas",
    "AskUserQuestion": "Aguardando resposta",
}

CODEX_EVENT_NAMES = {
    "exec_command_begin": "Executando comando",
    "exec_command_end": "Comando finalizado",
    "tool_call": "Executando ferramenta",
    "tool_call_begin": "Executando ferramenta",
    "tool_call_end": "Ferramenta finalizada",
    "mcp_tool_call_begin": "Executando ferramenta MCP",
    "mcp_tool_call_end": "Ferramenta MCP finalizada",
    "patch_apply_begin": "Aplicando alteracao",
    "patch_apply_end": "Alteracao aplicada",
}

def get_claude_model_arg(model):
    """Converte o modelo escolhido na interface para um valor aceito pelo Claude CLI (--model)."""
    if not model:
        return None
    m = str(model).strip().lower()
    if m.startswith('gemini') or m.startswith('gpt'):
        # Modelo de outro engine selecionado anteriormente; usa o padrão do Claude
        return None
    legacy_mapping = {
        'claude-sonnet-4.6': 'sonnet',
        'claude-opus-4.6': 'opus',
    }
    return legacy_mapping.get(m, model)

# session_id do Claude é sempre um UUID; qualquer outro valor (ex.: IDs sintéticos de
# versões antigas, como 'claude_remote_...') faria o --resume falhar com erro.
CLAUDE_SESSION_ID_RE = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')

def is_valid_claude_session_id(value):
    return bool(value and CLAUDE_SESSION_ID_RE.match(str(value).strip()))

def _first_text_value(value):
    if isinstance(value, str):
        return value
    if isinstance(value, dict):
        for key in ("text", "message", "delta", "content", "output"):
            result = _first_text_value(value.get(key))
            if result:
                return result
        return None
    if isinstance(value, list):
        parts = []
        for item in value:
            text = _first_text_value(item)
            if text:
                parts.append(text)
        return "\n".join(parts) if parts else None
    return None

def extract_codex_session_id(data):
    payload = data.get("payload") if isinstance(data.get("payload"), dict) else data
    for source in (data, payload):
        for key in ("session_id", "sessionId", "conversation_id", "conversationId", "thread_id", "threadId"):
            value = source.get(key)
            if value:
                return str(value)
        info = source.get("info")
        if isinstance(info, dict):
            for key in ("session_id", "sessionId", "conversation_id", "conversationId", "thread_id", "threadId"):
                value = info.get(key)
                if value:
                    return str(value)
    return None

def parse_codex_event(data):
    """Extrai uma saida amigavel dos eventos JSONL do `codex exec --json`."""
    payload = data.get("payload") if isinstance(data.get("payload"), dict) else data
    event_type = payload.get("type") or data.get("type")
    phase = payload.get("phase") or data.get("phase")

    if event_type in ("item.completed", "item.started"):
        item = payload.get("item") or {}
        if isinstance(item, dict):
            item_type = item.get("type")
            if item_type in ("agent_message", "assistant_message", "message"):
                text = _first_text_value(item.get("text") or item.get("message") or item.get("content"))
                if text:
                    return text
            if item_type in CODEX_EVENT_NAMES and event_type == "item.started":
                detail = item.get("command") or item.get("name") or item.get("tool_name") or item.get("cmd")
                suffix = f": {detail}" if detail else ""
                return f"\n🔧 [{CODEX_EVENT_NAMES[item_type]}{suffix}]\n"

    if event_type in ("agent_message", "assistant_message", "message_delta", "agent_message_delta", "final_message"):
        text = _first_text_value(payload.get("message") or payload.get("delta") or payload.get("content") or payload.get("text"))
        if text:
            return text

    if event_type == "response_item":
        item = payload.get("payload") or payload.get("item") or {}
        if isinstance(item, dict) and item.get("type") == "message" and item.get("role") == "assistant":
            text = _first_text_value(item.get("content"))
            if text:
                return text

    if event_type in CODEX_EVENT_NAMES and phase not in ("end", "completed"):
        detail = payload.get("command") or payload.get("name") or payload.get("tool_name") or payload.get("cmd")
        suffix = f": {detail}" if detail else ""
        return f"\n🔧 [{CODEX_EVENT_NAMES[event_type]}{suffix}]\n"

    if event_type in ("error", "fatal_error"):
        text = _first_text_value(payload.get("message") or payload.get("error"))
        if text:
            return f"\n⚠️ [Sistema: {text}]\n"

    return None

def _is_claude_resume_error(text):
    """Detecta mensagens de erro do Claude CLI causadas por sessão de resume inexistente/inválida."""
    if not text:
        return False
    t = str(text)
    return ('No conversation found with session ID' in t
            or 'is not a UUID and does not match any session title' in t
            or ('--resume' in t and 'session' in t.lower()))

# Tempo máximo (segundos) que uma execução pode ficar sem produzir NENHUMA atividade
# (nenhuma linha nova no transcript/stream) antes de ser encerrada automaticamente.
STALL_TIMEOUT_SECONDS = 3600

# O Antigravity CLI às vezes aborta a execução inteira no meio da tarefa por erros
# transitórios do modelo/API (ex.: 'model output error: invalid tool call'). Nesses casos
# a conversa fica salva no brain e pode ser retomada — fazemos isso automaticamente.
MAX_AUTO_CONTINUE_ATTEMPTS = 2

TRANSIENT_CLI_ERROR_PATTERNS = (
    'model output error',
    'invalid tool call',
    'timed out',
    'timeout',
    'deadline exceeded',
    'internal error',
    'unavailable',
    'overloaded',
    'resource_exhausted',
    'resource exhausted',
    'connection reset',
    'connection refused',
    'stream error',
    '429',
    '503',
    '529',
)

def is_transient_cli_error(error_details):
    """Erros em que vale a pena retomar a conversa automaticamente (não são culpa do usuário
    nem de configuração, e tendem a não se repetir)."""
    if not error_details:
        return False
    if error_details.get('type') in ('auth_error', 'permission_error'):
        return False
    msg = str(error_details.get('message', '')).lower()
    return any(p in msg for p in TRANSIENT_CLI_ERROR_PATTERNS)

AUTO_CONTINUE_PROMPT = (
    "A execução anterior foi interrompida no meio por um erro técnico transitório da CLI "
    "(não foi uma ação do usuário). Verifique o estado atual da tarefa e continue exatamente "
    "de onde parou, sem repetir trabalho já concluído, até finalizar a tarefa original."
)

# Tipos em que a CLI termina aguardando uma resposta do usuário — nesses casos NÃO
# devemos continuar automaticamente.
ANTIGRAVITY_WAITING_TYPES = ('ASK_QUESTION', 'ASK_PERMISSION')

def is_antigravity_task_incomplete(transcript_path):
    """Heurística para detectar aborto silencioso do Antigravity CLI.

    Uma execução concluída normalmente termina com uma resposta final do modelo
    (PLANNER_RESPONSE sem tool_calls). Se o transcript termina em uma chamada ou
    resultado de ferramenta, a CLI morreu no meio da tarefa sem concluí-la.
    """
    if not transcript_path or not os.path.exists(transcript_path):
        return False
    try:
        with open(transcript_path, 'r', encoding='utf-8', errors='replace') as f:
            lines = f.readlines()
        for raw in reversed(lines[-15:]):
            raw = raw.strip()
            if not raw:
                continue
            try:
                data = json.loads(raw)
            except Exception:
                continue
            if data.get('source') != 'MODEL':
                continue
            stype = str(data.get('type') or '')
            if stype in ANTIGRAVITY_WAITING_TYPES:
                return False  # aguardando o usuário: não continuar sozinho
            if stype == 'PLANNER_RESPONSE':
                # Resposta final do modelo (sem tool_calls pendentes) = tarefa concluída
                return bool(data.get('tool_calls'))
            # Último passo do modelo foi uma ferramenta => execução morreu no meio
            return True
    except Exception:
        return False
    return False

def is_antigravity_waiting_on_process(transcript_path):
    """Verifica se a última mensagem do assistente indica que ele encerrou o turno
    em um estado temporário de espera de processos automáticos (como deploy)."""
    if not transcript_path or not os.path.exists(transcript_path):
        return False
    try:
        # 1. Recuperar a última mensagem do assistente no transcript
        ultima_mensagem = ""
        with open(transcript_path, 'r', encoding='utf-8', errors='replace') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    data = json.loads(line)
                except Exception:
                    continue
                if data.get('source') == 'MODEL' and data.get('type') == 'PLANNER_RESPONSE' and data.get('content'):
                    ultima_mensagem = data['content']

        if not ultima_mensagem.strip():
            return False

        # 2. Tentar usar classificação por IA se a biblioteca e a chave estiverem disponíveis
        try:
            import google.generativeai as genai
            
            # Carregar chave do config.json da CLI
            config_path = os.path.expanduser(r"~\.gemini\antigravity-cli\scratch\config.json")
            api_key = os.environ.get("GEMINI_API_KEY")
            
            if not api_key and os.path.exists(config_path):
                with open(config_path, "r", encoding="utf-8") as cf:
                    config_data = json.load(cf)
                    api_key = config_data.get("gemini_api_key")
            
            if api_key:
                genai.configure(api_key=api_key)
                prompt = f"""
                Dada a última mensagem enviada por um assistente de IA em um chat de programação:
                ---
                "{ultima_mensagem}"
                ---
                Responda exatamente com a palavra 'AGUARDANDO' se a IA informou que está esperando a conclusão 
                de um processo automático (como deploy remoto, build, teste, script em background ou pipelines) 
                para poder prosseguir ou dar o resultado final.
                Responda com 'CONCLUIDO' se a IA já realizou a tarefa principal, ou se ela está fazendo uma pergunta 
                ou aguardando explicitamente que o usuário humano envie feedback/resposta/aprovação para continuar.

                Resposta (apenas 'AGUARDANDO' ou 'CONCLUIDO'):
                """
                model = genai.GenerativeModel("gemini-1.5-flash")
                response = model.generate_content(prompt)
                resultado = response.text.strip().upper()
                if "AGUARDANDO" in resultado:
                    return True
                if "CONCLUIDO" in resultado:
                    return False
        except Exception:
            pass

        # 3. Fallback para Expressões Regulares
        keywords = [
            r"aguardando.*deploy",
            r"aguardando.*conclus",
            r"deploy.*andamento",
            r"executando.*background",
            r"aguardando.*servidor"
        ]
        return any(re.search(kw, ultima_mensagem, re.IGNORECASE) for kw in keywords)
    except Exception:
        return False

def kill_pid_tree(pid):
    """Mata um processo (e filhos, no Windows) pelo PID. Usado quando não temos o objeto Popen."""
    if not pid:
        return
    try:
        if IS_WINDOWS:
            subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"],
                           capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
        else:
            os.kill(pid, signal.SIGKILL)
    except Exception:
        pass

sio = socketio.Client(logger=False, engineio_logger=False)

active_processes = {}  # client_conv_id -> subprocess.Popen
stopped_conversations = set()  # client_conv_ids cuja execução foi parada manualmente pelo usuário
processes_lock = threading.Lock()
current_username = "Nenhum"
current_server = ""
is_connected = False
icon = None

login_window_open = False
login_window_lock = threading.Lock()

def log_diagnostic(client_conv_id, message):
    if not client_conv_id:
        return
    log_dir = os.path.join(CLI_DIR, 'logs')
    os.makedirs(log_dir, exist_ok=True)
    diag_path = os.path.join(log_dir, f"diagnostics_{client_conv_id}.log")
    timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
    try:
        with open(diag_path, 'a', encoding='utf-8', errors='replace') as f:
            f.write(f"[{timestamp}] {message}\n")
    except Exception as e:
        print(f"[-] Erro ao gravar diagnóstico: {e}")

def load_credentials():
    if os.path.exists(CREDENTIALS_FILE):
        try:
            with open(CREDENTIALS_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except Exception as e:
            print(f"[-] Erro ao carregar credenciais: {e}")
    return None

def save_credentials(creds):
    try:
        os.makedirs(os.path.dirname(CREDENTIALS_FILE), exist_ok=True)
        with open(CREDENTIALS_FILE, 'w', encoding='utf-8') as f:
            json.dump(creds, f, indent=4)
    except Exception as e:
        print(f"[-] Erro ao salvar credenciais: {e}")

def delete_credentials():
    if os.path.exists(CREDENTIALS_FILE):
        try:
            os.remove(CREDENTIALS_FILE)
        except Exception as e:
            print(f"[-] Erro ao deletar credenciais: {e}")

# ========== GERENCIAMENTO DO TRAY ICON & PIL ==========

def create_ufo_image(color='#ff5252'):
    if Image is None:
        return None
    # Imagem 32x32 com fundo circular sólido para evitar bugs de transparência alpha no Windows
    image = Image.new('RGBA', (32, 32), (0, 0, 0, 0))
    dc = ImageDraw.Draw(image)
    
    # Círculo externo sólido (Fundo e borda indicadora de status)
    dc.ellipse((1, 1, 30, 30), fill='#0f172a', outline=color, width=2)
    
    # Desenha a cúpula do disco voador (azul)
    dc.ellipse((10, 6, 21, 15), fill='#3b82f6')
    
    # Desenha a base metálica do disco (cinza)
    dc.ellipse((5, 12, 26, 20), fill='#64748b', outline='#475569')
    
    # Luzes de status e auxiliares
    dc.ellipse((8, 15, 10, 17), fill='white')
    dc.ellipse((14, 15, 17, 18), fill=color) # Luz central (status)
    dc.ellipse((21, 15, 23, 17), fill='white')
    
    return image

if IS_WINDOWS:
    STARTUP_DIR = os.path.expandvars(r'%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup')
    STARTUP_FILE_PATH = os.path.join(STARTUP_DIR, "AntigravityAgent.vbs")
else:
    STARTUP_DIR = os.path.expanduser('~/Library/LaunchAgents')
    STARTUP_FILE_PATH = os.path.join(STARTUP_DIR, "com.antigravity.remoteagent.plist")

def is_startup_enabled():
    return os.path.exists(STARTUP_FILE_PATH)

def toggle_startup(icon, item):
    if is_startup_enabled():
        try:
            if not IS_WINDOWS:
                # macOS: descarrega o Launch Agent antes de excluir
                uid = os.getuid() if hasattr(os, 'getuid') else 0
                subprocess.run(['launchctl', 'bootout', f'gui/{uid}', STARTUP_FILE_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                subprocess.run(['launchctl', 'unload', STARTUP_FILE_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            os.remove(STARTUP_FILE_PATH)
            print("[*] Inicializacao automatica desativada.")
        except Exception as e:
            print(f"[-] Erro ao desativar inicializacao: {e}")
    else:
        # Use o script instalado se possivel, caso contrario use sys.argv[0]
        agent_py_path = os.path.abspath(sys.argv[0])
        # Se for rodado como atalho ou importado, garante que o caminho aponte para o agent.py correto
        if not agent_py_path.endswith('.py'):
            agent_py_path = os.path.join(os.path.dirname(agent_py_path), 'agent.py')
            
        if IS_WINDOWS:
            vbs_content = f'''Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "pythonw.exe ""{agent_py_path}""", 0, False
'''
            try:
                os.makedirs(STARTUP_DIR, exist_ok=True)
                with open(STARTUP_FILE_PATH, "w", encoding="utf-8") as f:
                    f.write(vbs_content)
                print("[*] Inicializacao automatica ativada (Windows).")
            except Exception as e:
                print(f"[-] Erro ao ativar inicializacao: {e}")
        else:
            # macOS plist
            python_exe = sys.executable
            install_dir = os.path.dirname(agent_py_path)
            plist_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.antigravity.remoteagent</string>
    <key>ProgramArguments</key>
    <array>
        <string>{python_exe}</string>
        <string>{agent_py_path}</string>
        <string>--headless</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>WorkingDirectory</key>
    <string>{install_dir}</string>
    <key>StandardOutPath</key>
    <string>{install_dir}/agent_stdout.log</string>
    <key>StandardErrorPath</key>
    <string>{install_dir}/agent_stderr.log</string>
</dict>
</plist>
'''
            try:
                os.makedirs(STARTUP_DIR, exist_ok=True)
                with open(STARTUP_FILE_PATH, "w", encoding="utf-8") as f:
                    f.write(plist_content)
                os.chmod(STARTUP_FILE_PATH, 0o644)
                
                uid = os.getuid() if hasattr(os, 'getuid') else 0
                subprocess.run(['launchctl', 'bootout', f'gui/{uid}', STARTUP_FILE_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                subprocess.run(['launchctl', 'unload', STARTUP_FILE_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                
                res = subprocess.run(['launchctl', 'bootstrap', f'gui/{uid}', STARTUP_FILE_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                if res.returncode != 0:
                    subprocess.run(['launchctl', 'load', STARTUP_FILE_PATH])
                print("[*] Inicializacao automatica ativada (macOS).")
            except Exception as e:
                print(f"[-] Erro ao ativar inicializacao no macOS: {e}")
                
    # Forca a redesenhar o menu com a nova marca de selecao
    update_tray_menu()

def update_tray_menu():
    global icon, current_username, is_connected
    if not icon:
        return
        
    status_label = f"Status: Conectado ({current_username})" if is_connected else "Status: Desconectado"
    
    def on_login(icon, item):
        # Abre o Tkinter em uma thread separada para não travar
        threading.Thread(target=open_login_window, daemon=True).start()
        
    def on_disconnect(icon, item):
        logout_agent()
        
    def on_exit(icon, item):
        stop_all_prompts()
        logout_agent()
        icon.stop()
        os._exit(0)

    menu_items = [
        pystray.MenuItem(status_label, lambda x: None, enabled=False),
        pystray.Menu.SEPARATOR,
    ]
    
    if not is_connected:
        menu_items.append(pystray.MenuItem("Fazer Login", on_login))
    else:
        menu_items.append(pystray.MenuItem("Desconectar Conta", on_disconnect))
        
    startup_label = "Iniciar com o Windows" if IS_WINDOWS else "Iniciar com o macOS"
    menu_items.append(pystray.MenuItem(startup_label, toggle_startup, checked=lambda item: is_startup_enabled()))
    menu_items.append(pystray.MenuItem("Sair", on_exit))
    
    icon.menu = pystray.Menu(*menu_items)
    
    # Atualiza imagem e título do ícone
    if is_connected:
        icon.icon = create_ufo_image('#00a884') # Verde
        icon.title = f"Antigravity Agent: Online ({current_username})"
    else:
        icon.icon = create_ufo_image('#ff5252') # Vermelho
        icon.title = "Antigravity Agent: Offline"

def logout_agent():
    global is_connected, current_username, current_server
    print("[*] Desconectando agente...")
    try:
        if sio.connected:
            sio.disconnect()
    except Exception:
        pass
    delete_credentials()
    is_connected = False
    current_username = "Nenhum"
    current_server = ""
    update_tray_menu()

# ========== JANELA DE LOGIN (TKINTER NO WINDOWS / DIÁLOGOS NATIVOS NO MACOS) ==========

def _osascript_text_input(message, default_answer="", hidden=False):
    """Mostra um diálogo nativo do macOS e retorna o texto digitado (None se cancelado)."""
    hidden_part = "with hidden answer " if hidden else ""
    script = (
        'text returned of (display dialog ' + json.dumps(message) +
        ' default answer ' + json.dumps(default_answer) + ' ' + hidden_part +
        'with title "Antigravity Agent" buttons {"Cancelar", "OK"} default button "OK")'
    )
    try:
        res = subprocess.run(['osascript', '-e', script], capture_output=True, text=True, timeout=600)
        if res.returncode != 0:
            return None
        return res.stdout.rstrip('\n')
    except Exception as e:
        print(f"[-] Erro no diálogo nativo (osascript): {e}")
        return None

def _osascript_alert(message):
    try:
        script = ('display dialog ' + json.dumps(message) +
                  ' with title "Antigravity Agent" buttons {"OK"} default button "OK"')
        subprocess.run(['osascript', '-e', script], capture_output=True, text=True, timeout=600)
    except Exception:
        pass

def open_login_dialog_macos():
    """Fluxo de login no macOS com diálogos nativos.

    Tkinter fora da main thread crasha no macOS, então usamos osascript,
    que roda em processo separado e funciona inclusive em modo headless (launchd).
    """
    default_url = "https://antigravity-remote.siamac.com.br"
    default_user = ""
    creds = load_credentials()
    if creds:
        default_url = creds.get('server_url', default_url)
        default_user = creds.get('username', '')

    url = _osascript_text_input("Login do Antigravity Remote\n\nURL do Cloud Relay:", default_url)
    if not url:
        print("[*] Login cancelado pelo usuário (URL).")
        return
    user = _osascript_text_input("Usuário:", default_user)
    if not user:
        print("[*] Login cancelado pelo usuário (usuário).")
        return
    pwd = _osascript_text_input(f"Senha de '{user.strip()}':", "", hidden=True)
    if not pwd:
        print("[*] Login cancelado pelo usuário (senha).")
        return

    url = url.strip()
    if not url.startswith('http://') and not url.startswith('https://'):
        url = 'https://' + url

    try:
        res = requests.post(f"{url}/api/login", json={"username": user.strip(), "password": pwd}, timeout=10)
        if res.status_code == 200:
            token = res.json().get('token')
            new_creds = {"server_url": url, "username": user.strip(), "token": token}
            save_credentials(new_creds)
            _osascript_alert("Login realizado com sucesso! O agente está conectado ao Cloud Relay.")
            connect_with_credentials(new_creds)
        else:
            try:
                err = res.json().get('error', 'Usuário ou senha incorretos')
            except Exception:
                err = 'Usuário ou senha incorretos'
            _osascript_alert(f"Falha no login: {err}")
    except Exception as e:
        _osascript_alert(f"Erro de conexão com o Cloud Relay: {e}")

def open_login_window():
    global login_window_open
    with login_window_lock:
        if login_window_open:
            print("[*] Janela de login já está aberta. Ignorando solicitação.")
            return
        login_window_open = True

    if not IS_WINDOWS:
        try:
            if IS_MAC:
                open_login_dialog_macos()
            else:
                print("[*] Sem interface gráfica de login neste sistema. "
                      "Rode 'python3 install.py' para configurar as credenciais.")
        finally:
            with login_window_lock:
                login_window_open = False
        return

    if tk is None:
        print("[-] Tkinter indisponível; não é possível abrir a janela de login. "
              "Rode 'python install.py' para configurar as credenciais.")
        with login_window_lock:
            login_window_open = False
        return

    # Carrega URL padrão
    default_url = "https://antigravity-remote.siamac.com.br"
    creds = load_credentials()
    if creds:
        default_url = creds.get('server_url', default_url)
        
    def submit_login():
        url = url_entry.get().strip()
        user = user_entry.get().strip()
        pwd = pwd_entry.get()
        
        if not url or not user or not pwd:
            messagebox.showerror("Erro", "Todos os campos são obrigatórios!")
            return
            
        status_lbl.config(text="Autenticando...", fg="#ffd600")
        root.update()
        
        try:
            if not url.startswith('http://') and not url.startswith('https://'):
                url = 'https://' + url
                
            res = requests.post(f"{url}/api/login", json={
                "username": user,
                "password": pwd
            }, timeout=6)
            
            if res.status_code == 200:
                data = res.json()
                token = data.get('token')
                
                new_creds = {
                    "server_url": url,
                    "username": user,
                    "token": token
                }
                save_credentials(new_creds)
                
                status_lbl.config(text="Login realizado com sucesso!", fg="#00e676")
                root.update()
                time.sleep(0.5)
                
                global login_window_open
                login_window_open = False
                root.destroy()
                
                # Tenta conectar via Socket
                connect_with_credentials(new_creds)
            else:
                err = res.json().get('error', 'Usuário ou senha incorretos')
                status_lbl.config(text=err, fg="#ff5252")
        except Exception as e:
            status_lbl.config(text=f"Erro de conexão: {str(e)[:40]}", fg="#ff5252")

    def on_close():
        global login_window_open
        login_window_open = False
        root.destroy()

    root = tk.Tk()
    root.title("Antigravity Cloud Login")
    root.geometry("380x300")
    root.resizable(False, False)
    root.configure(bg="#0f172a")
    root.protocol("WM_DELETE_WINDOW", on_close)
    
    # Centraliza janela
    root.eval('tk::PlaceWindow . center')
    
    title_lbl = tk.Label(root, text="🔒 Antigravity Agent Login", bg="#0f172a", fg="#ffffff", font=("Inter", 14, "bold"))
    title_lbl.pack(pady=15)
    
    url_lbl = tk.Label(root, text="URL do Cloud Relay", bg="#0f172a", fg="#8696a0", font=("Inter", 9))
    url_lbl.pack(anchor="w", padx=30)
    url_entry = tk.Entry(root, bg="#1e293b", fg="#ffffff", insertbackground="white", bd=1, relief="flat", font=("Inter", 10))
    url_entry.pack(fill="x", padx=30, pady=(0, 10))
    url_entry.insert(0, default_url)
    
    user_lbl = tk.Label(root, text="Usuário", bg="#0f172a", fg="#8696a0", font=("Inter", 9))
    user_lbl.pack(anchor="w", padx=30)
    user_entry = tk.Entry(root, bg="#1e293b", fg="#ffffff", insertbackground="white", bd=1, relief="flat", font=("Inter", 10))
    user_entry.pack(fill="x", padx=30, pady=(0, 10))
    if creds:
        user_entry.insert(0, creds.get('username', ''))
        
    pwd_lbl = tk.Label(root, text="Senha", bg="#0f172a", fg="#8696a0", font=("Inter", 9))
    pwd_lbl.pack(anchor="w", padx=30)
    pwd_entry = tk.Entry(root, show="*", bg="#1e293b", fg="#ffffff", insertbackground="white", bd=1, relief="flat", font=("Inter", 10))
    pwd_entry.pack(fill="x", padx=30, pady=(0, 10))
    
    status_lbl = tk.Label(root, text="", bg="#0f172a", fg="#ffffff", font=("Inter", 9))
    status_lbl.pack(pady=2)
    
    btn = tk.Button(root, text="Conectar", bg="#3b82f6", fg="#ffffff", activebackground="#2563eb", activeforeground="#ffffff", bd=0, font=("Inter", 10, "bold"), command=submit_login)
    btn.pack(fill="x", padx=30, pady=10)
    
    root.mainloop()

# ========== CONEXÃO E AUTENTICAÇÃO DO SOCKET ==========

def connect_with_credentials(creds):
    global is_connected, current_username, current_server
    if not creds:
        return
        
    server_url = creds.get('server_url')
    token = creds.get('token')
    username = creds.get('username')
    
    print(f"[*] Tentando conectar ao Cloud Relay em {server_url} como '{username}'...")
    
    try:
        if sio.connected:
            sio.disconnect()
            
        sio.connect(server_url, auth={'token': token}, wait_timeout=5)
        
        is_connected = True
        current_username = username
        current_server = server_url
        update_tray_menu()
    except Exception as e:
        print(f"[-] Erro ao conectar ao socket: {e}")
        is_connected = False
        update_tray_menu()

def check_auto_connect():
    creds = load_credentials()
    if creds:
        connect_with_credentials(creds)
    else:
        print("[*] Sem credenciais salvas. Aguardando login do usuário via bandeja do sistema.")
        # Abre o login automaticamente se não houver credenciais salvas
        threading.Thread(target=open_login_window, daemon=True).start()

# ========== PERSISTÊNCIA DE TAREFAS ATIVAS ==========

ACTIVE_TASKS_FILE = os.path.join(CLI_DIR, 'cache', 'active_tasks.json')

def load_active_tasks():
    if os.path.exists(ACTIVE_TASKS_FILE):
        try:
            with open(ACTIVE_TASKS_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except Exception as e:
            print(f"[-] Erro ao carregar active_tasks: {e}")
    return {}

def save_active_tasks(tasks):
    try:
        os.makedirs(os.path.dirname(ACTIVE_TASKS_FILE), exist_ok=True)
        with open(ACTIVE_TASKS_FILE, 'w', encoding='utf-8') as f:
            json.dump(tasks, f, indent=4)
    except Exception as e:
        print(f"[-] Erro ao salvar active_tasks: {e}")

def is_pid_cli_running(pid, engine='antigravity'):
    if not pid:
        return False
    # claude.exe nativo aparece como "claude"; instalação via npm roda como "node"
    if engine == 'claude':
        expected_names = ("claude", "node")
    elif engine == 'codex':
        expected_names = ("codex", "node")
    else:
        expected_names = ("agy",)
    if IS_WINDOWS:
        try:
            res = subprocess.run(
                ["tasklist", "/FI", f"PID eq {pid}", "/NH", "/FO", "CSV"],
                capture_output=True,
                text=True,
                creationflags=subprocess.CREATE_NO_WINDOW
            )
            out = res.stdout.lower()
            return any(name in out for name in expected_names)
        except Exception:
            return False
    else:
        try:
            res = subprocess.run(["ps", "-p", str(pid), "-o", "comm="], capture_output=True, text=True)
            out = res.stdout.lower()
            return any(name in out for name in expected_names)
        except Exception:
            return False

# ========== MÉTODOS DE PROJETOS E EXECUÇÃO DO ANTIGRAVITY ==========

def get_registered_projects():
    """Varre as configurações de projetos do IDE para obter os workspaces."""
    projects = []
    
    if platform.system() == "Windows":
        default_dir = r"c:\Users\danie\OneDrive\Área de Trabalho\Experimento"
    else:
        default_dir = os.path.expanduser("~/OneDrive/Área de Trabalho/Experimento")
        
    if os.path.exists(default_dir):
        projects.append({
            "name": "Experimento (Default)",
            "path": default_dir
        })
    
    projects_dir = os.path.join(os.path.expanduser('~'), '.gemini', 'config', 'projects')
    if os.path.exists(projects_dir):
        for filename in os.listdir(projects_dir):
            if filename.endswith('.json'):
                path = os.path.join(projects_dir, filename)
                try:
                    with open(path, 'r', encoding='utf-8') as f:
                        data = json.load(f)
                        name = data.get('name')
                        folder_uri = None
                        
                        resources = data.get('projectResources', {}).get('resources', [])
                        if resources:
                            folder_uri = resources[0].get('gitFolder', {}).get('folderUri')
                        
                        if name and folder_uri:
                            # Decodifica de forma robusta e garante caminhos absolutos no macOS/Linux
                            path_part = folder_uri.replace('file:///', '')
                            decoded_path = urllib.parse.unquote(path_part)
                            if platform.system() != "Windows" and not decoded_path.startswith('/'):
                                decoded_path = '/' + decoded_path
                            normalized_path = os.path.normpath(decoded_path)
                            
                            if not any(p['path'].lower() == normalized_path.lower() for p in projects):
                                projects.append({
                                    "name": name,
                                    "path": normalized_path
                                })
                except Exception as e:
                    print(f"[-] Erro ao carregar projeto {filename}: {e}")
                    
    return projects

def get_latest_cli_log():
    try:
        import glob
        log_dir = os.path.join(os.path.expanduser('~'), '.gemini', 'antigravity-cli', 'log')
        list_of_files = glob.glob(os.path.join(log_dir, 'cli-*.log'))
        if not list_of_files:
            return None
        latest_file = max(list_of_files, key=os.path.getmtime)
        return latest_file
    except Exception as e:
        print(f"[-] Erro ao buscar log da CLI mais recente: {e}")
        return None

def detect_execution_error(transcript_path, client_conv_id):
    error_info = {
        "has_error": False,
        "error_type": "unknown",
        "message": ""
    }
    
    # 1. Verifica se existe o transcript.jsonl e analisa erros nele
    if transcript_path and os.path.exists(transcript_path):
        try:
            with open(transcript_path, 'r', encoding='utf-8', errors='replace') as f:
                lines = f.readlines()
                # Analisa as últimas 10 linhas em busca de status de erro
                for line in reversed(lines[-10:]):
                    if not line.strip():
                        continue
                    try:
                        data = json.loads(line.strip())
                        # Se houver status ERROR ou similar
                        if data.get("status") == "ERROR" or "error" in str(data.get("status")).lower():
                            error_info["has_error"] = True
                            error_info["error_type"] = "cli_error"
                            error_info["message"] = data.get("content", "Erro na execução de etapa da CLI.")
                            break
                        # Se houver mensagem de sistema de erro
                        if data.get("source") == "SYSTEM" and "error" in str(data.get("content")).lower():
                            error_info["has_error"] = True
                            error_info["error_type"] = "system_error"
                            error_info["message"] = data.get("content")
                            break
                    except Exception:
                        pass
        except Exception as e:
            print(f"[-] Erro ao ler transcript para diagnóstico: {e}")

    # 2. Se não achou erro no transcript, ou quer detalhar, lê o log glog da CLI mais recente
    if not error_info["has_error"]:
        latest_log = get_latest_cli_log()
        if latest_log and os.path.exists(latest_log):
            try:
                # Se o log foi modificado nos últimos 10 minutos (para evitar pegar logs antigos)
                mtime = os.path.getmtime(latest_log)
                if time.time() - mtime < 600:
                    with open(latest_log, 'r', encoding='utf-8', errors='replace') as f:
                        lines = f.readlines()
                        # Procura por linhas de erro nas últimas 30 linhas
                        for line in reversed(lines[-30:]):
                            line_str = line.strip()
                            if not line_str:
                                continue
                            
                            is_error_line = line_str.startswith('E') and len(line_str) > 20 and line_str[1:5].isdigit()
                            
                            if is_error_line or "error" in line_str.lower() or "failed" in line_str.lower() or "timeout" in line_str.lower():
                                if "timed out after" in line_str:
                                    error_info["has_error"] = True
                                    error_info["error_type"] = "timeout"
                                    error_info["message"] = "A execução da CLI atingiu o limite máximo de tempo (timeout de 5 minutos) sem responder."
                                    break
                                elif "not logged into Antigravity" in line_str:
                                    error_info["has_error"] = True
                                    error_info["error_type"] = "auth_error"
                                    error_info["message"] = "Você não está autenticado no Antigravity. Por favor, faça login no agente local."
                                    break
                                elif "INVALID_ARGUMENT" in line_str:
                                    error_info["has_error"] = True
                                    error_info["error_type"] = "api_error"
                                    error_info["message"] = "Erro de argumento inválido na API do Gemini (geralmente causado pela tentativa de processar arquivos binários não suportados)."
                                    break
                                elif is_error_line:
                                    parts = line_str.split(']', 1)
                                    msg = parts[1].strip() if len(parts) > 1 else line_str
                                    error_info["has_error"] = True
                                    error_info["error_type"] = "cli_error"
                                    error_info["message"] = f"Erro reportado pela CLI: {msg}"
                                    break
            except Exception as e:
                print(f"[-] Erro ao analisar log de erro da CLI: {e}")

    # 3. Também verifica se temos saída no stdout de redirecionamento (se houver)
    if not error_info["has_error"] and client_conv_id:
        log_dir = os.path.join(CLI_DIR, 'logs')
        stdout_path = os.path.join(log_dir, f"cli_{client_conv_id}_output.log")
        if os.path.exists(stdout_path):
            try:
                mtime = os.path.getmtime(stdout_path)
                if time.time() - mtime < 600:
                    with open(stdout_path, 'r', encoding='utf-8', errors='replace') as f:
                        content = f.read()
                        if "INVALID_ARGUMENT" in content or "400" in content:
                            error_info["has_error"] = True
                            error_info["error_type"] = "api_error"
                            error_info["message"] = "Erro 400 (InvalidArgument) retornado pela API do Gemini ao tentar ler arquivos binários."
                        elif "AccessDenied" in content or "permission denied" in content.lower():
                            error_info["has_error"] = True
                            error_info["error_type"] = "permission_error"
                            error_info["message"] = "Permissão negada ao acessar ou ler arquivos/pastas locais."
            except Exception:
                pass

    return error_info

def drain_stream(stream, name):
    try:
        for line in iter(stream.readline, ''):
            if line:
                print(f"[{name}] {line.strip()}")
    except Exception:
        pass
def run_agy_thread(prompt, workspace, cli_conversation_id, client_conv_id, resume_pid=None, resume_offset=0, resume_transcript_path=None, already_finished=False, model=None, engine='antigravity', retry_attempt=0):
    """Executa ou retoma a CLI (Antigravity, Claude ou Codex) em segundo plano e faz stream da saída."""

    is_claude = (engine == 'claude')
    is_codex = (engine == 'codex')

    if engine not in ('antigravity', 'claude', 'codex'):
        error_msg = f"\n⚠️ [Sistema: Engine de CLI desconhecido recebido pelo agente: {engine}. A execucao foi cancelada para evitar usar a CLI errada.]\n"
        print(error_msg, end='', flush=True)
        log_diagnostic(client_conv_id, f"Engine desconhecido recebido: {engine}")
        while not sio.connected:
            time.sleep(0.5)
        sio.emit('prompt_output', {'text': error_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
        sio.emit('prompt_done', {
            'code': -1,
            'client_conv_id': client_conv_id,
            'timestamp': datetime.datetime.now().strftime('%H:%M'),
            'error_details': {
                'type': 'invalid_engine',
                'message': f"Engine de CLI desconhecido: {engine}"
            }
        })
        return

    # Autocorreção: IDs de sessão inválidos para o Claude (ex.: 'claude_remote_...' gerados por
    # versões antigas) fariam o --resume falhar. Nesse caso, iniciamos uma nova sessão limpa.
    if is_claude and cli_conversation_id and not is_valid_claude_session_id(cli_conversation_id):
        log_diagnostic(client_conv_id, f"cli_conversation_id inválido para o Claude ('{cli_conversation_id}'). Iniciando nova sessão limpa.")
        print(f"[!] cli_conversation_id inválido para o Claude ('{cli_conversation_id}'). Iniciando nova sessão.")
        cli_conversation_id = None

    log_diagnostic(client_conv_id, f"Iniciando run_agy_thread. Engine: {engine}, Resuming: {resume_pid is not None}, Finished: {already_finished}")

    # Save/update active tasks state on disk
    tasks = load_active_tasks()
    # If not resuming or resuming an active process, keep tracking it
    if not already_finished:
        tasks[client_conv_id] = {
            "prompt": prompt,
            "workspace": workspace,
            "cli_conversation_id": cli_conversation_id,
            "pid": resume_pid,
            "transcript_path": resume_transcript_path,
            "offset": resume_offset,
            "model": model,
            "engine": engine
        }
        save_active_tasks(tasks)

    proc = None
    pid = resume_pid
    claude_pre_spawn_offset = 0

    if not already_finished and not resume_pid:
        # Revalida o caminho da CLI na hora de executar: ela pode ter sido instalada
        # depois que o agente subiu, ou estar em outro local neste sistema.
        global AGY_PATH, CLAUDE_PATH, CODEX_PATH
        if is_claude:
            if not os.path.exists(CLAUDE_PATH):
                CLAUDE_PATH = _find_claude_path()
            cli_path = CLAUDE_PATH
        elif is_codex:
            if not os.path.exists(CODEX_PATH):
                CODEX_PATH = _find_codex_path()
            cli_path = CODEX_PATH
        else:
            if not os.path.exists(AGY_PATH):
                AGY_PATH = _find_agy_path()
            cli_path = AGY_PATH

        if not os.path.exists(cli_path):
            if is_claude:
                cli_label = "Claude Code (comando 'claude')"
                hint = ("Para usar este engine, instale o Claude Code neste computador "
                        "(https://claude.com/claude-code) e tente novamente — não é preciso "
                        "reiniciar o agente. Alternativa: crie uma nova conversa com o engine Antigravity.")
            elif is_codex:
                cli_label = "Codex CLI (comando 'codex')"
                hint = ("Para usar este engine, instale o Codex CLI neste computador "
                        "e faca login com 'codex login'. Depois tente novamente — nao e preciso "
                        "reiniciar o agente. Alternativa: crie uma nova conversa com outro engine.")
            else:
                cli_label = "Antigravity (comando 'agy')"
                hint = ("Para usar este engine, instale a CLI 'agy' do Antigravity neste computador "
                        "e tente novamente — não é preciso reiniciar o agente. Se ela já estiver "
                        "instalada, abra o Terminal, rode 'which agy' e informe o caminho encontrado. "
                        "Alternativa: crie uma nova conversa com o engine Claude.")
            error_msg = f"\n❌ A CLI do {cli_label} não foi encontrada neste computador.\n\n{hint}\n"
            print(error_msg)
            log_diagnostic(client_conv_id, f"CLI não encontrada para o engine '{engine}': {cli_path}")
            while not sio.connected:
                time.sleep(0.5)
            sio.emit('prompt_output', {'text': error_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
            sio.emit('prompt_done', {'code': -1, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
            tasks = load_active_tasks()
            if client_conv_id in tasks:
                del tasks[client_conv_id]
                save_active_tasks(tasks)
            return

        if is_claude:
            cmd = [
                CLAUDE_PATH,
                "-p", prompt,
                "--output-format", "stream-json",
                "--verbose",
                "--dangerously-skip-permissions"
            ]
            if cli_conversation_id:
                cmd.extend(["--resume", cli_conversation_id])
            claude_model = get_claude_model_arg(model)
            if claude_model:
                cmd.extend(["--model", claude_model])
        elif is_codex:
            if cli_conversation_id:
                cmd = [
                    CODEX_PATH,
                    "exec",
                    "resume",
                    "--json",
                    "--skip-git-repo-check",
                    "--dangerously-bypass-approvals-and-sandbox",
                ]
                if model:
                    cmd.extend(["--model", model])
                cmd.extend([cli_conversation_id, prompt])
            else:
                cmd = [
                    CODEX_PATH,
                    "exec",
                    "--json",
                    "--skip-git-repo-check",
                    "--dangerously-bypass-approvals-and-sandbox",
                ]
                if model:
                    cmd.extend(["--model", model])
                cmd.append(prompt)
        else:
            cmd = [
                AGY_PATH,
                "--prompt", prompt,
                "--dangerously-skip-permissions"
            ]
            if cli_conversation_id:
                cmd.extend(["--conversation", cli_conversation_id])
            if workspace:
                cmd.extend(["--add-dir", workspace])

        print(f"\n[+] Executando ({engine}): {' '.join(cmd)}")
        log_diagnostic(client_conv_id, f"Preparando para executar a CLI ({engine}). Comando: {' '.join(cmd)}")
        
        # Wait for connection before sending startup output
        while not sio.connected:
            time.sleep(0.5)
            
        sio.emit('prompt_output', {
            'text': f"\n🚀 Iniciando execução: {' '.join(cmd)}\n\n",
            'client_conv_id': client_conv_id,
            'timestamp': datetime.datetime.now().strftime('%H:%M')
        })
        
        clean_env = os.environ.copy()
        for key in list(clean_env.keys()):
            if key.startswith('ANTIGRAVITY_'):
                del clean_env[key]

        if not is_claude and not is_codex:
            if not model:
                model = 'gemini-3.5-flash-high'

            clean_env['GEMINI_MODEL'] = model
            clean_env['ANTIGRAVITY_MODEL'] = model
            clean_env['MODEL'] = model

        # Open a file for stdout/stderr redirection to prevent pipe breaks
        out_file = subprocess.DEVNULL
        if client_conv_id:
            log_dir = os.path.join(CLI_DIR, 'logs')
            os.makedirs(log_dir, exist_ok=True)
            stdout_path = os.path.join(log_dir, f"cli_{client_conv_id}_output.log")
            # Para o Claude, o stdout (stream-json) é o próprio transcript monitorado.
            # Como o arquivo é aberto em modo append, guardamos o tamanho atual para ler só a nova execução.
            if is_claude or is_codex:
                try:
                    if os.path.exists(stdout_path):
                        claude_pre_spawn_offset = os.path.getsize(stdout_path)
                except Exception:
                    claude_pre_spawn_offset = 0
            try:
                out_file = open(stdout_path, 'a', encoding='utf-8', errors='replace', buffering=1)
            except Exception as log_ex:
                print(f"[-] Erro ao abrir arquivo de log do CLI: {log_ex}")
                log_diagnostic(client_conv_id, f"Erro ao abrir arquivo de log do CLI: {log_ex}")
                out_file = subprocess.DEVNULL
 
        try:
            with processes_lock:
                # Use start_new_session=True to detach child process on Windows/macOS/Linux
                # Use out_file for stdout and stderr to prevent broken pipe crashes
                popen_kwargs = {
                    'stdin': subprocess.DEVNULL,
                    'stdout': out_file,
                    'stderr': subprocess.STDOUT,
                    'env': clean_env,
                    'cwd': workspace if workspace else None,
                    'start_new_session': True
                }
                if IS_WINDOWS:
                    popen_kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
                proc = subprocess.Popen(cmd, **popen_kwargs)
                pid = proc.pid
                active_processes[client_conv_id] = proc
                
            log_diagnostic(client_conv_id, f"Processo da CLI ({engine}) criado com sucesso. PID: {pid}")
            # Update state with the newly created process PID
            tasks = load_active_tasks()
            if client_conv_id in tasks:
                tasks[client_conv_id]["pid"] = pid
                if (is_claude or is_codex) and client_conv_id:
                    tasks[client_conv_id]["transcript_path"] = stdout_path
                    tasks[client_conv_id]["offset"] = claude_pre_spawn_offset
                save_active_tasks(tasks)

        except Exception as e:
            error_msg = f"\n[-] Erro ao executar a CLI ({engine}): {str(e)}\n"
            print(error_msg)
            log_diagnostic(client_conv_id, f"Erro ao iniciar processo da CLI ({engine}): {str(e)}")
            while not sio.connected:
                time.sleep(0.5)
            sio.emit('prompt_output', {'text': error_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
            sio.emit('prompt_done', {'code': -1, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
            tasks = load_active_tasks()
            if client_conv_id in tasks:
                del tasks[client_conv_id]
                save_active_tasks(tasks)
            return
        finally:
            if out_file != subprocess.DEVNULL:
                try:
                    out_file.close()
                except Exception:
                    pass

    normalized_ws = os.path.normpath(workspace).lower() if workspace else None
    initial_conv_id = cli_conversation_id if cli_conversation_id else None

    cached_conv_ids = set()
    if not is_claude and not is_codex and not initial_conv_id and workspace and os.path.exists(LAST_CONV_FILE):
        try:
            with open(LAST_CONV_FILE, 'r', encoding='utf-8') as f:
                mapping = json.load(f)
                for path, cid in mapping.items():
                    if os.path.normpath(path).lower() == normalized_ws:
                        cached_conv_ids.add(cid)
        except Exception:
            pass

    initial_offset = resume_offset
    transcript_path = resume_transcript_path
    if is_claude or is_codex:
        # Para o Claude, o transcript monitorado é o log de stdout (stream-json) da execução
        if not transcript_path and client_conv_id:
            transcript_path = os.path.join(CLI_DIR, 'logs', f"cli_{client_conv_id}_output.log")
            if not already_finished and not resume_pid:
                # Processo recém-criado: lê apenas a partir do fim anterior do log (modo append)
                initial_offset = claude_pre_spawn_offset
    elif not transcript_path and initial_conv_id:
        transcript_path = os.path.join(CLI_DIR, "brain", initial_conv_id, ".system_generated", "logs", "transcript.jsonl")
        if os.path.exists(transcript_path):
            try:
                initial_offset = os.path.getsize(transcript_path)
            except Exception:
                pass

    # Scan existing conversation folders in brain to detect the new one later
    initial_convs = set()
    brain_dir = os.path.join(CLI_DIR, "brain")
    if os.path.exists(brain_dir):
        try:
            initial_convs = set(os.listdir(brain_dir))
        except Exception:
            pass
    start_time = time.time()

    try:
        conv_id = initial_conv_id
        offset = initial_offset
        printed_steps = set()
        last_check_time = time.time()
        has_sent_initial_sync = False
        last_proc_check = 0.0
        cached_is_alive = True
        process_death_time = None
        claude_error_message = None
        claude_resume_failed = False
        codex_error_message = None
        stall_killed = False
        last_activity_time = time.time()
        last_offset_save = 0.0

        while True:
            # 1. Connection-aware pausing
            if not sio.connected:
                time.sleep(0.5)
                continue
                
            # Sync cli_conversation_id with the database as soon as it's detected
            if conv_id and not has_sent_initial_sync:
                sio.emit('update_cli_conversation_id', {
                    'client_conv_id': client_conv_id,
                    'cli_conversation_id': conv_id
                })
                has_sent_initial_sync = True
                
            # If we are in "already finished" catch-up mode, we don't have a running subprocess to check,
            # so we just scan/read transcript once and break when it is stable/fully read.
            # (Apenas para o Antigravity: o Claude informa o session_id no próprio stream-json)
            if not is_claude and not is_codex and not conv_id and workspace:
                # 1. Scan for newly created directories in brain
                if os.path.exists(brain_dir):
                    try:
                        current_convs = set(os.listdir(brain_dir))
                        candidates = current_convs - initial_convs
                        
                        new_conv_id = None
                        for cand in candidates:
                            if cand in initial_convs:
                                continue
                            t_path = os.path.join(brain_dir, cand, ".system_generated", "logs", "transcript.jsonl")
                            if os.path.exists(t_path):
                                try:
                                    with open(t_path, 'r', encoding='utf-8') as f:
                                        first_line = f.readline()
                                        if first_line.strip():
                                            data = json.loads(first_line)
                                            content = data.get("content", "")
                                            # Verify if prompt or injected_prompt exists in the content
                                            if (prompt in content) or (content and len(prompt) > 10 and prompt[:10] in content):
                                                new_conv_id = cand
                                                break
                                except Exception:
                                    pass
                                    
                        # Fallback: if only 1 candidate created after startup, assume it's ours
                        # We must make sure it was NOT in initial_convs
                        if not new_conv_id:
                            new_candidates = [c for c in candidates if c not in initial_convs]
                            if len(new_candidates) == 1:
                                cand = new_candidates[0]
                                t_path = os.path.join(brain_dir, cand, ".system_generated", "logs", "transcript.jsonl")
                                if os.path.exists(t_path):
                                    new_conv_id = cand
                                
                        if new_conv_id:
                            conv_id = new_conv_id
                            transcript_path = os.path.join(brain_dir, conv_id, ".system_generated", "logs", "transcript.jsonl")
                            offset = 0
                            print(f"[+] Nova conversa detectada com sucesso! ID: {conv_id}")
                            log_diagnostic(client_conv_id, f"Nova conversa detectada por escaneamento de diretorios: {conv_id}")
                            
                            # Update tasks file with the transcript path and conversation ID
                            tasks = load_active_tasks()
                            if client_conv_id in tasks:
                                tasks[client_conv_id]["transcript_path"] = transcript_path
                                tasks[client_conv_id]["cli_conversation_id"] = conv_id
                                save_active_tasks(tasks)
                    except Exception as e:
                        print(f"[-] Erro ao detectar nova conversa via diretorio: {e}")
                        
                # 2. Fallback to LAST_CONV_FILE check
                if not conv_id and os.path.exists(LAST_CONV_FILE):
                    try:
                        with open(LAST_CONV_FILE, 'r', encoding='utf-8') as f:
                            mapping = json.load(f)
                            for path, cid in mapping.items():
                                if os.path.normpath(path).lower() == normalized_ws:
                                    if cid in cached_conv_ids:
                                        continue
                                    conv_id = cid
                                    transcript_path = os.path.join(brain_dir, conv_id, ".system_generated", "logs", "transcript.jsonl")
                                    offset = 0
                                    
                                    # Update tasks file with the transcript path and conversation ID
                                    tasks = load_active_tasks()
                                    if client_conv_id in tasks:
                                        tasks[client_conv_id]["transcript_path"] = transcript_path
                                        tasks[client_conv_id]["cli_conversation_id"] = conv_id
                                        save_active_tasks(tasks)
                                    break
                    except Exception:
                        pass
            
            has_new_data = False
            if transcript_path and os.path.exists(transcript_path):
                try:
                    # Lê o arquivo diretamente a partir do offset usando encode utf-8 com 'replace' para evitar travamento por encoding
                    with open(transcript_path, 'r', encoding='utf-8', errors='replace') as f:
                        f.seek(offset)
                        while True:
                            current_pos = f.tell()
                            line = f.readline()
                            if not line:
                                break
                            
                            # Se a linha não termina com '\n' e o processo da CLI ainda está ativo,
                            # significa que a linha está incompleta (está sendo ativamente escrita).
                            # Retrocedemos para o início desta linha para ler por completo no próximo ciclo.
                            if not line.endswith('\n'):
                                is_proc_alive = False
                                if not already_finished:
                                    with processes_lock:
                                        current_proc = active_processes.get(client_conv_id)
                                        if current_proc:
                                            is_proc_alive = (current_proc.poll() is None)
                                        else:
                                            is_proc_alive = is_pid_cli_running(pid, engine)
                                if is_proc_alive:
                                    f.seek(current_pos)
                                    break
                                
                            # Linha completa ou processo finalizado. Avançamos o offset.
                            offset = f.tell()
                            has_new_data = True

                            # Save progress offset (com throttle de 1s para não saturar o disco
                            # em execuções com muitas linhas de saída)
                            now_save = time.time()
                            if now_save - last_offset_save > 1.0:
                                last_offset_save = now_save
                                tasks = load_active_tasks()
                                if client_conv_id in tasks:
                                    tasks[client_conv_id]["offset"] = offset
                                    save_active_tasks(tasks)
                            
                            if not line.strip():
                                continue
                                
                            if is_claude:
                                # Parsing dos eventos stream-json do Claude CLI
                                try:
                                    data = json.loads(line)
                                except Exception:
                                    # Linha não-JSON (provavelmente erro no stderr); repassa como aviso
                                    raw = line.strip()
                                    if raw:
                                        if _is_claude_resume_error(raw):
                                            # Sessão de resume inexistente: marcada para reinício automático,
                                            # sem poluir o chat com o stderr bruto.
                                            claude_resume_failed = True
                                            log_diagnostic(client_conv_id, f"Falha de resume detectada no stderr do Claude: {raw[:300]}")
                                        else:
                                            warn_msg = f"\n⚠️ [Sistema: {raw}]\n"
                                            sio.emit('prompt_output', {'text': warn_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                            print(warn_msg, end='', flush=True)
                                    continue

                                try:
                                    etype = data.get("type")
                                    sess_id = data.get("session_id")

                                    # Eventos 'result' com erro podem trazer um session_id descartável
                                    # (de uma sessão que nunca foi gravada); não pode virar o ID de resume.
                                    if etype == "result" and data.get("is_error"):
                                        sess_id = None

                                    # Detecta/atualiza o session_id (vira o cli_conversation_id para --resume)
                                    if sess_id and sess_id != conv_id:
                                        conv_id = sess_id
                                        has_sent_initial_sync = False
                                        log_diagnostic(client_conv_id, f"Session ID do Claude detectado: {conv_id}")
                                        tasks = load_active_tasks()
                                        if client_conv_id in tasks:
                                            tasks[client_conv_id]["cli_conversation_id"] = conv_id
                                            save_active_tasks(tasks)

                                    if etype == "assistant":
                                        message = data.get("message", {})
                                        for block in message.get("content", []):
                                            btype = block.get("type")
                                            if btype == "text":
                                                text = block.get("text", "")
                                                if text:
                                                    sio.emit('prompt_output', {'text': text, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                                    print(text, end='', flush=True)
                                            elif btype == "tool_use":
                                                tool_name = block.get("name", "")
                                                friendly = CLAUDE_TOOL_NAMES.get(tool_name)
                                                if friendly:
                                                    tc_msg = f"\n📂 [{friendly}]\n"
                                                else:
                                                    tc_msg = f"\n🔧 [Ferramenta: {tool_name}]\n"
                                                sio.emit('prompt_output', {'text': tc_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                                print(tc_msg, end='', flush=True)
                                    elif etype == "result":
                                        if data.get("is_error") or data.get("subtype") not in (None, "success"):
                                            errors_list = data.get("errors") or []
                                            joined_errors = "; ".join(str(e) for e in errors_list if e) if errors_list else None
                                            claude_error_message = data.get("result") or joined_errors or f"Execução do Claude finalizou com erro ({data.get('subtype')})."
                                            if _is_claude_resume_error(claude_error_message):
                                                claude_resume_failed = True
                                        log_diagnostic(client_conv_id, f"Evento result do Claude: subtype={data.get('subtype')}, is_error={data.get('is_error')}")
                                    # Eventos type=system (init), user (tool_result), rate_limit etc. não geram saída no chat
                                except Exception as json_err:
                                    print(f"[-] Erro ao processar evento do Claude: {json_err}")
                                    log_diagnostic(client_conv_id, f"Erro ao processar evento stream-json do Claude. Linha: {line.strip()[:500]}. Erro: {json_err}")
                                continue

                            if is_codex:
                                try:
                                    data = json.loads(line)
                                except Exception:
                                    raw = line.strip()
                                    if raw:
                                        if raw == "Reading additional input from stdin..." or " WARN " in raw:
                                            log_diagnostic(client_conv_id, f"Codex stderr/info ignorado: {raw[:300]}")
                                            continue
                                        warn_msg = f"\n⚠️ [Sistema: {raw}]\n"
                                        sio.emit('prompt_output', {'text': warn_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                        print(warn_msg, end='', flush=True)
                                    continue

                                try:
                                    sess_id = extract_codex_session_id(data)
                                    if sess_id and sess_id != conv_id:
                                        conv_id = sess_id
                                        has_sent_initial_sync = False
                                        log_diagnostic(client_conv_id, f"Session ID do Codex detectado: {conv_id}")
                                        tasks = load_active_tasks()
                                        if client_conv_id in tasks:
                                            tasks[client_conv_id]["cli_conversation_id"] = conv_id
                                            save_active_tasks(tasks)

                                    rendered = parse_codex_event(data)
                                    if rendered:
                                        sio.emit('prompt_output', {'text': rendered, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                        print(rendered, end='', flush=True)

                                    payload = data.get("payload") if isinstance(data.get("payload"), dict) else data
                                    etype = payload.get("type") or data.get("type")
                                    if etype in ("error", "fatal_error"):
                                        codex_error_message = _first_text_value(payload.get("message") or payload.get("error")) or "Codex CLI reportou erro."
                                except Exception as json_err:
                                    print(f"[-] Erro ao processar evento do Codex: {json_err}")
                                    log_diagnostic(client_conv_id, f"Erro ao processar evento JSONL do Codex. Linha: {line.strip()[:500]}. Erro: {json_err}")
                                continue

                            try:
                                data = json.loads(line)
                                step_idx = data.get("step_index")
                                source = data.get("source")
                                stype = data.get("type")
                                status = data.get("status")
                                content = data.get("content", "")

                                log_diagnostic(client_conv_id, f"Processando linha do transcript: idx={step_idx}, source={source}, type={stype}, status={status}")
                                
                                step_key = f"{step_idx}_{status}"
                                if step_key in printed_steps:
                                    continue
                                printed_steps.add(step_key)
                                
                                if source == "MODEL":
                                    if stype == "PLANNER_RESPONSE":
                                        if content:
                                            sio.emit('prompt_output', {'text': content, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                            print(content, end='', flush=True)
                                        tool_calls = data.get("tool_calls", [])
                                        for tc in tool_calls:
                                            tc_msg = f"\n🔧 [Ferramenta: {tc.get('name')}]\n"
                                            sio.emit('prompt_output', {'text': tc_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                            print(tc_msg, end='', flush=True)
                                    elif stype in [
                                        "LIST_DIRECTORY", "VIEW_FILE", "CODE_ACTION", "RUN_COMMAND", 
                                        "GREP_SEARCH", "WRITE_FILE", "WRITE_TO_FILE", "REPLACE_FILE_CONTENT", 
                                        "MULTI_REPLACE_FILE_CONTENT", "ASK_PERMISSION", "ASK_QUESTION", 
                                        "GENERATE_IMAGE", "READ_URL_CONTENT", "SEARCH_WEB"
                                    ]:
                                        mapping_names = {
                                            "LIST_DIRECTORY": "Listando diretório",
                                            "VIEW_FILE": "Lendo arquivo",
                                            "CODE_ACTION": "Modificando arquivo",
                                            "RUN_COMMAND": "Executando comando",
                                            "GREP_SEARCH": "Buscando no projeto",
                                            "WRITE_FILE": "Criando arquivo",
                                            "WRITE_TO_FILE": "Criando arquivo",
                                            "REPLACE_FILE_CONTENT": "Modificando arquivo",
                                            "MULTI_REPLACE_FILE_CONTENT": "Modificando arquivo",
                                            "ASK_PERMISSION": "Solicitando permissão",
                                            "ASK_QUESTION": "Aguardando resposta",
                                            "GENERATE_IMAGE": "Gerando imagem",
                                            "READ_URL_CONTENT": "Lendo conteúdo web",
                                            "SEARCH_WEB": "Buscando na web"
                                        }
                                        action_msg = f"\n📂 [{mapping_names.get(stype, stype)}]\n"
                                        sio.emit('prompt_output', {'text': action_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                        print(action_msg, end='', flush=True)
                                elif source == "SYSTEM" and stype == "SYSTEM_MESSAGE":
                                    if content:
                                        sys_msg = f"\n⚠️ [Sistema: {content}]\n"
                                        sio.emit('prompt_output', {'text': sys_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                                        print(sys_msg, end='', flush=True)
                            except Exception as json_err:
                                print(f"[-] Erro ao processar JSON da linha: {json_err}")
                                log_diagnostic(client_conv_id, f"Erro ao decodificar JSON do transcript. Linha: {line.strip()}. Erro: {json_err}")
                except Exception as read_err:
                    print(f"[-] Erro ao ler arquivo de log da conversa: {read_err}")
                    log_diagnostic(client_conv_id, f"Erro ao ler arquivo de log: {read_err}")
 
            is_alive = False
            if not already_finished:
                with processes_lock:
                    current_proc = active_processes.get(client_conv_id)
                    if current_proc:
                        is_alive = (current_proc.poll() is None)
                    else:
                        now_check = time.time()
                        if now_check - last_proc_check > 1.5:
                            cached_is_alive = is_pid_cli_running(pid, engine)
                            last_proc_check = now_check
                        is_alive = cached_is_alive

            if has_new_data:
                last_activity_time = time.time()

            # Watchdog anti-travamento: se o processo está vivo mas não produz NENHUMA
            # saída há muito tempo, encerramos para a conversa não ficar presa para sempre.
            if is_alive and (time.time() - last_activity_time) > STALL_TIMEOUT_SECONDS:
                stall_killed = True
                last_activity_time = time.time()
                stall_msg = f"\n⚠️ [Sistema: A execução ficou {STALL_TIMEOUT_SECONDS // 60} minutos sem nenhuma atividade e foi encerrada automaticamente. Envie a mensagem novamente para continuar.]\n"
                print(stall_msg, end='', flush=True)
                log_diagnostic(client_conv_id, f"WATCHDOG: processo da CLI ({engine}) sem atividade por {STALL_TIMEOUT_SECONDS}s. Encerrando processo (PID {pid}).")
                try:
                    sio.emit('prompt_output', {'text': stall_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                except Exception:
                    pass
                with processes_lock:
                    current_proc = active_processes.get(client_conv_id)
                try:
                    if current_proc and current_proc.poll() is None:
                        current_proc.kill()
                    elif pid:
                        kill_pid_tree(pid)
                except Exception as kill_err:
                    log_diagnostic(client_conv_id, f"WATCHDOG: erro ao matar processo travado: {kill_err}")

            if not is_alive:
                if not process_death_time:
                    process_death_time = time.time()
                if not has_new_data:
                    if time.time() - process_death_time > 5.0:
                        log_diagnostic(client_conv_id, f"Processo da CLI ({engine}) inativo e sem novos dados por mais de 5s. Encerrando monitoramento.")
                        break
                else:
                    process_death_time = time.time()

            time.sleep(0.1)
 
        exit_code = 0
        if already_finished:
            exit_code = -100
        else:
            with processes_lock:
                current_proc = active_processes.get(client_conv_id)
                if current_proc:
                    current_proc.wait()
                    exit_code = current_proc.returncode
                
        print(f"\n[+] Finalizado com código: {exit_code}")
        log_diagnostic(client_conv_id, f"Finalizado com código: {exit_code}")

        # Autocorreção do Claude: se a sessão de resume não existe mais (apagada/expirada),
        # reexecuta automaticamente o mesmo prompt em uma sessão nova (apenas 1 tentativa).
        if (is_claude and claude_resume_failed and retry_attempt == 0
                and not already_finished and client_conv_id not in stopped_conversations):
            info_msg = "\n⚠️ [Sistema: A sessão anterior do Claude não foi encontrada. Iniciando uma nova sessão e reenviando sua mensagem...]\n"
            print(info_msg, end='', flush=True)
            log_diagnostic(client_conv_id, "Resume do Claude falhou. Reexecutando automaticamente em nova sessão.")
            while not sio.connected:
                time.sleep(0.5)
            sio.emit('prompt_output', {'text': info_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
            return run_agy_thread(prompt, workspace, None, client_conv_id, model=model, engine=engine, retry_attempt=1)

        # Executa o diagnóstico de erro
        error_details = None
        if is_claude:
            if claude_error_message:
                error_details = {
                    "type": "cli_error",
                    "message": claude_error_message
                }
            elif not already_finished and exit_code not in (0, None) and client_conv_id not in stopped_conversations:
                error_details = {
                    "type": "cli_error",
                    "message": f"O Claude CLI finalizou com código de saída {exit_code}. Verifique se você está autenticado (rode 'claude' no terminal do computador hospedeiro)."
                }
            if error_details:
                print(f"[*] Diagnóstico de erro (claude) gerado: {error_details}")
                log_diagnostic(client_conv_id, f"Diagnóstico de erro (claude) gerado: {error_details}")
        elif is_codex:
            if codex_error_message:
                error_details = {
                    "type": "cli_error",
                    "message": codex_error_message
                }
            elif not already_finished and exit_code not in (0, None) and client_conv_id not in stopped_conversations:
                error_details = {
                    "type": "cli_error",
                    "message": f"O Codex CLI finalizou com codigo de saida {exit_code}. Verifique se voce esta autenticado (rode 'codex login' no terminal do computador hospedeiro)."
                }
            if error_details:
                print(f"[*] Diagnostico de erro (codex) gerado: {error_details}")
                log_diagnostic(client_conv_id, f"Diagnostico de erro (codex) gerado: {error_details}")
        else:
            try:
                diag = detect_execution_error(transcript_path, client_conv_id)
                if diag and diag.get("has_error"):
                    error_details = {
                        "type": diag.get("error_type", "unknown"),
                        "message": diag.get("message", "")
                    }
                    print(f"[*] Diagnóstico de erro gerado: {error_details}")
                    log_diagnostic(client_conv_id, f"Diagnóstico de erro gerado: {error_details}")
            except Exception as diag_ex:
                print(f"[-] Erro ao rodar diagnóstico: {diag_ex}")
                log_diagnostic(client_conv_id, f"Erro ao rodar diagnóstico: {diag_ex}")

        # O encerramento pelo watchdog de inatividade tem prioridade sobre outros diagnósticos
        if stall_killed:
            error_details = {
                "type": "timeout",
                "message": f"A execução foi encerrada automaticamente após {STALL_TIMEOUT_SECONDS // 60} minutos sem nenhuma atividade da CLI ({engine}). Envie a mensagem novamente para continuar."
            }

        # Autocontinuação do Antigravity: quando a CLI aborta no meio da tarefa — seja por um
        # erro transitório diagnosticado (ex.: 'model output error: invalid tool call'), seja
        # silenciosamente (transcript termina em ferramenta, sem resposta final) — a conversa
        # continua salva no brain, então retomamos automaticamente pedindo para continuar.
        if (not is_claude and not is_codex and not already_finished and not stall_killed and conv_id
                and retry_attempt < MAX_AUTO_CONTINUE_ATTEMPTS
                and client_conv_id not in stopped_conversations):
            auto_continue_reason = None
            if error_details and is_transient_cli_error(error_details):
                auto_continue_reason = "interrompida por um erro transitório"
            elif not error_details and is_antigravity_task_incomplete(transcript_path):
                auto_continue_reason = "finalizada sem concluir a tarefa"
            elif not error_details and is_antigravity_waiting_on_process(transcript_path):
                auto_continue_reason = "interrompida aguardando processos automáticos"
            if auto_continue_reason:
                attempt = retry_attempt + 1
                info_msg = (f"\n⚠️ [Sistema: A CLI foi {auto_continue_reason}. "
                            f"Retomando a tarefa automaticamente (tentativa {attempt} de {MAX_AUTO_CONTINUE_ATTEMPTS})...]\n")
                print(info_msg, end='', flush=True)
                err_summary = error_details.get('message', '')[:300] if error_details else 'aborto silencioso (transcript sem resposta final)'
                log_diagnostic(client_conv_id, f"AUTOCONTINUAÇÃO: {auto_continue_reason} ({err_summary}). Retomando conversa {conv_id} (tentativa {attempt}/{MAX_AUTO_CONTINUE_ATTEMPTS}).")
                while not sio.connected:
                    time.sleep(0.5)
                sio.emit('prompt_output', {'text': info_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
                return run_agy_thread(AUTO_CONTINUE_PROMPT, workspace, conv_id, client_conv_id, model=model, engine=engine, retry_attempt=attempt)

        # Ensure we are connected before sending completion
        while not sio.connected:
            time.sleep(0.5)
            
        payload = {
            'code': exit_code,
            'cli_conversation_id': conv_id,
            'client_conv_id': client_conv_id,
            'timestamp': datetime.datetime.now().strftime('%H:%M')
        }
        if error_details:
            payload['error_details'] = error_details

        sio.emit('prompt_done', payload)
    except Exception as e:
        import traceback
        error_msg = f"\n[-] Erro ao executar a CLI ({engine}): {str(e)}\n"
        print(error_msg)
        log_diagnostic(client_conv_id, f"ERRO CRÍTICO em run_agy_thread: {str(e)}\n{traceback.format_exc()}")
        while not sio.connected:
            time.sleep(0.5)
        sio.emit('prompt_output', {'text': error_msg, 'client_conv_id': client_conv_id, 'timestamp': datetime.datetime.now().strftime('%H:%M')})
        sio.emit('prompt_done', {
            'code': -1, 
            'client_conv_id': client_conv_id, 
            'timestamp': datetime.datetime.now().strftime('%H:%M'),
            'error_details': {
                'type': 'internal_agent_error',
                'message': f"Erro crítico interno no Desktop Agent: {str(e)}"
            }
        })
    finally:
        with processes_lock:
            if client_conv_id in active_processes:
                del active_processes[client_conv_id]
            stopped_conversations.discard(client_conv_id)
        # Remove task from state file on disk
        tasks = load_active_tasks()
        if client_conv_id in tasks:
            del tasks[client_conv_id]
            save_active_tasks(tasks)
 
def stop_active_prompt(client_conv_id):
    """Encerra o processo da CLI ativo para uma conversa específica se houver."""
    with processes_lock:
        proc = active_processes.get(client_conv_id)
        if proc and proc.poll() is None:
            print(f"[*] Matando processo ativo da CLI para chat {client_conv_id}...")
            stopped_conversations.add(client_conv_id)
            proc.kill()
            sio.emit('prompt_output', {
                'text': "\n⚠️ [EXECUÇÃO PARADA PELO USUÁRIO]\n",
                'client_conv_id': client_conv_id,
                'timestamp': datetime.datetime.now().strftime('%H:%M')
            })
            return True
    return False

def stop_all_prompts():
    """Encerra todos os processos ativos da CLI."""
    with processes_lock:
        for client_conv_id, proc in list(active_processes.items()):
            if proc.poll() is None:
                proc.kill()
        active_processes.clear()

# ========== EVENTOS SOCKET.IO ==========

@sio.event
def connect():
    global is_connected
    print(f"[+] Conectado ao Cloud Relay")
    is_connected = True
    
    # Registra o computador com o hostname do sistema
    computer_name = sys_socket.gethostname()
    sio.emit('register_agent', {'computerName': computer_name})
    
    # Envia lista inicial de workspaces
    workspaces = get_registered_projects()
    sio.emit('workspaces_list', {'workspaces': workspaces})
    update_tray_menu()

@sio.event
def connect_error(data):
    global is_connected
    print(f"[-] Falha na conexão: {data}")
    is_connected = False
    if data and ("Token" in str(data) or "Authentication" in str(data)):
        print("[*] Credenciais inválidas ou expiradas. Limpando dados e abrindo tela de login.")
        delete_credentials()
        try:
            sio.disconnect()
        except Exception:
            pass
        threading.Thread(target=open_login_window, daemon=True).start()
    update_tray_menu()

@sio.event
def disconnect():
    global is_connected
    print("[-] Desconectado do servidor.")
    is_connected = False
    update_tray_menu()

@sio.on('get_workspaces')
def on_get_workspaces(data=None):
    reply_to = data.get('replyTo') if data else None
    workspaces = get_registered_projects()
    print(f"[<] get_workspaces recebido. Enviando: {len(workspaces)} projetos.")
    sio.emit('workspaces_list', {'workspaces': workspaces, 'replyTo': reply_to})

def get_windows_shell_folder(folder_name):
    """Retorna o caminho de pastas do sistema como Documentos ou Desktop direto do Registro do Windows."""
    try:
        import winreg
        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders") as key:
            value, _ = winreg.QueryValueEx(key, folder_name)
            return os.path.expandvars(value)
    except Exception:
        return None

@sio.on('open_file')
def on_open_file(data):
    import urllib.parse
    import urllib.request
    import os
    
    url = data.get('url', '')
    if url:
        path_url = url
        if path_url.lower().startswith('file:'):
            if path_url.lower().startswith('file://') and not path_url.lower().startswith('file:///'):
                path_url = 'file:///' + path_url[7:]
            elif path_url.lower().startswith('file:/') and not path_url.lower().startswith('file:///'):
                path_url = 'file:///' + path_url[6:]
                
            parsed_url = urllib.parse.urlparse(path_url)
            path = urllib.request.url2pathname(parsed_url.path)
        else:
            path = path_url
            
        path = urllib.parse.unquote(path)
        path = os.path.normpath(path)
        
        print(f"[+] Abrindo arquivo via comando socket: {path}")
        if os.path.exists(path):
            try:
                if IS_WINDOWS:
                    os.startfile(path)
                elif IS_MAC:
                    subprocess.run(['open', path])
                else:
                    subprocess.run(['xdg-open', path])
            except Exception as e:
                print(f"[-] Erro ao abrir arquivo via socket: {e}")
        else:
            print(f"[-] Arquivo local nao existe: {path}")

@sio.on('get_logs')
def on_get_logs(data):
    request_id = data.get('requestId')
    try:
        script_dir = os.path.dirname(os.path.abspath(__file__))
        log_file = os.path.join(script_dir, 'agent_system.log')
        
        logs_content = ""
        if os.path.exists(log_file):
            # Lê as últimas 150 linhas do arquivo de log
            with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
                lines = f.readlines()
                logs_content = "".join(lines[-150:])
        else:
            logs_content = "Arquivo agent_system.log não encontrado."
            
        sio.emit('agent_logs_response', {
            'requestId': request_id,
            'logs': logs_content
        })
    except Exception as e:
        sio.emit('agent_logs_response', {
            'requestId': request_id,
            'error': f"Erro ao ler logs locais: {str(e)}"
        })

@sio.on('list_directories')
def on_list_directories(data):
    import os
    import platform
    reply_to = data.get('replyTo') if data else None
    current_path = data.get('path', '')
    try:
        if not current_path:
            # Retorna o home directory e os drives lógicos (se for Windows)
            home = os.path.expanduser('~')
            drives = []
            shortcuts = []
            
            possible_shortcuts = []
            
            # Adiciona primeiro os caminhos oficiais do Registro do Windows se estiver no Windows
            if platform.system() == "Windows":
                reg_personal = get_windows_shell_folder("Personal")
                reg_desktop = get_windows_shell_folder("Desktop")
                if reg_personal:
                    is_onedrive = 'onedrive' in reg_personal.lower()
                    name = "Documentos (OneDrive)" if is_onedrive else "Documentos"
                    possible_shortcuts.append((name, reg_personal))
                if reg_desktop:
                    is_onedrive = 'onedrive' in reg_desktop.lower()
                    name = "Área de Trabalho (OneDrive)" if is_onedrive else "Área de Trabalho"
                    possible_shortcuts.append((name, reg_desktop))
            
                # No Windows, a pedido do usuário, não listamos os fallbacks locais se já obtivemos caminhos.
                # Se não obtivermos nada do registro, adicionamos os fallbacks do OneDrive
                if not possible_shortcuts:
                    possible_shortcuts.extend([
                        ("Documentos (OneDrive)", os.path.join(home, 'OneDrive', 'Documentos')),
                        ("Documents (OneDrive)", os.path.join(home, 'OneDrive', 'Documents')),
                        ("Área de Trabalho (OneDrive)", os.path.join(home, 'OneDrive', 'Área de Trabalho')),
                        ("Desktop (OneDrive)", os.path.join(home, 'OneDrive', 'Desktop')),
                    ])
            else:
                # No macOS / Linux, adicionamos caminhos padrão do usuário
                possible_shortcuts.extend([
                    ("Documentos", os.path.join(home, 'Documents')),
                    ("Mesa", os.path.join(home, 'Desktop')),
                ])
            
            # Adiciona todos os que existirem, sem duplicados de caminho
            seen_paths = set()
            for name, path in possible_shortcuts:
                normalized_path = os.path.normpath(path)
                if os.path.exists(normalized_path) and os.path.isdir(normalized_path):
                    if normalized_path.lower() not in seen_paths:
                        shortcuts.append({
                            'name': name,
                            'path': normalized_path,
                            'icon': '📂'
                        })
                        seen_paths.add(normalized_path.lower())
            
            if platform.system() == "Windows":
                import string
                from ctypes import windll
                bitmask = windll.kernel32.GetLogicalDrives()
                for letter in string.ascii_uppercase:
                    if bitmask & 1:
                        drives.append(f"{letter}:\\")
                    bitmask >>= 1
            else:
                drives = ["/"]
            sio.emit('directories_list', {
                'replyTo': reply_to,
                'status': 'ok',
                'home': home,
                'drives': drives,
                'shortcuts': shortcuts,
                'current_path': '',
                'subdirs': []
            })
            return
            
        normalized = os.path.abspath(current_path)
        subdirs = []
        for item in os.listdir(normalized):
            full_path = os.path.join(normalized, item)
            if os.path.isdir(full_path):
                if not item.startswith('.'):
                    subdirs.append(item)
                    
        sio.emit('directories_list', {
            'replyTo': reply_to,
            'status': 'ok',
            'current_path': normalized,
            'subdirs': sorted(subdirs, key=str.lower)
        })
    except Exception as e:
        sio.emit('directories_list', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('create_directory')
def on_create_directory(data):
    import os
    reply_to = data.get('replyTo') if data else None
    parent_path = data.get('parentPath')
    name = data.get('name')
    try:
        full_path = os.path.join(parent_path, name)
        if os.path.exists(full_path):
            if os.path.isdir(full_path):
                raise FileExistsError(f"A pasta '{name}' já existe neste diretório.")
            else:
                raise FileExistsError(f"Um arquivo com o nome '{name}' já existe neste diretório.")
        os.makedirs(full_path, exist_ok=False)
        sio.emit('create_directory_result', {
            'replyTo': reply_to,
            'status': 'ok',
            'path': full_path
        })
    except FileExistsError as e:
        sio.emit('create_directory_result', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })
    except Exception as e:
        sio.emit('create_directory_result', {
            'replyTo': reply_to,
            'status': 'error',
            'message': f"Erro inesperado: {str(e)}"
        })

@sio.on('register_workspace')
def on_register_workspace(data):
    import os
    import json
    import time
    reply_to = data.get('replyTo') if data else None
    name = data.get('name')
    path = data.get('path')
    try:
        projects_dir = os.path.join(os.path.expanduser('~'), '.gemini', 'config', 'projects')
        os.makedirs(projects_dir, exist_ok=True)
        
        normalized_path = os.path.abspath(path).replace('\\', '/')
        if not normalized_path.startswith('/'):
            normalized_path = '/' + normalized_path
        folder_uri = f"file://{normalized_path}"
        
        project_data = {
            "name": name,
            "projectResources": {
                "resources": [
                    {
                        "gitFolder": {
                            "folderUri": folder_uri
                        }
                    }
                ]
            }
        }
        
        safe_name = "".join([c if c.isalnum() else "_" for c in name])
        file_path = os.path.join(projects_dir, f"{safe_name}_{int(time.time())}.json")
        with open(file_path, 'w', encoding='utf-8') as f:
            json.dump(project_data, f, indent=4, ensure_ascii=False)
            
        sio.emit('register_workspace_result', {
            'replyTo': reply_to,
            'status': 'ok',
            'name': name,
            'path': os.path.abspath(path)
        })
    except Exception as e:
        sio.emit('register_workspace_result', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('list_workspace_files')
def on_list_workspace_files(data):
    reply_to = data.get('replyTo') if data else None
    current_path = data.get('path', '')
    try:
        import os
        normalized = os.path.abspath(current_path)
        items = []
        for item in os.listdir(normalized):
            if item == '.git':
                continue
            full_path = os.path.join(normalized, item)
            is_dir = os.path.isdir(full_path)
            size = 0
            if not is_dir:
                try:
                    size = os.path.getsize(full_path)
                except Exception:
                    pass
            items.append({
                'name': item,
                'is_dir': is_dir,
                'size': size
            })
        
        # Sort: directories first, then files
        items.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
        
        sio.emit('workspace_files_list', {
            'replyTo': reply_to,
            'status': 'ok',
            'current_path': normalized,
            'items': items
        })
    except Exception as e:
        sio.emit('workspace_files_list', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('open_local_directory')
def on_open_local_directory(data):
    reply_to = data.get('replyTo') if data else None
    dir_path = data.get('path', '')
    try:
        import os
        import subprocess
        import sys
        
        normalized = os.path.abspath(dir_path)
        if not os.path.exists(normalized):
            raise FileNotFoundError(f"A pasta '{normalized}' não existe no computador hospedeiro.")
        
        if not os.path.isdir(normalized):
            normalized = os.path.dirname(normalized)
            
        if os.name == 'nt':
            os.startfile(normalized)
        elif sys.platform == 'darwin':
            subprocess.run(['open', normalized])
        else:
            subprocess.run(['xdg-open', normalized])
            
        sio.emit('open_local_directory_result', {
            'replyTo': reply_to,
            'status': 'ok',
            'path': normalized
        })
    except Exception as e:
        sio.emit('open_local_directory_result', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('download_workspace_file')
def on_download_workspace_file(data):
    reply_to = data.get('replyTo') if data else None
    file_path = data.get('path', '')
    try:
        import os
        import base64
        normalized = os.path.abspath(file_path)
        if not os.path.isfile(normalized):
            raise FileNotFoundError("Arquivo não encontrado ou é um diretório.")
        
        # Limit to 50MB
        size = os.path.getsize(normalized)
        if size > 50 * 1024 * 1024:
            raise ValueError("O arquivo excede o limite de tamanho de 50MB para download.")
            
        with open(normalized, 'rb') as f:
            content = base64.b64encode(f.read()).decode('utf-8')
            
        sio.emit('workspace_file_content', {
            'replyTo': reply_to,
            'status': 'ok',
            'filename': os.path.basename(normalized),
            'content': content
        })
    except Exception as e:
        sio.emit('workspace_file_content', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('delete_workspace_file')
def on_delete_workspace_file(data):
    reply_to = data.get('replyTo') if data else None
    file_path = data.get('path', '')
    try:
        import os
        import shutil
        normalized = os.path.abspath(file_path)
        if not os.path.exists(normalized):
            raise FileNotFoundError("Arquivo ou pasta não encontrado.")
            
        if os.path.isdir(normalized):
            shutil.rmtree(normalized)
        else:
            os.remove(normalized)
            
        sio.emit('delete_workspace_file_result', {
            'replyTo': reply_to,
            'status': 'ok',
            'path': file_path
        })
    except Exception as e:
        sio.emit('delete_workspace_file_result', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('rename_workspace_file')
def on_rename_workspace_file(data):
    reply_to = data.get('replyTo') if data else None
    old_path = data.get('path', '')
    new_path = data.get('newPath', '')
    try:
        import os
        normalized_old = os.path.abspath(old_path)
        normalized_new = os.path.abspath(new_path)
        
        if not os.path.exists(normalized_old):
            raise FileNotFoundError("Arquivo ou pasta de origem não encontrado.")
            
        os.rename(normalized_old, normalized_new)
        
        sio.emit('rename_workspace_file_result', {
            'replyTo': reply_to,
            'status': 'ok',
            'path': old_path,
            'newPath': new_path
        })
    except Exception as e:
        sio.emit('rename_workspace_file_result', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('get_workspace_rules')
def on_get_workspace_rules(data):
    reply_to = data.get('replyTo') if data else None
    workspace_path = data.get('path', '')
    try:
        import os
        # Determina a pasta de regras (.agents/rules ou .agent/rules)
        rules_dir = None
        for folder_name in ('.agents', '.agent'):
            p = os.path.join(workspace_path, folder_name, 'rules')
            if os.path.isdir(p):
                rules_dir = p
                break
                
        if not rules_dir:
            rules_dir = os.path.join(workspace_path, '.agents', 'rules')
            
        rules = []
        if os.path.exists(rules_dir) and os.path.isdir(rules_dir):
            for item in os.listdir(rules_dir):
                if item.endswith('.md'):
                    file_path = os.path.join(rules_dir, item)
                    try:
                        with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
                            content = f.read()
                        rules.append({
                            'filename': item,
                            'content': content
                        })
                    except Exception:
                        pass
        
        rules.sort(key=lambda x: x['filename'].lower())
        
        sio.emit('workspace_rules_content', {
            'replyTo': reply_to,
            'status': 'ok',
            'rules': rules
        })
    except Exception as e:
        sio.emit('workspace_rules_content', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('save_workspace_rules')
def on_save_workspace_rules(data):
    reply_to = data.get('replyTo') if data else None
    workspace_path = data.get('path', '')
    filename = data.get('filename', '')
    content = data.get('content', '')
    try:
        import os
        if not filename.endswith('.md'):
            filename += '.md'
            
        rules_dir = None
        for folder_name in ('.agents', '.agent'):
            p = os.path.join(workspace_path, folder_name, 'rules')
            if os.path.isdir(p):
                rules_dir = p
                break
                
        if not rules_dir:
            rules_dir = os.path.join(workspace_path, '.agents', 'rules')
            
        os.makedirs(rules_dir, exist_ok=True)
        
        file_path = os.path.join(rules_dir, filename)
        normalized = os.path.abspath(file_path)
        
        with open(normalized, 'w', encoding='utf-8') as f:
            f.write(content)
            
        sio.emit('save_workspace_rules_result', {
            'replyTo': reply_to,
            'status': 'ok',
            'filename': filename
        })
    except Exception as e:
        sio.emit('save_workspace_rules_result', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

@sio.on('delete_workspace_rule')
def on_delete_workspace_rule(data):
    reply_to = data.get('replyTo') if data else None
    workspace_path = data.get('path', '')
    filename = data.get('filename', '')
    try:
        import os
        rules_dir = None
        for folder_name in ('.agents', '.agent'):
            p = os.path.join(workspace_path, folder_name, 'rules')
            if os.path.isdir(p):
                rules_dir = p
                break
                
        if not rules_dir or not os.path.exists(rules_dir):
            raise FileNotFoundError("Pasta de regras não encontrada.")
            
        file_path = os.path.join(rules_dir, filename)
        normalized = os.path.abspath(file_path)
        if os.path.exists(normalized) and os.path.isfile(normalized):
            os.remove(normalized)
            
        sio.emit('delete_workspace_rule_result', {
            'replyTo': reply_to,
            'status': 'ok',
            'filename': filename
        })
    except Exception as e:
        sio.emit('delete_workspace_rule_result', {
            'replyTo': reply_to,
            'status': 'error',
            'message': str(e)
        })

def sync_claude_conversation(cli_conversation_id):
    """Extrai o histórico de uma sessão do Claude CLI (~/.claude/projects/*/<session_id>.jsonl)."""
    import glob as glob_module

    pattern = os.path.join(CLAUDE_DIR, 'projects', '*', f"{cli_conversation_id}.jsonl")
    matches = glob_module.glob(pattern)
    if not matches:
        raise FileNotFoundError(f"Arquivo de sessao do Claude nao encontrado localmente para o ID {cli_conversation_id}.")

    session_path = max(matches, key=os.path.getmtime)
    print(f"[Sync] Lendo sessao do Claude em: {session_path}")

    messages = []
    with open(session_path, 'r', encoding='utf-8', errors='replace') as f:
        for line in f:
            if not line.strip():
                continue
            try:
                line_data = json.loads(line.strip())
                if line_data.get("isMeta") or line_data.get("isSidechain"):
                    continue

                line_type = line_data.get("type")
                message = line_data.get("message", {}) or {}
                timestamp = line_data.get("timestamp", "")

                if line_type == "user":
                    content = message.get("content", "")
                    if isinstance(content, list):
                        # Mensagens com tool_result não são entradas reais do usuário
                        text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
                        content = "\n".join([t for t in text_parts if t])
                    if not content or not isinstance(content, str) or not content.strip():
                        continue
                    # Ignora mensagens internas de comandos/slash commands do Claude Code
                    if content.lstrip().startswith(('<command-name>', '<local-command', 'Caveat:', '<system-reminder>')):
                        continue
                    messages.append({
                        "source": "USER_EXPLICIT",
                        "content": content,
                        "timestamp": timestamp
                    })
                elif line_type == "assistant":
                    content_blocks = message.get("content", [])
                    if isinstance(content_blocks, str):
                        text = content_blocks
                    else:
                        text_parts = [b.get("text", "") for b in content_blocks if isinstance(b, dict) and b.get("type") == "text"]
                        text = "\n".join([t for t in text_parts if t])
                    if text and text.strip():
                        messages.append({
                            "source": "MODEL",
                            "content": text,
                            "timestamp": timestamp
                        })
            except Exception as e:
                print(f"[Sync] Erro ao parsear linha de sessao do Claude: {e}")

    return messages

def sync_codex_conversation(cli_conversation_id):
    """Extrai o historico de uma sessao do Codex CLI (~/.codex/sessions/**/*.jsonl)."""
    import glob as glob_module

    pattern = os.path.join(CODEX_DIR, 'sessions', '**', f"*{cli_conversation_id}*.jsonl")
    matches = glob_module.glob(pattern, recursive=True)
    if not matches:
        raise FileNotFoundError(f"Arquivo de sessao do Codex nao encontrado localmente para o ID {cli_conversation_id}.")

    session_path = max(matches, key=os.path.getmtime)
    print(f"[Sync] Lendo sessao do Codex em: {session_path}")

    messages = []
    with open(session_path, 'r', encoding='utf-8', errors='replace') as f:
        for line in f:
            if not line.strip():
                continue
            try:
                line_data = json.loads(line.strip())
                payload = line_data.get("payload") if isinstance(line_data.get("payload"), dict) else line_data
                event_type = payload.get("type") or line_data.get("type")

                if event_type == "event_msg" and isinstance(payload.get("payload"), dict):
                    inner = payload.get("payload")
                    if inner.get("type") == "agent_message" and inner.get("message"):
                        messages.append({
                            "source": "MODEL",
                            "content": inner.get("message"),
                            "timestamp": line_data.get("timestamp", "")
                        })
                    continue

                if event_type == "response_item":
                    item = payload.get("payload") or payload.get("item") or {}
                    if not isinstance(item, dict) or item.get("type") != "message":
                        continue
                    role = item.get("role")
                    text = _first_text_value(item.get("content"))
                    if not text or not text.strip():
                        continue
                    if role == "user":
                        messages.append({
                            "source": "USER_EXPLICIT",
                            "content": text,
                            "timestamp": line_data.get("timestamp", "")
                        })
                    elif role == "assistant":
                        messages.append({
                            "source": "MODEL",
                            "content": text,
                            "timestamp": line_data.get("timestamp", "")
                        })
            except Exception as e:
                print(f"[Sync] Erro ao parsear linha de sessao do Codex: {e}")

    return messages

@sio.on('force_sync_conversation')
def on_force_sync_conversation(data):
    reply_to = data.get('replyTo') if data else None
    cli_conversation_id = data.get('cli_conversation_id', '')
    client_conv_id = data.get('client_conv_id', '')
    engine = data.get('engine') or 'antigravity'

    print(f"[Sync] Recebida solicitacao para cli_conversation_id: {cli_conversation_id}, client_conv_id: {client_conv_id}, engine: {engine}")
    try:
        import os
        import json
        if not cli_conversation_id:
            raise ValueError("ID de conversa CLI ausente.")

        if engine == 'claude':
            messages = sync_claude_conversation(cli_conversation_id)
            print(f"[Sync] Total de mensagens extraidas da sessao Claude: {len(messages)}")
            if not messages:
                raise ValueError("O historico local da conversa esta vazio ou nao possui interacoes validas.")
            sio.emit('force_sync_conversation_result', {
                'replyTo': reply_to,
                'client_conv_id': client_conv_id,
                'status': 'ok',
                'messages': messages
            })
            print(f"[Sync] Sincronizacao (claude) enviada com sucesso para client_conv_id: {client_conv_id}")
            return

        if engine == 'codex':
            messages = sync_codex_conversation(cli_conversation_id)
            print(f"[Sync] Total de mensagens extraidas da sessao Codex: {len(messages)}")
            if not messages:
                raise ValueError("O historico local da conversa esta vazio ou nao possui interacoes validas.")
            sio.emit('force_sync_conversation_result', {
                'replyTo': reply_to,
                'client_conv_id': client_conv_id,
                'status': 'ok',
                'messages': messages
            })
            print(f"[Sync] Sincronizacao (codex) enviada com sucesso para client_conv_id: {client_conv_id}")
            return

        transcript_path = os.path.join(CLI_DIR, "brain", cli_conversation_id, ".system_generated", "logs", "transcript.jsonl")
        normalized = os.path.abspath(transcript_path)
        print(f"[Sync] Buscando log local em: {normalized}")
        
        if not os.path.exists(normalized) or not os.path.isfile(normalized):
            raise FileNotFoundError(f"Arquivo de log transcript.jsonl nao encontrado localmente para o ID {cli_conversation_id}.")

        messages = []
        with open(normalized, 'r', encoding='utf-8', errors='replace') as f:
            for line in f:
                if not line.strip():
                    continue
                try:
                    line_data = json.loads(line.strip())
                    source = line_data.get("source")
                    type_ = line_data.get("type", "")
                    content = line_data.get("content", "")
                    
                    is_valid_user = (source == "USER_EXPLICIT" and type_ == "USER_INPUT")
                    is_valid_model = (source == "MODEL" and type_ == "PLANNER_RESPONSE")
                    
                    if (is_valid_user or is_valid_model) and content and content.strip():
                        # Limpa tags XML da CLI no input do usuário
                        if source == "USER_EXPLICIT":
                            import re
                            match = re.search(r'<USER_REQUEST>(.*?)</USER_REQUEST>', content, re.DOTALL)
                            if match:
                                content = match.group(1).strip()
                        
                        # Formata o timestamp de ISO UTC para HH:MM local
                        created_at = line_data.get("created_at", "")
                        timestamp_formatted = ""
                        if created_at:
                            try:
                                from datetime import datetime, timezone
                                ts_str = created_at
                                is_utc = False
                                if ts_str.endswith('Z'):
                                    ts_str = ts_str[:-1]
                                    is_utc = True
                                
                                dt = datetime.fromisoformat(ts_str)
                                if is_utc:
                                    dt = dt.replace(tzinfo=timezone.utc)
                                    local_dt = dt.astimezone()
                                else:
                                    local_dt = dt
                                timestamp_formatted = local_dt.strftime("%H:%M")
                            except Exception:
                                if "T" in created_at:
                                    parts = created_at.split("T")
                                    if len(parts) > 1:
                                        timestamp_formatted = parts[1][:5]
                        
                        messages.append({
                            "source": source,
                            "content": content,
                            "timestamp": timestamp_formatted
                        })
                except Exception as e:
                    print(f"[Sync] Erro ao parsear linha de log: {e}")
                    
        print(f"[Sync] Total de mensagens extraidas e formatadas: {len(messages)}")
        if not messages:
            raise ValueError("O historico local da conversa esta vazio ou nao possui interacoes validas.")
            
        sio.emit('force_sync_conversation_result', {
            'replyTo': reply_to,
            'client_conv_id': client_conv_id,
            'status': 'ok',
            'messages': messages
        })
        print(f"[Sync] Sincronizacao enviada com sucesso para client_conv_id: {client_conv_id}")
    except Exception as e:
        print(f"[Sync] Erro na sincronizacao: {e}")
        sio.emit('force_sync_conversation_result', {
            'replyTo': reply_to,
            'client_conv_id': client_conv_id,
            'status': 'error',
            'message': str(e)
        })

def run_test_connectivity_thread(reply_to):
    """Executa agy.exe --version de forma rápida para testar a conectividade."""
    cmd = [AGY_PATH, "--version"]
    clean_env = os.environ.copy()
    for key in list(clean_env.keys()):
        if key.startswith('ANTIGRAVITY_'):
            del clean_env[key]
            
    print(f"\n[+] Rodando teste de conectividade: {' '.join(cmd)}")
    try:
        run_kwargs = {
            'stdin': subprocess.DEVNULL,
            'stdout': subprocess.PIPE,
            'stderr': subprocess.PIPE,
            'text': True,
            'timeout': 3,
            'env': clean_env
        }
        if IS_WINDOWS:
            run_kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
        res = subprocess.run(
            cmd,
            **run_kwargs
        )
        if res.returncode == 0:
            version = res.stdout.strip()
            print(f"[+] Conectividade OK: {version}")
            sio.emit('connectivity_result', {
                'status': 'ok',
                'version': version,
                'replyTo': reply_to
            })
        else:
            error_msg = res.stderr.strip() or f"Código de saída {res.returncode}"
            print(f"[-] Conectividade falhou: {error_msg}")
            sio.emit('connectivity_result', {
                'status': 'error',
                'message': error_msg,
                'replyTo': reply_to
            })
    except Exception as e:
        print(f"[-] Erro no teste de conectividade: {e}")
        sio.emit('connectivity_result', {
            'status': 'error',
            'message': str(e),
            'replyTo': reply_to
        })

@sio.on('test_connectivity')
def on_test_connectivity(data):
    reply_to = data.get('replyTo') if data else None
    t = threading.Thread(target=run_test_connectivity_thread, args=(reply_to,), daemon=True)
    t.start()

@sio.on('run_prompt')
def on_run_prompt(data):
    prompt = data.get('prompt')
    workspace = data.get('workspace')
    cli_conversation_id = data.get('cli_conversation_id')
    client_conv_id = data.get('client_conv_id')
    files = data.get('files')
    model = data.get('model')
    engine = data.get('engine') or 'antigravity'

    print(f"[>] run_prompt recebido para workspace '{workspace}': '{prompt}' (cli_conversation_id={cli_conversation_id}, client_conv_id={client_conv_id}, model={model}, engine={engine})")
    
    log_diagnostic(client_conv_id, "=== NOVO COMANDO RECEBIDO DO RELAY ===")
    log_diagnostic(client_conv_id, f"Prompt: {prompt}")
    log_diagnostic(client_conv_id, f"Workspace: {workspace}")
    log_diagnostic(client_conv_id, f"CLI Conversation ID anterior: {cli_conversation_id}")
    log_diagnostic(client_conv_id, f"Modelo solicitado: {model}")
    log_diagnostic(client_conv_id, f"Engine solicitado: {engine}")
    log_diagnostic(client_conv_id, f"Socket conectado: {sio.connected}")
    
    if not prompt and not files:
        log_diagnostic(client_conv_id, "Erro: Prompt e arquivos vazios. Abortando.")
        return
        
    stop_active_prompt(client_conv_id)
    
    # Processa e salva arquivos locais se houver
    injected_prompt = prompt
    if files and len(files) > 0 and workspace:
        try:
            remote_dir = os.path.join(workspace, 'antigravity_remote')
            os.makedirs(remote_dir, exist_ok=True)
            
            injected_note = "\n\n[Nota do Sistema: O usuário anexou os seguintes arquivos à mensagem. Eles foram salvos no diretório local do projeto em 'antigravity_remote/':\n"
            
            import base64
            for file_info in files:
                filename = file_info.get('filename')
                b64_data = file_info.get('base64') or file_info.get('data')
                if not filename or not b64_data:
                    continue
                
                # Remove prefixo base64 se existir (ex: data:image/png;base64,)
                if ',' in b64_data:
                    b64_data = b64_data.split(',')[1]
                    
                file_bytes = base64.b64decode(b64_data)
                local_file_path = os.path.join(remote_dir, filename)
                with open(local_file_path, 'wb') as f:
                    f.write(file_bytes)
                
                relative_path = f"antigravity_remote/{filename}"
                injected_note += f"- '{relative_path}' ({len(file_bytes)} bytes)\n"
                print(f"[+] Arquivo salvo no workspace: {relative_path}")
                log_diagnostic(client_conv_id, f"Arquivo anexo salvo: {relative_path} ({len(file_bytes)} bytes)")
                
            injected_note += "Esses arquivos fazem parte do contexto do pedido dessa mensagem.]\n\n"
            injected_prompt = injected_note + (prompt or "")
        except Exception as e:
            err_msg = f"Erro ao salvar arquivos locais no workspace: {e}"
            print(f"[-] {err_msg}")
            log_diagnostic(client_conv_id, err_msg)
            
    t = threading.Thread(
        target=run_agy_thread,
        args=(injected_prompt, workspace, cli_conversation_id, client_conv_id),
        kwargs={'model': model, 'engine': engine},
        daemon=True
    )
    t.start()

@sio.on('stop_generation')
def on_stop_generation(data=None):
    print("[>] stop_generation recebido.")
    client_conv_id = data.get('client_conv_id') if data else None
    if client_conv_id:
        stop_active_prompt(client_conv_id)
    else:
        stop_all_prompts()

@sio.on('shutdown_computer')
def on_shutdown_computer(data=None):
    print("[>] Comando de desligamento recebido do Cloud Relay.")
    import platform
    import subprocess
    try:
        if platform.system() == "Windows":
            subprocess.run(["shutdown", "/s", "/f", "/t", "0"])
        elif platform.system() == "Darwin": # macOS
            subprocess.run(["osascript", "-e", "tell app \"System Events\" to shut down"])
        else: # Linux/Unix
            subprocess.run(["shutdown", "-h", "now"])
    except Exception as e:
        print(f"[-] Erro ao executar comando de desligamento: {e}")

def resume_active_tasks():
    tasks = load_active_tasks()
    if not tasks:
        return
        
    print(f"[*] Encontradas {len(tasks)} tarefas ativas para retomar...")
    for client_conv_id, t_info in list(tasks.items()):
        pid = t_info.get("pid")
        workspace = t_info.get("workspace")
        cli_conversation_id = t_info.get("cli_conversation_id")
        prompt = t_info.get("prompt")
        offset = t_info.get("offset", 0)
        transcript_path = t_info.get("transcript_path")
        model = t_info.get("model")
        engine = t_info.get("engine") or 'antigravity'

        is_running = is_pid_cli_running(pid, engine)
        print(f"    - Tarefa {client_conv_id[:8]}: PID={pid}, Rodando={is_running}, Model={model}, Engine={engine}")

        # Inicia thread para monitorar/retomar a tarefa
        t = threading.Thread(
            target=run_agy_thread,
            args=(prompt, workspace, cli_conversation_id, client_conv_id, pid, offset, transcript_path, not is_running),
            kwargs={'model': model, 'engine': engine},
            daemon=True
        )
        t.start()

# ========== INICIALIZAÇÃO E EXECUÇÃO ==========

def setup_tray():
    global icon
    # Inicia a checagem automática e conexão
    threading.Thread(target=check_auto_connect, daemon=True).start()
    threading.Thread(target=resume_active_tasks, daemon=True).start()
    
    headless_mode = '--headless' in sys.argv

    if pystray is None and not headless_mode:
        print("[*] pystray/Pillow indisponíveis. Mudando automaticamente para modo headless...")
        headless_mode = True

    if headless_mode:
        print("[*] Iniciando agente em modo headless (sem bandeja)...")
    else:
        try:
            # Inicializa o ícone de bandeja como offline (vermelho)
            icon = pystray.Icon("antigravity_agent", create_ufo_image('#ff5252'), "Antigravity Agent")
            update_tray_menu()
            
            # Roda o tray icon (bloqueante)
            icon.run()
            return
        except Exception as tray_err:
            print(f"[-] Erro ao carregar/rodar bandeja do sistema (Pystray): {tray_err}")
            print("[*] Mudando automaticamente para modo headless...")
        
    print("[*] Mantendo o agente ativo em segundo plano (modo headless)...")
    # Mantém a thread principal ativa caso a bandeja falhe ou retorne
    try:
        while True:
            time.sleep(60)
    except KeyboardInterrupt:
        pass

_lock_socket = None
def ensure_single_instance():
    global _lock_socket
    import socket as sys_socket_module
    import sys
    try:
        _lock_socket = sys_socket_module.socket(sys_socket_module.AF_INET, sys_socket_module.SOCK_STREAM)
        _lock_socket.bind(('127.0.0.1', 55677))
    except sys_socket_module.error:
        print("[*] Outra instancia do Antigravity Agent ja esta rodando (porta 55677 ocupada). Saindo...")
        sys.exit(0)

if __name__ == '__main__':
    ensure_single_instance()
    print("=" * 60)
    print("      Antigravity CLI Agent (Tray Mode)")
    print(f"      Máquina: {sys_socket.gethostname()}")
    print(f"      Binário CLI (Antigravity): {AGY_PATH}")
    print(f"      Binário CLI (Claude): {CLAUDE_PATH}")
    print(f"      Binário CLI (Codex): {CODEX_PATH}")
    print("=" * 60)
    
    try:
        setup_tray()
    except KeyboardInterrupt:
        print("\n[*] Encerrado pelo usuário.")
        stop_all_prompts()
        if sio.connected:
            sio.disconnect()
