Skip to content

feat(provider): opencode as a first-class provider + HUD panel + heartbeat/routine reliability fixes#121

Open
sistemabritto wants to merge 110 commits into
evolution-foundation:mainfrom
sistemabritto:upstream/opencode-provider-support
Open

feat(provider): opencode as a first-class provider + HUD panel + heartbeat/routine reliability fixes#121
sistemabritto wants to merge 110 commits into
evolution-foundation:mainfrom
sistemabritto:upstream/opencode-provider-support

Conversation

@sistemabritto

Copy link
Copy Markdown

Summary

  • opencode as a provider — adds opencode (agentic coding CLI, native tool-use, routable against any OpenAI-compatible endpoint) as a selectable provider alongside claude/openclaude. Since opencode has no long-lived interactive process, the Terminal spawns it as a headless REPL (one opencode run --format json call per message) instead of a PTY; the same NDJSON parsing is adapted into the heartbeats/Telegram path (provider_fallback.py) and the Chat UI's Agent SDK fallback chain (chat-bridge.js).
  • Provider-fallback resilience — the fallback chain now builds env fresh per attempt (no stale credentials leaking into the next provider), detects error results from the Agent SDK (which complete "normally" instead of raising) and advances the chain instead of surfacing the error, preserves 503 response bodies through retries, and cuts short the CLI's internal backoff so a provider outage degrades to the next configured provider in seconds instead of minutes.
  • Terminal/Chat HUD panel — a small car-dashboard-style status panel (gear/shifter indicator, provider/model + tokens/s readout, busy/heavy semaphore) shared between the embedded Terminal and Chat views, plus a dedicated input bar for opencode's REPL sessions so typing doesn't share the same scrollback as the AI's streamed response.
  • Heartbeat/routine reliability fixes — found while auditing why routines looked unhealthy in the dashboard:
    • One routine stored success_rate as a 0-1 fraction while every consumer (and every other routine, via runner.py) assumes 0-100 — a real 83.3% success rate displayed as "0.8333%" and was misclassified as critical. Fixed at the source; both consumers now derive the percentage fresh from raw counts instead of trusting the stored field.
    • register_interval_jobs() used schedule.every(N).do(job), which always counts from the moment it's registered, not from the heartbeat's last real run — every process restart silently resets every interval heartbeat's countdown, so a system that redeploys with any regularity can drift heartbeats far behind their nominal SLA with nothing actually stuck. Added a catch-up dispatch on registration for anything already overdue by its own run history.
  • OAuth behind a reverse proxyProxyFix + SESSION_COOKIE_SAMESITE=Lax so OAuth callback flows (any provider) work correctly when the dashboard sits behind Traefik/nginx.

Test plan

  • dashboard/terminal-server: node --test test/chat-bridge-fallback.test.js — 9/9 pass
  • dashboard/frontend: tsc --noEmit clean, npm run build clean
  • Python: py_compile clean on all touched backend files
  • Manually reproduced and fixed the opencode ProviderModelNotFoundError masked as a generic "Unexpected server error" (confirmed via opencode --print-logs --log-level DEBUG)
  • Manually confirmed the success_rate unit bug end-to-end (25/30 real successes rendering as "0.8333% / critical" before the fix, "83.3% / healthy" after)

I'd like to be added as a contributor — happy to split this into smaller PRs per area (opencode provider / HUD panel / heartbeat fixes) if that's easier to review.

DavidsonGomes and others added 30 commits June 12, 2026 06:14
…volution-foundation#52)

Vault audit §2.S1 CRITICAL: /api/shares/<token>/view had zero rate
limiting. Add flask-limiter (in-memory, single-process MVP) with:
- 60 req/min/IP on view_share (Vault §2.S1)
- Global default 600 req/min on all other routes (non-blocking baseline)
- Referrer-Policy, Cache-Control no-store, Pragma, HSTS, X-Content-Type-Options
  headers on every public share response (Vault §2.S2)

