From baa43608b28877dfa9f7763a2bee008ecc3764f1 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Wed, 15 Apr 2026 07:08:08 -0300 Subject: [PATCH 1/8] fix: add PID file lock to prevent multiple scheduler instances When systemd restarted the service, concurrent scheduler processes would pile up causing routines (e.g. morning briefing) to fire multiple times. acquire_lock() checks for a live PID on startup; release_lock() cleans up the PID file on SIGINT/SIGTERM. Co-Authored-By: Claude Sonnet 4.6 --- scheduler.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/scheduler.py b/scheduler.py index e5233b5c..b9109c28 100644 --- a/scheduler.py +++ b/scheduler.py @@ -16,6 +16,29 @@ WORKSPACE = Path(__file__).parent PYTHON = "uv run python" if os.system("command -v uv > /dev/null 2>&1") == 0 else "python3" ROUTINES_DIR = WORKSPACE / "ADWs" / "routines" +PID_FILE = WORKSPACE / "ADWs" / "logs" / "scheduler.pid" + + +def acquire_lock() -> bool: + """Ensure only one scheduler instance runs. Returns False if another is alive.""" + if PID_FILE.exists(): + try: + existing_pid = int(PID_FILE.read_text().strip()) + # Check if that process is still alive + os.kill(existing_pid, 0) + print(f" Scheduler already running (PID {existing_pid}). Exiting.") + return False + except (ProcessLookupError, ValueError): + # Stale PID file — previous instance is dead + PID_FILE.unlink(missing_ok=True) + + PID_FILE.write_text(str(os.getpid())) + return True + + +def release_lock(): + """Remove PID file on clean shutdown.""" + PID_FILE.unlink(missing_ok=True) def run_adw(name: str, script: str, args: str = ""): @@ -115,6 +138,9 @@ def main(): """Entry point — standalone scheduler.""" import schedule + if not acquire_lock(): + sys.exit(1) + print("EvoNexus Scheduler") setup_schedule() total = len(schedule.get_jobs()) @@ -122,6 +148,7 @@ def main(): print(f" Press Ctrl+C to stop\n") def shutdown(sig, frame): + release_lock() print("\n Scheduler stopped") sys.exit(0) From 0b051af1a711bfe06218274e699e50ae866d4706 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Thu, 16 Apr 2026 06:54:32 -0300 Subject: [PATCH 2/8] fix(scheduler): atomic PID lock to prevent duplicate instances Replace TOCTOU-prone check-then-create with O_CREAT|O_EXCL atomic open. Prevents multiple schedulers from starting simultaneously during rapid restarts, which caused routines (review-todoist, git-sync) to fire multiple times and send duplicate Telegram messages. Co-Authored-By: Claude Sonnet 4.6 --- scheduler.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/scheduler.py b/scheduler.py index b9109c28..b3ee922a 100644 --- a/scheduler.py +++ b/scheduler.py @@ -20,20 +20,36 @@ def acquire_lock() -> bool: - """Ensure only one scheduler instance runs. Returns False if another is alive.""" - if PID_FILE.exists(): + """Ensure only one scheduler instance runs. Returns False if another is alive. + + Uses O_CREAT|O_EXCL for atomic creation, then validates the PID inside. + Avoids the TOCTOU race where two processes both see a stale PID file and + both proceed to start. + """ + import fcntl + try: + fd = os.open(str(PID_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + return True + except FileExistsError: + # File exists — check if the owner is still alive try: existing_pid = int(PID_FILE.read_text().strip()) - # Check if that process is still alive os.kill(existing_pid, 0) print(f" Scheduler already running (PID {existing_pid}). Exiting.") return False except (ProcessLookupError, ValueError): - # Stale PID file — previous instance is dead + # Stale lock — remove and retry once PID_FILE.unlink(missing_ok=True) - - PID_FILE.write_text(str(os.getpid())) - return True + try: + fd = os.open(str(PID_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + return True + except FileExistsError: + print(" Scheduler lock contention — another instance just started. Exiting.") + return False def release_lock(): From b7bfab50c9ada812e5bc22a828206cfcec7f1d39 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Thu, 16 Apr 2026 06:58:00 -0300 Subject: [PATCH 3/8] fix(dashboard): restart-all kills processes directly instead of systemctl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit systemctl restart on Type=oneshot + KillMode=none doesn't reliably kill child processes. New approach: pkill processes directly then re-run start-services.sh — works without sudo and actually restarts everything. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/backend/routes/services.py | 40 +++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/dashboard/backend/routes/services.py b/dashboard/backend/routes/services.py index 7af8d773..5a6ee801 100644 --- a/dashboard/backend/routes/services.py +++ b/dashboard/backend/routes/services.py @@ -122,28 +122,38 @@ def run_routine(routine_id): @bp.route("/api/services/restart-all", methods=["POST"]) def restart_all_services(): - """Restart the EvoNexus systemd service (dashboard + scheduler + terminal-server). - Spawns the restart with a delay so the HTTP response can be sent first.""" - import shutil - if not shutil.which("systemctl"): - return jsonify({"error": "systemctl not available (not running as systemd service)"}), 400 + """Restart all EvoNexus services (dashboard + scheduler + terminal-server). - # Check if the service exists - result = subprocess.run( - ["systemctl", "is-enabled", "evo-nexus"], - capture_output=True, text=True + Kills processes directly and re-runs start-services.sh, bypassing + 'systemctl restart' which doesn't reliably kill children on Type=oneshot + services with KillMode=none. + """ + import shutil + import os + workspace = str(WORKSPACE) + start_script = os.path.join(workspace, "start-services.sh") + + if not os.path.exists(start_script): + return jsonify({"error": "start-services.sh not found"}), 400 + + # Kill existing processes then re-run start-services.sh. + # sleep 2 gives Flask time to send this response before app.py dies. + cmd = ( + "sleep 2 && " + "pkill -f 'terminal-server/bin/server.js' 2>/dev/null; " + "pkill -f 'python.*scheduler.py' 2>/dev/null; " + "pkill -f 'python.*app.py' 2>/dev/null; " + "sleep 1 && " + f"bash {start_script}" ) - if result.returncode != 0: - return jsonify({"error": "evo-nexus service not found. Run: sudo bash install-service.sh"}), 400 - - # Spawn restart with delay so this response can be sent subprocess.Popen( - ["bash", "-c", "sleep 2 && systemctl restart evo-nexus"], + ["bash", "-c", cmd], start_new_session=True, + cwd=workspace, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - return jsonify({"status": "restarting", "message": "Service will restart in ~2 seconds"}) + return jsonify({"status": "restarting", "message": "Services will restart in ~3 seconds"}) TELEGRAM_LOG = f"{WORKSPACE_STR}/ADWs/logs/telegram.log" From 77826155d722b8847c093386cc728acf20495581 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Thu, 16 Apr 2026 09:18:08 -0300 Subject: [PATCH 4/8] fix(heartbeat): pass prompt as positional arg instead of -p flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude CLI does not have a -p flag — prompt must be passed as a positional argument. The old -p flag caused the YAML frontmatter (---) to be interpreted as an unknown CLI option, failing all heartbeats with: unknown option '---\nname: "zara-cs"'. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/backend/heartbeat_runner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dashboard/backend/heartbeat_runner.py b/dashboard/backend/heartbeat_runner.py index ef2cdcea..d247911f 100644 --- a/dashboard/backend/heartbeat_runner.py +++ b/dashboard/backend/heartbeat_runner.py @@ -239,7 +239,8 @@ def step7_invoke_claude( "--print", "--max-turns", str(max_turns), "--dangerously-skip-permissions", - "-p", prompt, + "--output-format", "json", + prompt, # positional argument — Claude CLI does not have a -p flag ] start_time = time.time() From e2ae123c583627f55f17a8d3a65cdc0bab63cdb8 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Fri, 17 Apr 2026 13:20:53 -0300 Subject: [PATCH 5/8] fix(scheduler): remove duplicate scheduler thread from app.py The dashboard was running scheduler.py both as a standalone process (via start-services.sh) and as an embedded thread (app.py), causing every routine to fire 2-3x per trigger. Removed the _run_scheduler thread; kept a lightweight _poll_scheduled_tasks thread for one-off ScheduledTask DB entries only. feat(fin): add Evo Academy as revenue source in financial skills fin-daily-pulse, fin-weekly-report and fin-monthly-close-kickoff now pull data from the Evo Academy Analytics API (summary, orders, subs) and consolidate it alongside Stripe and Omie. MRR, daily revenue and P&L now reflect courses, subscriptions and Summit tickets. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/fin-daily-pulse/SKILL.md | 41 ++++++++++++-- .../skills/fin-monthly-close-kickoff/SKILL.md | 44 ++++++++++----- .claude/skills/fin-weekly-report/SKILL.md | 34 ++++++++++-- dashboard/backend/app.py | 53 ++++++------------- 4 files changed, 116 insertions(+), 56 deletions(-) diff --git a/.claude/skills/fin-daily-pulse/SKILL.md b/.claude/skills/fin-daily-pulse/SKILL.md index 9a51c28f..20a32dac 100644 --- a/.claude/skills/fin-daily-pulse/SKILL.md +++ b/.claude/skills/fin-daily-pulse/SKILL.md @@ -1,11 +1,11 @@ --- name: fin-daily-pulse -description: "Daily financial pulse — queries Stripe (MRR, charges, churn, failures) and Omie (accounts payable/receivable, invoices) to generate an HTML snapshot of the company's financial health. Trigger when user says 'financial pulse', 'financial snapshot', or 'financial metrics'." +description: "Daily financial pulse — queries Stripe (MRR, charges, churn, failures), Omie (accounts payable/receivable, invoices) and Evo Academy (courses, subscriptions, Summit tickets) to generate an HTML snapshot of the company's financial health. Trigger when user says 'financial pulse', 'financial snapshot', or 'financial metrics'." --- # Financial Pulse — Daily Financial Snapshot -Daily routine that pulls data from Stripe and Omie to generate an HTML snapshot of financial health. +Daily routine that pulls data from Stripe, Omie and Evo Academy to generate an HTML snapshot of financial health. **Always respond in English.** @@ -47,15 +47,49 @@ Use the `/int-omie` skill to fetch: - Fetch invoices pending issuance - Count invoices issued in the current month + +## Step 2.5 — Collect Evo Academy data (silently) + +Call the Evo Academy Analytics API directly: +- **Base URL:** `$EVO_ACADEMY_BASE_URL` (env var) +- **Auth:** `Authorization: Bearer $EVO_ACADEMY_API_KEY` + +### 2.5a. Summary do dia +``` +GET /api/v1/analytics/summary?period=today +``` +Captura: `revenue.total`, `orders.completed`, `orders.pending`, `orders.failed`, `subscriptions.active`, `students.new_in_period` + +### 2.5b. Orders completados hoje +``` +GET /api/v1/analytics/orders?status=completed&created_after=YYYY-MM-DD&per_page=100 +``` +(hoje em BRT; converter para UTC → `created_after = date.today().isoformat()`) +- Itere paginação por cursor até `meta.has_more = false` +- Some `amount` de todos os orders → receita bruta Evo Academy do dia +- Separe por tipo: renovações (`is_renewal=true`) vs novos (`is_renewal=false`) +- Agrupe por produto: cursos, assinaturas, ingressos, outros + +### 2.5c. MRR de assinaturas ativas (Evo Academy) +``` +GET /api/v1/analytics/subscriptions?status=active&per_page=100 +``` +- Itere até `meta.has_more = false` +- Some `plan.price` de cada assinatura ativa → MRR Evo Academy + ## Step 3 — Day's transactions Consolidate all financial transactions for the day: - Stripe charges (revenue) +- Evo Academy orders (revenue — courses / subscriptions / tickets) - Payments recorded in Omie (expenses) - Refunds Format each transaction with: type (Revenue/Expense/Refund), description, amount, status. +**Total revenue = Stripe today + Evo Academy today** +**Total MRR = Stripe MRR + Evo Academy MRR** + ## Step 4 — Classify financial health Define the health badge (CSS class): @@ -105,7 +139,8 @@ Create the directory `workspace/finance/reports/daily/` if it does not exist. ## Financial Pulse generated **File:** workspace/finance/reports/daily/[C] YYYY-MM-DD-financial-pulse.html -**MRR:** R$ X,XXX | **Subscriptions:** N | **Churn:** X% +**MRR total:** R$ X,XXX (Stripe: R$ X,XXX | Evo Academy: R$ X,XXX) +**Receita hoje:** R$ X,XXX | **Subscriptions:** N | **Churn:** X% **Alerts:** {N} attention points ``` diff --git a/.claude/skills/fin-monthly-close-kickoff/SKILL.md b/.claude/skills/fin-monthly-close-kickoff/SKILL.md index 64ffad92..a62548f3 100644 --- a/.claude/skills/fin-monthly-close-kickoff/SKILL.md +++ b/.claude/skills/fin-monthly-close-kickoff/SKILL.md @@ -1,6 +1,6 @@ --- name: fin-monthly-close-kickoff -description: "Monthly close kickoff — initiates the month-end closing process with a checklist, simplified P&L, pending reconciliations, receivables, payables, and action items for the finance team. Trigger when user says 'monthly close', 'start closing', 'closing kickoff', or on the 1st of each month." +description: "Monthly close kickoff — initiates the month-end closing process with a checklist, simplified P&L (Stripe + Omie + Evo Academy), pending reconciliations, receivables, payables, and action items for the finance team. Trigger when user says 'monthly close', 'start closing', 'closing kickoff', or on the 1st of each month." --- # Monthly Close Kickoff @@ -33,14 +33,30 @@ Use `/int-omie`: - Invoices issued during the month - Invoices that should have been issued but were not -### 2c. Outstanding receivables + +### 2c. Revenue (Evo Academy) +Call `GET /api/v1/analytics/summary?period=30d` (env: `$EVO_ACADEMY_BASE_URL`, auth: `Bearer $EVO_ACADEMY_API_KEY`): +- `revenue.total` → receita bruta do mês +- `orders.completed / pending / refunded` → contagem por status +- `subscriptions.active / cancelled` → base e churn do mês + +Fetch todos os orders do mês: `GET /api/v1/analytics/orders?status=completed&created_after=YYYY-MM-01&created_before=YYYY-MM-31&per_page=100` +- Itere por cursor até `has_more=false` +- Some `amount` → receita total do mês +- Separe por produto: Evo Academy (R$950/mês), Evolution Builder (R$970/mês), Curso Agentic Engineer (R$2k/mês), Beta Access (R$370/mês), one-time (Blueprint Pack, Fast Start Pro), Evo Setup (R$5/mês) +- Identifique renovações (`is_renewal=true`) vs novos clientes + +Fetch assinaturas ativas no fim do mês: `GET /api/v1/analytics/subscriptions?status=active&per_page=100` +- MRR Evo Academy = soma de `plan.price` das ativas + +### 2d. Outstanding receivables - List all open receivables (from the month or earlier) - Highlight overdue items -### 2d. Next month's payables +### 2e. Next month's payables - List payables due in the current month (the upcoming month) -### 2e. Previous month (for comparison) +### 2f. Previous month (for comparison) - Read the previous month's financial report from `workspace/finance/reports/monthly/` if it exists - Or use data from the last monthly close @@ -51,6 +67,7 @@ Structure the income statement with: | Account | Actual | Prior Month | Variance | |---------|--------|-------------|----------| | Gross Revenue (Stripe) | | | | +| Gross Revenue (Evo Academy) | | | | | Gross Revenue (Omie/Services) | | | | | (-) Taxes | | | | | **Net Revenue** | | | | @@ -68,14 +85,15 @@ Structure the income statement with: Generate a checklist with initial status for each item: 1. **Reconcile Stripe** — verify all charges match received payments -2. **Reconcile Omie** — verify entries and exits in the ERP are correct -3. **Issue pending invoices** — list invoices that need to be issued (finance team) -4. **Collect overdue accounts** — list clients with late payments -5. **Categorize expenses** — verify all expenses are categorized -6. **Review entries** — verify manual or atypical entries -7. **Calculate taxes** — verify month's tax obligations -8. **Generate final income statement** — after reconciliations, generate the definitive P&L -9. **Approve close** — the responsible person reviews and approves +2. **Reconcile Evo Academy** — verify orders and subscriptions match expected MRR +3. **Reconcile Omie** — verify entries and exits in the ERP are correct +4. **Issue pending invoices** — list invoices that need to be issued (finance team) +5. **Collect overdue accounts** — list clients with late payments +6. **Categorize expenses** — verify all expenses are categorized +7. **Review entries** — verify manual or atypical entries +8. **Calculate taxes** — verify month's tax obligations +9. **Generate final income statement** — after reconciliations, generate the definitive P&L +10. **Approve close** — the responsible person reviews and approves Possible statuses: - `done` (checkmark) — already completed automatically @@ -161,7 +179,7 @@ Create the directory `workspace/finance/reports/monthly/` if it does not exist. **File:** workspace/finance/reports/monthly/[C] YYYY-MM-monthly-close.html **Month:** {reference month} **Revenue:** R$ X,XXX | **Expenses:** R$ X,XXX | **Result:** R$ X,XXX -**Checklist:** X/9 completed +**Checklist:** X/10 completed **Finance team pending items:** {N} items ``` diff --git a/.claude/skills/fin-weekly-report/SKILL.md b/.claude/skills/fin-weekly-report/SKILL.md index b89291cd..ef3c85f2 100644 --- a/.claude/skills/fin-weekly-report/SKILL.md +++ b/.claude/skills/fin-weekly-report/SKILL.md @@ -1,11 +1,11 @@ --- name: fin-weekly-report -description: "Weekly financial report — consolidates Stripe and Omie data for the week: revenue, expenses, cash flow projection, overdue accounts, and variance analysis. Trigger when user says 'financial weekly', 'weekly financial report', or 'financial summary of the week'." +description: "Weekly financial report — consolidates Stripe, Omie and Evo Academy data for the week: revenue (courses, subscriptions, tickets), expenses, cash flow projection, overdue accounts, and variance analysis. Trigger when user says 'financial weekly', 'weekly financial report', or 'financial summary of the week'." --- # Financial Weekly — Weekly Financial Report -Weekly routine that consolidates the week's financial data: revenue, expenses, Stripe, Omie, projected cash flow, and analysis. +Weekly routine that consolidates the week's financial data: revenue, expenses, Stripe, Omie, Evo Academy, projected cash flow, and analysis. **Always respond in English.** @@ -24,8 +24,25 @@ Use `/int-omie` to fetch: - Confirmed receipts for the week - Invoices issued during the week + +### 1c. Evo Academy — revenue +Call `GET /api/v1/analytics/summary?period=7d` (env: `$EVO_ACADEMY_BASE_URL`, auth: `Bearer $EVO_ACADEMY_API_KEY`): +- `revenue.total` → receita bruta da semana +- `orders.completed` → número de vendas +- `subscriptions.active` / `subscriptions.cancelled` → net change + +Fetch orders da semana: `GET /api/v1/analytics/orders?status=completed&created_after=YYYY-MM-DD&per_page=100` +- Itere por cursor até `has_more=false` +- Some `amount` → receita total Evo Academy na semana +- Separe: renovações vs novos, one-time vs assinatura + +Fetch assinaturas novas na semana: `GET /api/v1/analytics/subscriptions?status=active&created_after=YYYY-MM-DD&per_page=100` +- MRR adicionado = soma dos `plan.price` de assinaturas criadas na semana + Group revenue by category: - Stripe Subscriptions +- Evo Academy — Courses & Subscriptions +- Evo Academy — One-time (tickets, packs) - Services / Consulting - Partnerships - Other @@ -59,10 +76,19 @@ Consolidate the week's Omie metrics: - Invoices issued during the week - Confirmed receipts +## Step 4.5 — Detailed Evo Academy metrics + +Consolidate Evo Academy's week metrics: +- MRR (sum of all active subscription `plan.price`) and variance vs prior week +- New subscriptions vs cancellations +- One-time revenue (tickets, packs, live events) +- Top-selling products of the week +- Students enrolled (`students.new_in_period`) + ## Step 5 — Cash flow projection (4 weeks) Based on collected data, project: -- Expected inflows (Stripe recurring + receivables) +- Expected inflows (Stripe recurring + Evo Academy subscriptions + receivables) - Expected outflows (payables + recurring expenses) - Balance and cumulative by week @@ -135,7 +161,7 @@ Create the directory `workspace/finance/reports/weekly/` if it does not exist. **File:** workspace/finance/reports/weekly/[C] YYYY-WXX-financial-weekly.html **Revenue:** R$ X,XXX ({var}%) | **Expenses:** R$ X,XXX ({var}%) -**MRR:** R$ X,XXX | **Projected 30d balance:** R$ XX,XXX +**MRR total:** R$ X,XXX (Stripe: R$ X,XXX | Evo Academy: R$ X,XXX) | **Projected 30d balance:** R$ XX,XXX **Alerts:** {N} overdue accounts | {N} pending invoices ``` diff --git a/dashboard/backend/app.py b/dashboard/backend/app.py index 278a5b32..62aa0312 100644 --- a/dashboard/backend/app.py +++ b/dashboard/backend/app.py @@ -637,13 +637,16 @@ def serve_frontend(path): port = int(cfg["port"]) except Exception: pass - # Start scheduler in background thread + # Scheduler runs as a standalone process (scheduler.py) started by start-services.sh. + # A thread here would create a duplicate instance — all routines would fire 2-3x. + # One-off scheduled tasks (ScheduledTask model) are checked by the standalone scheduler + # via _run_pending_tasks, which is called from its own loop. import threading + def _run_pending_tasks(): """Check for pending scheduled tasks and execute them.""" from datetime import datetime as _dt, timezone as _tz from models import ScheduledTask - from routes.tasks import _execute_task try: now = _dt.now(_tz.utc) @@ -659,45 +662,23 @@ def _run_pending_tasks(): t = threading.Thread(target=_execute_task_with_context, args=(task.id,), daemon=True) t.start() - except Exception as e: - pass # Don't crash scheduler loop on task errors + except Exception: + pass def _execute_task_with_context(task_id): with app.app_context(): from routes.tasks import _execute_task _execute_task(task_id) - def _run_scheduler(): - log_path = WORKSPACE / "ADWs" / "logs" / "scheduler.log" - log_path.parent.mkdir(parents=True, exist_ok=True) - try: - import importlib.util - spec = importlib.util.spec_from_file_location("scheduler", WORKSPACE / "scheduler.py") - sched_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(sched_module) - sched_module.setup_schedule() - - import schedule as sched_lib - import time as _time - from datetime import datetime as _dt - - with open(log_path, "a") as log: - log.write(f"\n[{_dt.now().strftime('%Y-%m-%d %H:%M:%S')}] Scheduler started ({len(sched_lib.get_jobs())} routines)\n") - log.flush() - - while True: - sched_lib.run_pending() - # Check for one-off scheduled tasks - with app.app_context(): - _run_pending_tasks() - _time.sleep(30) - except Exception as e: - with open(log_path, "a") as log: - log.write(f"Scheduler error: {e}\n") - print(f"Scheduler failed to start: {e}") - - sched_thread = threading.Thread(target=_run_scheduler, daemon=True, name="scheduler") - sched_thread.start() - print(f" Scheduler started in background") + def _poll_scheduled_tasks(): + """Lightweight thread that only polls ScheduledTask — no routine scheduling.""" + import time as _time + while True: + with app.app_context(): + _run_pending_tasks() + _time.sleep(30) + + task_thread = threading.Thread(target=_poll_scheduled_tasks, daemon=True, name="task-poller") + task_thread.start() app.run(host="0.0.0.0", port=port, debug=False) From 378be721bb69500c4c15e1348de8d67c767f5a09 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Mon, 20 Apr 2026 10:10:21 -0300 Subject: [PATCH 6/8] fix(skills): prevent duplicate Telegram notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prod-end-of-day: Step 5 now reviews Todoist directly instead of calling /prod-review-todoist as sub-skill, which was sending its own Telegram notification before the EOD notification — causing 2x Telegram per EOD run - pulse-faq-sync: explicit instruction to send exactly ONE Telegram message combining summary + alerts, instead of splitting into multiple reply() calls Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/prod-end-of-day/SKILL.md | 5 ++++- .claude/skills/pulse-faq-sync/SKILL.md | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.claude/skills/prod-end-of-day/SKILL.md b/.claude/skills/prod-end-of-day/SKILL.md index bef109eb..88059d11 100644 --- a/.claude/skills/prod-end-of-day/SKILL.md +++ b/.claude/skills/prod-end-of-day/SKILL.md @@ -72,7 +72,10 @@ The log should include: ## Step 5 — Organize tasks -Run `/prod-review-todoist` to ensure tasks created during the day are categorized and translated. +Review Todoist tasks directly (do NOT invoke `/prod-review-todoist` as a sub-skill — it sends a duplicate Telegram notification): +- Run `todoist today` to list today's tasks +- For each uncategorized or non-PT-BR task: rename/recategorize via `todoist update` +- Report how many were organized ## Step 6 — Confirm diff --git a/.claude/skills/pulse-faq-sync/SKILL.md b/.claude/skills/pulse-faq-sync/SKILL.md index e2de89df..f1aefdb0 100644 --- a/.claude/skills/pulse-faq-sync/SKILL.md +++ b/.claude/skills/pulse-faq-sync/SKILL.md @@ -206,7 +206,8 @@ The "Skipped" block is mandatory — it gives visibility on questions the commun ### Notify via Telegram -Upon completion, send a short summary via Telegram to the user: +Upon completion, send **exactly ONE** Telegram message with the full summary: - Use the Telegram MCP: `reply(chat_id="YOUR_CHAT_ID", text="...")` -- Format: emoji + routine name + main result (1-3 lines) +- Format: emoji + routine name + main result (totals + alerts combined in one message) +- Do NOT split into multiple messages — combine summary and alerts into a single call - If the routine had no updates, send anyway with "no updates" From db1eafff47a6f2732bcdd25108f6d4cd9ed0921d Mon Sep 17 00:00:00 2001 From: Pedro Augusto Date: Tue, 21 Apr 2026 05:24:00 -0400 Subject: [PATCH 7/8] fix: support openclaude path and disable broken agent chat on codex --- ADWs/runner.py | 28 +++++++++- dashboard/backend/routes/providers.py | 56 +++++++++++++++---- dashboard/frontend/src/pages/AgentDetail.tsx | 38 ++++++++++++- .../terminal-server/src/claude-bridge.js | 36 +++++++++++- start-services.sh | 26 +++------ 5 files changed, 149 insertions(+), 35 deletions(-) diff --git a/ADWs/runner.py b/ADWs/runner.py index 6d1533a7..35c88ce8 100644 --- a/ADWs/runner.py +++ b/ADWs/runner.py @@ -7,6 +7,7 @@ import os import sys import json +import shutil from datetime import datetime from pathlib import Path @@ -127,6 +128,27 @@ def _log_to_file(log_name, prompt, stdout, stderr, returncode, duration, usage=N _ALLOWED_CLI_COMMANDS = frozenset({"claude", "openclaude"}) +def _augment_path(base_path: str | None = None) -> str: + """Extend PATH with common user-local install locations for CLI tools.""" + path_parts = [p for p in (base_path or "").split(os.pathsep) if p] + for candidate in ( + str(Path.home() / ".local" / "bin"), + str(Path.home() / ".npm-global" / "bin"), + "/usr/local/bin", + "/usr/bin", + "/bin", + ): + if candidate not in path_parts: + path_parts.append(candidate) + return os.pathsep.join(path_parts) + + +def _resolve_cli_path(cli_command: str, env: dict) -> str: + """Resolve CLI binary path from the effective environment.""" + resolved = shutil.which(cli_command, path=env.get("PATH")) + return resolved or cli_command + + def _spawn_cli(cli_command: str, prompt: str, agent: str | None, provider_env: dict) -> subprocess.Popen: """Spawn a CLI process using only hardcoded command strings. @@ -139,6 +161,8 @@ def _spawn_cli(cli_command: str, prompt: str, agent: str | None, provider_env: d base_args.append(prompt) env = {**os.environ, **provider_env, "TERM": "dumb"} + env["PATH"] = _augment_path(env.get("PATH")) + resolved_cli = _resolve_cli_path(cli_command, env) popen_kwargs = dict( stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -149,9 +173,9 @@ def _spawn_cli(cli_command: str, prompt: str, agent: str | None, provider_env: d # Hardcoded dispatch — each branch uses a literal string for the executable if cli_command == "openclaude": - return subprocess.Popen(["openclaude"] + base_args, **popen_kwargs) # noqa: S603 + return subprocess.Popen([resolved_cli] + base_args, **popen_kwargs) # noqa: S603 else: - return subprocess.Popen(["claude"] + base_args, **popen_kwargs) # noqa: S603 + return subprocess.Popen([resolved_cli] + base_args, **popen_kwargs) # noqa: S603 _ALLOWED_ENV_VARS = frozenset({ "CLAUDE_CODE_USE_OPENAI", "CLAUDE_CODE_USE_GEMINI", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL", diff --git a/dashboard/backend/routes/providers.py b/dashboard/backend/routes/providers.py index a2127ef8..f1c1d0a1 100644 --- a/dashboard/backend/routes/providers.py +++ b/dashboard/backend/routes/providers.py @@ -55,6 +55,14 @@ "CLOUD_ML_REGION", }) +_CLI_SEARCH_DIRS = ( + str(Path.home() / ".local" / "bin"), + str(Path.home() / ".npm-global" / "bin"), + "/usr/local/bin", + "/usr/bin", + "/bin", +) + def _read_config() -> dict: """Read providers.json. If missing, copy from providers.example.json.""" @@ -87,28 +95,53 @@ def _mask_secret(value: str) -> str: return value[:6] + "****" + value[-4:] +def _build_cli_env(env: dict | None = None) -> dict: + """Ensure CLI checks inherit common user-local bin directories.""" + merged = dict(env or os.environ) + current_path = merged.get("PATH", "") + path_parts = [p for p in current_path.split(os.pathsep) if p] + for candidate in _CLI_SEARCH_DIRS: + if candidate not in path_parts: + path_parts.append(candidate) + merged["PATH"] = os.pathsep.join(path_parts) + return merged + + +def _resolve_cli_path(command: str, env: dict | None = None) -> str | None: + """Resolve a CLI path using an augmented PATH plus common fallbacks.""" + cli_env = _build_cli_env(env) + resolved = shutil.which(command, path=cli_env.get("PATH")) + if resolved: + return resolved + for directory in _CLI_SEARCH_DIRS: + candidate = Path(directory) / command + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + return None + + def _run_cli_version(command: str, env: dict | None = None) -> dict: """Run ' --version' safely using hardcoded dispatch. Each branch uses a literal string for the executable so that semgrep/opengrep does not flag it as subprocess injection. """ - run_kwargs = dict(capture_output=True, text=True, timeout=10) - if env is not None: - run_kwargs["env"] = env + cli_env = _build_cli_env(env) + resolved = _resolve_cli_path(command, cli_env) + run_kwargs = dict(capture_output=True, text=True, timeout=10, env=cli_env) try: - if command == "openclaude": - result = subprocess.run(["openclaude", "--version"], **run_kwargs) # noqa: S603, S607 - elif command == "claude": - result = subprocess.run(["claude", "--version"], **run_kwargs) # noqa: S603, S607 + if resolved and command == "openclaude": + result = subprocess.run([resolved, "--version"], **run_kwargs) # noqa: S603 + elif resolved and command == "claude": + result = subprocess.run([resolved, "--version"], **run_kwargs) # noqa: S603 else: return {"installed": False, "version": None, "path": None} version = result.stdout.strip() or result.stderr.strip() - return {"installed": True, "version": version, "path": shutil.which(command)} + return {"installed": True, "version": version, "path": resolved} except (subprocess.TimeoutExpired, OSError): - return {"installed": False, "version": None, "path": shutil.which(command)} + return {"installed": False, "version": None, "path": resolved} def _check_cli(command: str) -> dict: @@ -338,7 +371,8 @@ def test_provider(provider_id): if cli not in ALLOWED_CLI_COMMANDS: return jsonify({"success": False, "error": f"Unsupported CLI: {cli}"}), 400 - if not shutil.which(cli): + resolved_cli = _resolve_cli_path(cli) + if not resolved_cli: return jsonify({ "success": False, "error": f"'{cli}' not found in PATH", @@ -349,7 +383,7 @@ def test_provider(provider_id): env_vars = _sanitize_env_vars( {k: v for k, v in provider.get("env_vars", {}).items() if v} ) - test_env = {**os.environ, **env_vars} + test_env = _build_cli_env({**os.environ, **env_vars}) result = _run_cli_version(cli, env=test_env) return jsonify({ diff --git a/dashboard/frontend/src/pages/AgentDetail.tsx b/dashboard/frontend/src/pages/AgentDetail.tsx index 8454cf73..31c11973 100644 --- a/dashboard/frontend/src/pages/AgentDetail.tsx +++ b/dashboard/frontend/src/pages/AgentDetail.tsx @@ -72,6 +72,7 @@ export default function AgentDetail() { const [activeChatSessionId, setActiveChatSessionId] = useState(null) const [chatConnectError, setChatConnectError] = useState(null) const [chatConnecting, setChatConnecting] = useState(false) + const [activeProviderCli, setActiveProviderCli] = useState('claude') // Notification badge state — pending approvals per session const approvalCountsRef = useRef>(new Map()) @@ -104,6 +105,27 @@ export default function AgentDetail() { if (name) trackAgentVisit(name) }, [name]) + useEffect(() => { + let cancelled = false + api.get('/providers/active') + .then((data: { cli_command?: string }) => { + if (!cancelled) setActiveProviderCli(data.cli_command || 'claude') + }) + .catch(() => { + if (!cancelled) setActiveProviderCli('claude') + }) + return () => { cancelled = true } + }, []) + + const chatSupported = activeProviderCli === 'claude' + + useEffect(() => { + if (!chatSupported && viewMode === 'chat') { + setViewMode('terminal') + try { localStorage.setItem('evo:agent-view-mode', 'terminal') } catch {} + } + }, [chatSupported, viewMode]) + // Load existing terminal sessions for this agent useEffect(() => { if (!name) return @@ -523,12 +545,18 @@ export default function AgentDetail() { {/* View mode toggle */}
)} + + {!chatSupported && ( +
+ Chat indisponível com provider OAuth +
+ )} {/* Content */} diff --git a/dashboard/terminal-server/src/claude-bridge.js b/dashboard/terminal-server/src/claude-bridge.js index 8260f0b3..04f1add4 100644 --- a/dashboard/terminal-server/src/claude-bridge.js +++ b/dashboard/terminal-server/src/claude-bridge.js @@ -7,6 +7,24 @@ class ClaudeBridge { this.sessions = new Map(); } + buildCliPath(basePath = '') { + const home = process.env.HOME || '/'; + const extraPaths = [ + path.join(home, '.local', 'bin'), + path.join(home, '.npm-global', 'bin'), + '/usr/local/bin', + '/usr/bin', + '/bin', + ]; + const pathParts = (basePath || '').split(':').filter(Boolean); + for (const candidate of extraPaths) { + if (!pathParts.includes(candidate)) { + pathParts.push(candidate); + } + } + return pathParts.join(':'); + } + /** * Load active provider config from config/providers.json. * Returns the CLI command to use and env vars to inject. @@ -88,15 +106,24 @@ class ClaudeBridge { findClaudeCommand(cliCommand = 'claude') { const { execSync } = require('child_process'); + const resolvedPath = this.buildCliPath(process.env.PATH || ''); // Use shell-based `which` to resolve with full PATH (incl. nvm, fnm, etc.) // Hardcoded dispatch to satisfy semgrep — each branch is a literal string try { let resolved; if (cliCommand === 'openclaude') { - resolved = execSync('which openclaude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + resolved = execSync('which openclaude', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + env: { ...process.env, PATH: resolvedPath } + }).trim(); } else { - resolved = execSync('which claude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + resolved = execSync('which claude', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + env: { ...process.env, PATH: resolvedPath } + }).trim(); } if (resolved) { console.log(`[provider] Found ${cliCommand} at: ${resolved}`); @@ -111,12 +138,14 @@ class ClaudeBridge { const paths = cliCommand === 'openclaude' ? [ path.join(home, '.local', 'bin', 'openclaude'), + path.join(home, '.npm-global', 'bin', 'openclaude'), '/usr/local/bin/openclaude', '/usr/bin/openclaude', ] : [ path.join(home, '.claude', 'local', 'claude'), path.join(home, '.local', 'bin', 'claude'), + path.join(home, '.npm-global', 'bin', 'claude'), '/usr/local/bin/claude', '/usr/bin/claude', ]; @@ -229,6 +258,7 @@ class ClaudeBridge { for (const key of SYSTEM_VARS) { if (process.env[key]) cleanEnv[key] = process.env[key]; } + cleanEnv.PATH = this.buildCliPath(cleanEnv.PATH || process.env.PATH || ''); // Ensure OPENAI_MODEL is set when using an OpenAI-based provider. // OpenClaude's Codex mode requires 'codexplan' or 'codexspark' aliases @@ -417,4 +447,4 @@ class ClaudeBridge { } -module.exports = ClaudeBridge; \ No newline at end of file +module.exports = ClaudeBridge; diff --git a/start-services.sh b/start-services.sh index b5ee194c..18033e5f 100755 --- a/start-services.sh +++ b/start-services.sh @@ -1,26 +1,18 @@ #!/bin/bash -export PATH="/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin" -cd /home/evonexus/evo-nexus +export PATH="/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin:$HOME/.npm-global/bin" +cd /home/pedro/evo-nexus -# Load environment variables -if [ -f .env ]; then - set -a - source .env - set +a -fi - -# Kill existing services (including scheduler) +# Kill existing services pkill -f 'terminal-server/bin/server.js' 2>/dev/null -pkill -f 'python.*app.py' 2>/dev/null -pkill -f 'python.*scheduler.py' 2>/dev/null +pkill -f 'dashboard/backend.*app.py' 2>/dev/null sleep 1 -# Start terminal-server (must run FROM the project root for agent discovery) -nohup node dashboard/terminal-server/bin/server.js > /home/evonexus/evo-nexus/logs/terminal-server.log 2>&1 & +# Clean stale sessions — old sessions cause agent persona issues +rm -f $HOME/.claude-code-web/sessions.json 2>/dev/null -# Start scheduler -nohup /home/evonexus/evo-nexus/.venv/bin/python scheduler.py > /home/evonexus/evo-nexus/logs/scheduler.log 2>&1 & +# Start terminal-server (must run FROM the project root for agent discovery) +nohup node dashboard/terminal-server/bin/server.js > /home/pedro/evo-nexus/logs/terminal-server.log 2>&1 & # Start Flask dashboard cd dashboard/backend -nohup /home/evonexus/evo-nexus/.venv/bin/python app.py > /home/evonexus/evo-nexus/logs/dashboard.log 2>&1 & +nohup /home/pedro/evo-nexus/.venv/bin/python app.py > /home/pedro/evo-nexus/logs/dashboard.log 2>&1 & From 5856a14accf7e2542ce9d54f9cdec13ad79bf3a5 Mon Sep 17 00:00:00 2001 From: Pedro Augusto Date: Tue, 21 Apr 2026 05:26:51 -0400 Subject: [PATCH 8/8] chore: ignore local evo-nexus workspace data --- .gitignore | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.gitignore b/.gitignore index 14069237..19fdc13b 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,14 @@ site/lib/ mempalace.yaml entities.json .claude/worktrees/ +.codex +.codex/ +logs/ +community/ +courses/ +daily-logs/ +finance/ +meetings/ +projects/ +social/ +strategy/