The Limiter singleton lives in rate_limit.py to break the circular-import
chain between app.py (which imports route blueprints) and the blueprints
that need @limiter.limit() decorators.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ortals (evolution-foundation#53)

* feat(security): rate-limit public share endpoint + security headers

Vault audit §2.S1 CRITICAL: /api/shares/<token>/view had zero rate
limiting. Add flask-limiter (in-memory, single-process MVP) with:
- 60 req/min/IP on view_share (Vault §2.S1)
- Global default 600 req/min on all other routes (non-blocking baseline)
- Referrer-Policy, Cache-Control no-store, Pragma, HSTS, X-Content-Type-Options
  headers on every public share response (Vault §2.S2)

The Limiter singleton lives in rate_limit.py to break the circular-import
chain between app.py (which imports route blueprints) and the blueprints
that need @limiter.limit() decorators.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(plugins): B2.0 public_pages capability — read-only token-bound portals

Add the public_pages plugin capability (B2.0 scope, read-only):

plugin_schema.py:
- Add Capability.public_pages and Capability.safe_uninstall enum values
- Add PluginPublicPageTokenSource + PluginPublicPage Pydantic models
  (bundle must be under ui/public/, revoked_when disallowed in v1 to prevent SQL injection)
- Extend ReadonlyQuery with public_via + bind_token_param fields
- Add 4 PluginManifest model validators: capability required, table slug-prefix,
  unique ids/route_prefixes, readonly_data references valid page

routes/plugin_public_pages.py (new):
- GET /p/<slug>/<route_prefix>/<token>       — serve portal bundle (60 req/min/IP)
- GET /p/<slug>/<route_prefix>/<token>/data  — serve token-bound readonly query (120/min)
- GET /p/<slug>/public-assets/<path>         — serve ui/public/ static assets
- Token validation via parametric SQL (identifiers validated at install by schema)
- Module-level _PLUGIN_PUBLIC_PREFIXES cache for install/uninstall lifecycle
- Vault §B2.S2: Referrer-Policy, Cache-Control no-store, HSTS, X-Content-Type-Options on all responses
- CSP: default-src 'self' on portal bundles
- Rate limiting via rate_limit.limiter (imported from PR evolution-foundation#52)

app.py:
- Import and register plugin_public_pages_bp
- /p/... paths already bypass auth_middleware (non-/api/ paths are passthrough)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…vation, sandboxed hook (evolution-foundation#54)

- plugin_schema.py: PluginSafeUninstall, PluginPreUninstallHook, PluginUserConfirmation
  models + validators (block_uninstall enforcement, preserved_tables slug-prefix check,
  safe_uninstall_enabled_requires_confirmation, no _orphan_* refs in readonly SQL)
- routes/plugins.py: uninstall gate (admin-only, confirmation_phrase, exported_at check,
  zip_password); sandboxed pre-hook subprocess (no secrets, read-only DB copy, 600s timeout);
  cascade-DELETE filtering for preserved_host_entities; orphan table rename
  (_orphan_{slug}_{table}); EVONEXUS_ALLOW_FORCE_UNINSTALL=1 escape hatch with audit;
  reinstall SHA256 check against plugin_orphans before orphan recovery
- app.py: plugin_orphans table migration (id, slug, tablename, orphaned_at,
  orphaned_by_user_id, original_plugin_version, original_sha256, original_publisher_url,
  recovered_at, UNIQUE(slug, tablename))
- PluginUninstall.tsx: 3-step wizard (regulatory reason+checkbox → ZIP password → typed
  phrase); force-uninstall orange banner; integrated in PluginDetail.tsx
- docs/plugin-contract.md: full plugin.yaml contract for public_pages + safe_uninstall

Vault §B3 mitigations: S1 block_uninstall gate, S2 admin enforcement, S3 sandboxed hook,
S4 no _orphan_* SQL refs, S5 AES-256 ZIP password, S6 force-uninstall audit trail.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er auto-injection (evolution-foundation#55)

Two related Wave 2.1.x extensions to the plugin contract that close the last
two gaps blocking endpoint-level RBAC for plugin authors (gap inventory in
evonexus-plugin-nutri Step 3 RBAC decision).

Changes
- PluginWritableResource.requires_role: Optional[List[str]] — when set, the
  POST/PUT/DELETE handler returns 403 if current_user.role is not in the list.
  'admin' role always passes (super-user override). Backwards compatible:
  resources without the field accept any authenticated user (legacy default).
  Validator enforces kebab-case role names (^[a-z][a-z0-9-]*$).

- routes.plugins.writable_data: enforces requires_role at the endpoint, with
  a 403 message naming the required roles and the actor's current role.

- routes.plugins.readonly_data: auto-injects :current_user_id and
  :current_user_role as bind params on every readonly query. Plugins can
  reference them directly in SQL for server-enforced scoping without an
  app-layer wrapper. The two parameter names are reserved — client requests
  carrying them in the query string get 400 (no identity spoofing).

Tests
- tests/backend/test_plugins_rbac_and_scoping.py — 13 cases covering Pydantic
  acceptance/rejection, 403/200 paths for writable, scoping/spoofing for readonly,
  backwards compat for resources without requires_role and queries without
  :current_user_id refs.

Compat
- Existing plugins (PM Essentials) continue to work unchanged — the new field
  defaults to None and the auto-injected bind params are silently ignored if
  the SQL doesn't reference them.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ser, raw bundle for clients (evolution-foundation#56)

The portal_page handler at /p/{slug}/{route_prefix}/{token} previously served
the plugin's JS bundle with mimetype application/javascript regardless of the
caller. Browsers navigating to a portal URL saw the JS source instead of a
rendered page. Discovered during evonexus-plugin-nutri Step 5.

Changes
- portal_page: when the Accept header includes text/html and NOT
  application/javascript, render a minimal HTML shell that loads the bundle
  as <script type="module" src="/p/{slug}/public-assets/{file}"> and
  instantiates the plugin's declared custom_element_name. Token reaches the
  element via data-token attribute (no need for the bundle to re-parse
  window.location).
- _serve_html_shell: new helper. Defensive: validates bundle path is inside
  ui/public/ and custom_element_name matches alphanum-dash before emitting.
  Sets X-Content-Type-Options: nosniff + a tight CSP (default-src 'self',
  frame-ancestors 'none', no external scripts).
- Programmatic clients (curl, fetch with Accept: application/javascript)
  keep getting the raw bundle — backwards compatible.
- Bundle is fetched from the existing /p/{slug}/public-assets/{path} route
  (no token), which is safe because the bundle contains no patient data —
  data lives behind the token-gated /data endpoint.

Tests
- tests/backend/test_plugin_public_pages_html_shell.py — 9 cases:
  HTML accept renders shell, JS accept returns bundle, default Accept (*/*)
  returns bundle, invalid token returns 404 even with HTML accept, CSP +
  X-Content-Type-Options present, custom element name appears exactly once,
  XSS-safe (single <script> tag, token only inside data-token), legacy
  programmatic fetches unchanged.

Compat
- Existing public-page consumers using fetch() with Accept: application/json
  or default see no behaviour change. Plugins that already shipped a bundle
  start rendering correctly in browsers without any plugin update.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…volution-foundation#57)

Bumps version to 0.33.0 so the plugin nutri (which requires this version)
can install. Bundles the five plugin-contract PRs (evolution-foundation#52evolution-foundation#56) merged today
into a single release. Plus a UX fix on the install wizard so 409s say why
they conflicted.

The fix
- lib/api.ts buildError now falls back to data.conflicts[0] when the
  standard error/message fields are absent. The plugin preview endpoint
  returns {conflicts: string[], manifest, ...} on 409 — without this fix
  the wizard showed only "409 CONFLICT" with the actual reason hidden.
- PluginInstallModal: conflicts type was Record<string, unknown>, backend
  always returned string[]; the JSON.keys() coercion produced index strings.
  Now typed as string[] and rendered as a list.

Tested
- Frontend tsc --noEmit clean
- Plugin nutri 200 pytest still pass after the 11 `# nosec B603` markers
  added to subprocess.run calls (false positives from regex security scan —
  all calls use list args, no shell=True)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… mode

Non-Anthropic providers in the agent chat previously bypassed openclaude
entirely — a raw /chat/completions fetch with no tool calling, no session
history, and a hard API-key requirement that broke Codex OAuth. The
terminal was separately blocked by the isCodeModel() name heuristic for
agentic models like openrouter/owl-alpha.

- chat-bridge: external providers in code mode now go through the Agent
  SDK with pathToClaudeCodeExecutable pointing at the openclaude binary
  and a clean whitelisted env (mirrors ClaudeBridge). Restores tool
  calling, structured streaming, UI tool approval, and session resume.
  Chat-only models keep the REST path; missing binary falls back with a
  clear log.
- chat-bridge: external providers get full system-prompt replacement for
  agent personas (append is too weak for GPT models), matching the
  terminal behavior.
- provider-config: new per-provider `mode: code|chat` field overrides the
  model-name heuristic; the terminal no longer refuses agentic models
  whose names don't match the code regex.
- providers.example.json: mode field on openrouter/omnirouter + new
  NVIDIA NIM provider entry (OpenAI-compatible endpoint).
- tests: provider-config mode override coverage (node --test).

Verified end-to-end against OpenRouter: text streaming and Read tool
call complete with all UI events (tool_use_start, tool_input_delta,
block_stop, result).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OpenClaude >=0.18 detects integrate.api.nvidia.com and demands the key
in NVIDIA_API_KEY, exiting 1 when only OPENAI_API_KEY is set ("Claude
Code process exited with code 1" in the chat). Derive NVIDIA_API_KEY
from OPENAI_API_KEY in loadProviderConfig so the UI keeps a single key
field, and allowlist the var in both config layers.

Verified: chat session via Agent SDK + openclaude against NVIDIA NIM
(stepfun-ai/step-3.7-flash) completes with result: success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the NVIDIA provider beyond chat/code into image generation, and
fixes three bugs found while dogfooding the multi-provider flow:

- ai-image-creator: NVIDIA NIM provider with FLUX models — flux.2-klein-4b
  (default, best composition, ~2.5s), flux.1-dev (30 steps), flux.1-schnell.
  Aspect-ratio mapping constrained to the dimension set and 1.06MP pixel
  budget the API validates; per-model cfg_scale/steps defaults (schnell
  requires cfg=0, klein requires cfg>=1); JPEG output auto-converted to
  PNG via ImageMagick with ffmpeg fallback (temp files beside the output —
  snap-confined ffmpeg cannot read /tmp). Uses NVIDIA_API_KEY from .env.

- backend/providers: saving provider config with a blank key field no
  longer wipes the stored secret — the UI submits empty password inputs
  when editing other fields (e.g. model), silently erasing the API key
  and breaking every session afterwards with auth errors.

- claude-bridge: terminal agent personas now resolve from WORKSPACE_ROOT
  with cwd fallback, matching chat-bridge (cab8966) — sessions started
  outside the workspace root were silently falling back to a generic
  "You are the X agent" persona.

- Makefile: `make telegram` fails loudly when screen/bun are missing
  instead of printing a false success message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le providers

Enforce a minimum interval between chat completion requests and retry
429/503 responses with exponential backoff, honoring Retry-After.
Configurable via CHAT_MIN_INTERVAL_MS, CHAT_MAX_RETRIES, CHAT_BASE_DELAY_MS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
onclose only cleared the keepalive and never reconnected — a dropped
socket left the terminal/chat dead until the component remounted
(switching tabs). Now both components reconnect with capped exponential
backoff and rejoin immediately on visibilitychange, replaying the
session buffer on a cleared terminal to avoid duplicate output. If the
process died while disconnected, surface it instead of silently
restarting the agent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r fallback

- Pin the CLI conversation to the terminal-server session UUID:
  first start uses --session-id, restarts use --resume when a persisted
  conversation file exists — provider crashes and server restarts no
  longer lose the conversation.
- Per-agent model tiers: agents declare model: opus|sonnet|haiku in
  frontmatter; providers.json maps each tier to a provider model via
  the new model_tiers field.
- Pass --fallback-model from the new fallback_models chain (first entry
  distinct from the primary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… totals

Older skill versions logged image cost entries without
token_usage.total_tokens — the unguarded access in the Image Generation
table threw a TypeError and unmounted the whole page. Normalize the
image-costs payload defensively, same pattern as normalizeCostData.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eator

New 'openai' provider hitting api.openai.com/v1/images/generations
directly, with the 'image2' model keyword (auto-detects provider).
Requires a platform API key (AI_IMG_CREATOR_OPENAI_KEY or
OPENAI_API_KEY) — ChatGPT Plus/Codex OAuth tokens lack the
api.model.images.request scope (verified: 401), so they cannot be used
for image generation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ude 0.18

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e cost estimate

Images API bills text input and image output at different per-token
rates — use the prompt/completion split when available instead of
total_tokens at the input rate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reds-bearing test script

Top-level business folders (finance/, meetings/, social/, etc.) sit
outside the workspace/** rule and were trackable — never commit private
operational data, especially in a public PR. Also ignore local backups
and scripts/ghost_integration_test.py (has hardcoded Ghost API keys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dation

- Heartbeats can now run an in-process Python handler instead of
  spawning a Claude agent: new 'handler' field (max_turns=0), DB
  migration, schema validation, dispatcher sync, runner execution path.
- provider_fallback.py: 429/quota detection + model/provider rotation
  with cooldown tracking (foundation; not yet wired into the runner).

Tests: tests/heartbeats 25/26 pass (the 1 failure is a data-driven seed
assertion against the gitignored local heartbeats.yaml, not a regression).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
step7 now routes through provider_fallback.invoke_with_fallback so a
429/quota error rotates model→provider (NVIDIA chain → Codex → native
claude) instead of failing the run. Disable with
HEARTBEAT_PROVIDER_FALLBACK=0; unavailable engine falls back to the
native claude path (preserved as _step7_invoke_claude_native).

Also in provider_fallback.py:
- fix NameError on 429 (DEFAULT_COOLDOWN → DEFAULT_COOLDOWN_SECONDS)
- align default NVIDIA model chain with validated models; drop OpenRouter
  (stealth models 404 intermittently)
- derive NVIDIA_API_KEY for NVIDIA base URLs (openclaude ≥0.18 needs it)
- parse token usage + cost from the CLI JSON envelope so heartbeat runs
  land real numbers on /costs instead of nulls

Tested: invoke_with_fallback + step7_invoke_claude run end-to-end via
NVIDIA glm-5.1; tests/heartbeats 25/26 (the 1 failure is the pre-existing
data-driven seed assertion on the gitignored local heartbeats.yaml).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bot ran on native claude (Anthropic) and got stuck on an Anthropic
usage-limit prompt — it stopped responding because the bun MCP polling
loop was blocked behind it. Route the channel agent to Codex via
CLAUDE_CODE_USE_OPENAI=1 + OPENAI_MODEL=codexplan (native claude keeps
--channels support; openclaude does not expose that flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sistemabritto and others added 23 commits July 6, 2026 22:29
…object

- OpenClaude v0.22+ bloqueia --dangerously-skip-permissions como root
  → adicionar --allow-dangerously-skip-permissions antes da flag
- node-pty 1.1+ retorna exitCode como {exitCode, signal} em vez de number
  → normalizar para número antes de propagar
- Remove EIO suppression leftovers que nao resolveram a raiz
openclaude v0.22 (e Claude Code) recusam --dangerously-skip-permissions
como root a menos que IS_SANDBOX=1 esteja no env do processo. A flag
--allow-dangerously-skip-permissions do commit anterior não lifta esse
check — ela só habilita o modo bypass como opção.

O entrypoint.sh já exporta IS_SANDBOX=1, mas o whitelist de env limpo
dos bridges descartava a variável, então o CLI nascia sem o marcador
de sandbox e abortava com exit 1.

- claude-bridge: sempre passa --dangerously-skip-permissions em trust
  mode e injeta IS_SANDBOX=1 quando root (claude e openclaude)
- claude-bridge/chat-bridge: IS_SANDBOX adicionado aos whitelists
- auto-accept do PTY cobre o diálogo "2. Yes, I accept" do primeiro
  uso do modo bypass

Verificado com fakeroot + openclaude@0.22.0: sem IS_SANDBOX reproduz o
erro; com IS_SANDBOX=1 o check passa e o CLI segue até a API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mais sem login claude.ai

O caminho LiteLLM-proxy (Anthropic → NVIDIA) nunca passou na verificação
server-side de channels da Anthropic — o claude printava "Channels are
not currently available" e o bot ficava desconectado das mensagens.

Novo comportamento do telegram_swarm_entry.sh (TELEGRAM_MODE=auto):
- login claude.ai no volume → channels direto na Anthropic (como antes)
- sem login → telegram_provider_bot.py no provider ativo (NVIDIA etc.),
  o mesmo runtime do make telegram local, sem gate de channels
- TELEGRAM_MODE=channels|provider força um modo específico
- espera TELEGRAM_BOT_TOKEN em vez de NVIDIA_API_KEY

Smoke test na imagem sha-248a7b2 com o script montado: modo provider
selecionado, channel config seedado, bot sobe e chama getMe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dois terminais abertos ao mesmo tempo para o mesmo agente caíam no mesmo
PTY via find-or-create — cada tecla/comando ecoava e duplicava nos dois
(ex.: /goal enviado duas vezes). Agora sessões com conexão WS ativa são
puladas na reutilização e o segundo terminal ganha sessão própria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Login claude.ai antigo no volume de auth fazia o modo auto escolher
channels — e o canal voltava a morrer se o login/entitlement estiver
inválido. O openclaude não resolve: o gate de channels (feature flag
server-side + OAuth claude.ai) existe nele também, independente do
provider. Provider mode usa o telegram_provider_bot.py, que já suporta
NVIDIA/OmniRouter/OpenRouter (chat completions) e codex_auth (codex CLI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Versão sanitizada da stack VPS para distribuir: domínio e token via
variáveis de ambiente do Portainer, zero credenciais no arquivo,
alias de rede evonexus-dashboard para o EVONEXUS_API_URL funcionar
independente do nome da stack, TELEGRAM_MODE=provider como default
e instruções de onboarding no cabeçalho.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gateway de IA (diegosouzapw/OmniRoute, MIT) rodando dentro do Swarm com
alias omniroute na network_public. O provider omnirouter do EvoNexus
aponta para http://omniroute:20128/v1 (model=auto para fallback entre
237 providers) em vez de depender de gateway externo — elimina o 503
sem fallback visto no bot do Telegram. Dashboard interno por padrão,
com labels Traefik+basic-auth comentadas e aviso de segurança.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Serviço omniroute com alias interno (omniroute:20128) para o provider
omnirouter do EvoNexus, dashboard exposto via Traefik com basic-auth,
segredos movidos para variáveis de ambiente da stack no Portainer
(DASHBOARD_API_TOKEN, OMNIROUTE_BASICAUTH), alias evonexus-dashboard
para o EVONEXUS_API_URL não depender do nome da stack, e
TELEGRAM_MODE=provider persistido.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Basic-auth na frente do dashboard entra em loop de 401: as chamadas
internas (SSE/WS/API) enviam Authorization próprio que atropela o Basic.
O OmniRoute tem auth nativa para reverse proxy: INITIAL_PASSWORD (login),
JWT_SECRET (sessão em cookie), AUTH_COOKIE_SECURE atrás de HTTPS,
OMNIROUTE_TRUST_PROXY para X-Forwarded-*, e STORAGE_ENCRYPTION_KEY
cifrando o SQLite com as keys dos providers. Segredos via env da stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… VPS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… recebe mais a chave NVIDIA

O bot do Telegram e o FallbackEngine resolviam a API key priorizando
NVIDIA_API_KEY/OPENAI_API_KEY do env do processo (carregadas do .env na
inicialização) sobre a chave do próprio provider em config/providers.json.
Resultado: toda chamada ao omnirouter mandava a chave NVIDIA pro OmniRoute
e tomava 401, mesmo com key nova configurada no dashboard.

- telegram_provider_bot: env_vars do provider primeiro; NVIDIA_API_KEY do
  env só para endpoints *.nvidia.com; invoke_cli não vaza [REDACTED] pro CLI
- provider_fallback._get_api_key: mesma precedência (heartbeats/rotinas)
- claude-bridge/chat-bridge/invoke_cli: DISABLE_AUTOUPDATER=1 — o openclaude
  se auto-migrava pro instalador nativo no meio da sessão e morria com exit 1
- .env.example: documenta a ordem de resolução de chaves, onde gerar cada
  key (OpenRouter/OpenAI/Gemini/NVIDIA NIM/OmniRoute) e o gotcha de keys do
  OmniRoute morrerem com o volume

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refeito com foco no que este fork adiciona: OmniRoute embutido na stack
Swarm, seletor de providers, bot do Telegram multi-provider, pipeline de
deploy VPS (GH Actions → Docker Hub próprio → Portainer/Traefik) e
hardening de produção. Guia passo a passo completo de deploy na VPS com
troubleshooting.

Créditos devidos: Evolution Foundation (EvoNexus, base integral), Diego
Souza (OmniRoute), Yeachan Heo (oh-my-claudecode, herdado do upstream) e
OpenClaude. Licença original preservada (Apache 2.0 + condições de marca),
com a camada Sistema Britto declarada como distribuição derivada e
backlinks para sistemabritto.com.br.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gram multi-provider, deploy VPS

Promove a branch feat/chat-openclaude-provider-routing (92 commits) à main
do fork. A main passa a ser a branch canônica da distribuição Omni-Nexus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…base

O docker-image.yml (builds da main) apontava a imagem evo-nexus-runtime pro
./Dockerfile — imagem base sem claude/openclaude/entrypoints do Swarm. A
stack da VPS espera o Dockerfile.swarm, o mesmo que o workflow da branch de
feature já usa.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
O OmniRoute streama SSE por padrão quando o payload não traz "stream" — o
bot fazia json.loads do corpo inteiro e explodia com "Expecting value:
line 1 column 1 (char 0)". Agora pede stream=false explicitamente e, se o
gateway ignorar e streamar mesmo assim, agrega os deltas dos chunks SSE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PROMPT-OMNIROUTE-CONFIG.md — prompt copy-paste para Claude Code (ou agente
similar) auditar e otimizar um OmniRoute remoto via management API + CLI em
modo remoto: validação de access token, keys nomeadas (inferência + manage),
autoRefreshProviderQuota, compressão com auto-trigger, re-teste de conexões
para destravar modelos :free e validação fim-a-fim com headers de telemetria.
Extraído da configuração real aplicada na VPS em 07/07/2026. Linkado no
README na seção do OmniRoute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-learning

Agentes perdiam contexto após /clear — três mudanças fecham o ciclo:

- Dockerfile.swarm.dashboard: mempalace pré-instalado na imagem (o install
  via botão caía na layer do container e sumia a cada redeploy; os dados
  chroma/sources já persistem no volume dashboard/data)
- routes/mempalace.py: primeiro uso seeda fontes padrão — memory/,
  .claude/agent-memory/ e workspace/development/ — pra busca semântica
  cobrir as memórias do workspace e dos agentes sem setup manual
- .claude/rules/memory-recall.md (auto-carregada): protocolo de recall no
  início da sessão (agent-memory → MemPalace search → inbox de tickets) e
  self-learning no final (learnings.md por agente, fatos em memory/,
  trabalho inacabado vira ticket, re-mine do índice)

Testes atualizados pro comportamento de seed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… no check de PID

O worker publica phase="done" e sai, mas o Flask nunca dá wait() no child —
ele vira zumbi e os.kill(pid, 0) segue passando, então a UI ficava presa em
"Mining in progress" até reiniciar o dashboard. Agora o status respeita a
fase terminal (done/error): limpa o arquivo e faz reap best-effort do zumbi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds opencode (github.com/sst/opencode) as a selectable CLI provider
alongside claude/openclaude — a coding-agent CLI with native tool-use,
routable against any OpenAI-compatible endpoint (self-hosted gateway,
OpenRouter, etc.) via Base URL/API Key, same pattern OpenClaude already
uses.

- routes/providers.py: allowlist opencode's binary, report install status
- config/providers.json: opencode provider preset (baseURL/key left
  empty — set from the Providers page)
- opencode.json: minimal template opencode.json registering an
  "opencode" provider block that resolves baseURL/apiKey from env
- Dockerfiles: install opencode-ai CLI (version-pinned, see openclaude
  pin below for why) + sharp (peer dep the Read tool needs for image
  files, otherwise every image read fails)

Also pins @gitlawb/openclaude to a tested version instead of @latest —
floating @latest meant an image rebuild could silently ship a new CLI
release (backoff behavior, error body shape, provider-rotation logic
all changed across recent releases) with nobody testing it first. Bump
deliberately after validating a new version.
opencode has no long-lived interactive process like claude/openclaude —
each message is a fresh `opencode run --format json` call, so the
Terminal spawns it as a headless REPL (claude-bridge.js) instead of a
PTY: buffers keystrokes into lines, runs one `opencode run` per
submitted line, parses its NDJSON event stream (step_start/text/
step_finish), renders markdown as ANSI for the terminal. Same NDJSON
parsing lands in provider_fallback.py (heartbeats/Telegram path) and
chat-bridge.js (Chat UI path via the Agent SDK's fallback chain), each
adapted to that context's existing conventions.

Resilience fixes that apply to every provider, not just opencode:
- Chat's fallback chain now builds the env fresh per attempt instead of
  inheriting the previous (failed) provider's OPENAI_BASE_URL/API_KEY —
  a stale env would silently hijack the next provider's calls.
- An error `result` from the Agent SDK (503/429/"Maximum combo retry
  limit reached" etc.) doesn't raise an exception — it completes
  "normally" with an error payload. Detecting this and advancing the
  fallback chain (instead of surfacing the error) means a provider
  outage degrades to the next configured provider instead of failing
  the whole chat turn.
- 503 response bodies are preserved through the retry path instead of
  discarded, so the real upstream error reaches the logs.
- Internal CLI retry backoff gets cut short and rotates to the next
  provider in seconds instead of waiting out a multi-minute backoff a
  fallback chain exists specifically to avoid.

test/chat-bridge-fallback.test.js covers the retryable/fatal error
classification and the SDK-compatibility filter (opencode can't speak
the Agent SDK protocol, so it's excluded from Chat's fallback chain and
only used via the Terminal's dedicated REPL bridge).
A small dashboard-style panel above both the embedded Terminal and
Chat views: a shifter-style gear indicator (H-gate SVG, advances on
real provider/model changes) plus an LCD readout showing the active
provider/model and a tokens/s estimate, and a 3-light semaphore
(green=idle, yellow=working, red=last turn was unusually large).

- TerminalHudPanel.tsx: the shared panel component.
- AgentTerminal.tsx: renders it for opencode REPL sessions (the only
  session type with per-turn NDJSON to instrument); also gives opencode
  sessions their own fixed input bar below the output area instead of
  sharing xterm's scrollback with the AI's response — typing and reply
  were hard to visually tell apart otherwise. xterm becomes output-only
  for these sessions so the two input paths can't double-process a
  keystroke.
- AgentChat.tsx: same panel wired to the Agent SDK's per-turn `result`
  message (usage + duration) for the fallback-chain / native-Claude
  path.
- Agents.tsx: reflects the same busy/heavy semaphore state on the
  agents list page via a lightweight polling endpoint, so you can see
  which agents are active without opening each one.
…lling after restart

Two correctness bugs found while auditing why routines/heartbeats
looked unhealthy in the dashboard:

1. One routine (uso_modelos_dia) stored success_rate as a 0-1 fraction
   while every other routine (via runner.py) stores it as a 0-100
   percentage — the two dashboard consumers of this field (Routines
   page, /api/overview) assume the 0-100 convention, so a routine with
   a real 83.3% success rate displayed as "0.8333% success" and got
   misclassified as "critical" instead of "healthy". Fixed at the
   source, and both consumers now derive the percentage fresh from raw
   runs/successes counts instead of trusting the stored field — immune
   to any future producer getting the units wrong again.

2. register_interval_jobs() used `schedule.every(N).do(job)`, which
   always starts counting from the moment it's called, not from the
   heartbeat's last real run. Every process restart (redeploy, crash,
   a plugin install triggering reload_config) silently resets every
   interval-based heartbeat's countdown to the full interval — a
   heartbeat that's mid-cycle when the process restarts effectively
   loses that progress and won't fire again until a full fresh interval
   elapses from the restart. In a system that redeploys with any
   regularity, heartbeats configured for a few hours' cadence can drift
   far behind their nominal SLA purely from restart timing, not because
   anything is stuck. Fix: on registration, check each heartbeat's last
   actual run (heartbeat_runs.started_at) and fire an immediate
   catch-up dispatch for anything already overdue by its own history,
   before registering the periodic job. dispatch()'s existing 30s
   debounce makes this safe to call on every reload_config too.
Two related fixes for running the dashboard behind Traefik/nginx with
any OAuth-based integration:

- Flask saw the proxy's internal http scheme, not the public https one,
  so request.host_url built http:// redirect_uris that OAuth providers
  reject. ProxyFix(x_proto=1, x_host=1) honors X-Forwarded-Proto/Host
  from the one hop of proxy in front of it.
- SESSION_COOKIE_SAMESITE="Strict" silently broke every OAuth flow
  mounted on the dashboard: the provider's redirect back to /callback/*
  is a cross-site top-level navigation, so a Strict cookie doesn't ride
  along, the session (and the CSRF state stashed in it) is gone, and
  the callback fails with "Invalid state". Lax still blocks the
  cross-site mutating requests (POST/iframe/subresource) CSRF actually
  uses, while allowing the top-level GET redirect OAuth needs.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, we are unable to review this pull request

The GitHub API does not allow us to fetch diffs exceeding 20000 lines

…eiro

Achado investigando por que TODAS as rotinas cron pareciam paradas há
semanas: _load_routines_from_yaml relança qualquer exceção pro config
principal (não-plugin). Um erro de indentação real em
config/routines.yaml (entradas fora do nível da lista "daily") fazia o
yaml.safe_load levantar ParserError, que subia até main() e matava o
processo do scheduler ANTES dele alcançar o loop de execução — nenhuma
rotina rodava, nem as hardcoded (good-morning, backup, end-of-day),
porque o processo nunca chegava lá. Se o container reinicia no mesmo
arquivo quebrado, trava nesse ponto pra sempre, silenciosamente.

Fix: erro no config principal agora loga alto e segue sem as rotinas
custom (as core, registradas antes dessa chamada, continuam intactas)
em vez de derrubar o processo inteiro — mesmo tratamento que plugins
já tinham.
…mo com opencode ativo

Causa raiz real de "todas as rotinas paradas" (não era o routines.yaml,
esse nunca chegava a ser lido) — confirmado ao vivo pelos logs do
scheduler: preso desde 2026-07-14T18:51 em loop de 30s "waiting for
ANTHROPIC_API_KEY", nunca chegando a executar scheduler.py.

O gate em entrypoint.sh (REQUIRE_ANTHROPIC_KEY=1, setado no stack só
pro serviço scheduler) só verificava a env var ANTHROPIC_API_KEY,
hardcoded — mas esse workspace tem active_provider=opencode desde
2026-07-12, com credencial em config/providers.json (env_vars do
provider, não uma ANTHROPIC_API_KEY top-level). Resultado: o gate
nunca desbloqueava, mesmo com o provider ativo 100% configurado e
funcional (confirmado nesta mesma sessão via chamadas reais ao
OmniRoute).

Fix: _has_usable_provider() aceita ANTHROPIC_API_KEY OU um
active_provider em config/providers.json com credencial real (mesmo
check que ADWs/runner.py::_get_provider_config() já faz em runtime) —
desbloqueia com qualquer provider funcional, não só Anthropic.
…o stack

Segundo bug de boot achado ao vivo na mesma sessão: depois do fix do
gate ANTHROPIC_API_KEY, o scheduler finalmente rodou — mas toda
chamada de API (heartbeats, routines/run) voltava "Authentication
required" mesmo com o token certo capturado via `docker exec printenv
DASHBOARD_API_TOKEN` no container do dashboard.

Causa: .env.example tem `DASHBOARD_API_TOKEN=` (vazio, documentado
como vindo do stack). No primeiro boot isso é copiado pro
$CONFIG_DIR/.env do volume. O entrypoint faz `set -a; . .env; set +a`
DEPOIS que o Docker já injetou o valor real (environment: do stack) —
sourcing um arquivo com essa linha vazia reexporta e apaga
silenciosamente o valor correto. `docker exec ... printenv` mostrava o
valor certo porque é um processo novo, não passa pelo entrypoint —
só o Flask (filho do entrypoint via start-dashboard.sh) via a versão
vazia.

Fix: snapshot de DASHBOARD_API_TOKEN antes do source, restaura se o
.env deixou vazio. Valores não-vazios no .env continuam vencendo
(a UI de Settings ainda funciona) — só bloqueia uma linha vazia
apagando um valor real já injetado pelo Docker.
…em profundidade)

Complementa o guard do entrypoint.sh (commit c985aa3) — esse não corrige
o volume já existente na VPS (só evita novos first-boots caírem na
mesma armadilha). O guard em runtime é quem resolve o problema atual.
…segunda instância

Varri evonexus-vps.stack.yml x .env.example procurando o mesmo padrão
(var setada no stack, vazia no .env.example) que causou o bug do
DASHBOARD_API_TOKEN — achei DASHBOARD_API_USER também (menor
gravidade, tem fallback documentado pro "primeiro admin", mas mesmo
risco de clobber silencioso se o stack algum dia fixar um usuário
específico).

Generalizei o fix de commit c985aa3 (que só cobria DASHBOARD_API_TOKEN
nominalmente) pra cobrir a classe inteira: snapshot de todo env var já
exportado antes do source do .env, restaura qualquer um que tenha
ficado vazio depois — sem precisar listar variável por variável.
Testado isoladamente (bash -c) antes de subir: var previamente setada
some no .env -> restaura; var nova no .env -> ganha; var não tocada
-> intacta.
@sistemabritto

Copy link
Copy Markdown
Author

Adicionei mais 5 commits com bugs reais que achei enquanto validava tudo isso ao vivo numa instância de produção própria:

  • fix(scheduler): erro no routines.yaml não derruba mais o processo inteiro_load_routines_from_yaml relançava qualquer exceção do config principal (não-plugin). Um YAML mal formado em config/routines.yaml (indentação errada) derrubava scheduler.py inteiro antes dele alcançar o loop de execução — nenhuma rotina rodava, nem as hardcoded, e se o container reiniciasse, travava no mesmo ponto pra sempre, silenciosamente. Agora loga alto e segue sem as rotinas custom em vez de matar o processo.
  • fix(entrypoint): gate de boot travava esperando ANTHROPIC_API_KEY mesmo com outro provider ativo — o gate REQUIRE_ANTHROPIC_KEY só verificava a env var ANTHROPIC_API_KEY, hardcoded. Um workspace com active_provider diferente de Anthropic (credencial vive em config/providers.json, não numa env var top-level) esperava para sempre, mesmo com o provider ativo 100% funcional. Agora aceita qualquer provider configurado com credencial real.
  • fix(entrypoint): .env com token vazio apagava o valor real injetado pelo stack.env.example documenta DASHBOARD_API_TOKEN como vindo do stack (linha vazia de propósito), mas o primeiro boot copia isso pro volume, e o source .env do entrypoint reexportava (e apagava) o valor real do Docker com a string vazia. Toda chamada de API Bearer-token quebrava com "Authentication required" — não por token errado, mas porque o valor já tinha sido apagado antes do Flask ler. Generalizei o fix pra cobrir qualquer variável nessa mesma situação, não só essa uma.

Todos os 3 foram reproduzidos e confirmados ao vivo (não são teóricos) antes do fix, e revalidados depois via execução real (rotinas disparando, heartbeats fazendo catch-up, tickets mudando de status).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants