Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,14 @@ site/lib/
mempalace.yaml
entities.json
.claude/worktrees/
.codex
.codex/
logs/
community/
courses/
daily-logs/
finance/
meetings/
projects/
social/
strategy/
28 changes: 26 additions & 2 deletions ADWs/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import sys
import json
import shutil
from datetime import datetime
from pathlib import Path

Expand Down Expand Up @@ -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.

Expand All @@ -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,
Expand All @@ -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",
Expand Down
56 changes: 45 additions & 11 deletions dashboard/backend/routes/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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 '<command> --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:
Expand Down Expand Up @@ -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",
Expand All @@ -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({
Expand Down
38 changes: 36 additions & 2 deletions dashboard/frontend/src/pages/AgentDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default function AgentDetail() {
const [activeChatSessionId, setActiveChatSessionId] = useState<string | null>(null)
const [chatConnectError, setChatConnectError] = useState<string | null>(null)
const [chatConnecting, setChatConnecting] = useState(false)
const [activeProviderCli, setActiveProviderCli] = useState<string>('claude')

// Notification badge state — pending approvals per session
const approvalCountsRef = useRef<Map<string, number>>(new Map())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -523,12 +545,18 @@ export default function AgentDetail() {
{/* View mode toggle */}
<div className="flex items-center border-r border-[#21262d] h-full">
<button
onClick={() => { setViewMode('chat'); localStorage.setItem('evo:agent-view-mode', 'chat') }}
onClick={() => {
if (!chatSupported) return
setViewMode('chat')
localStorage.setItem('evo:agent-view-mode', 'chat')
}}
disabled={!chatSupported}
className={`flex items-center gap-1.5 px-3 h-full text-[11px] transition-colors ${
viewMode === 'chat'
? 'text-[#e6edf3] bg-[#0C111D]'
: 'text-[#667085] hover:text-[#e6edf3] hover:bg-[#161b22]'
}`}
} ${!chatSupported ? 'opacity-40 cursor-not-allowed' : ''}`}
title={chatSupported ? 'Chat' : 'Chat de agentes disponível apenas com Claude nativo no estado atual'}
>
<MessageSquare size={12} style={{ color: viewMode === 'chat' ? agentColor : undefined }} />
Chat
Expand Down Expand Up @@ -584,6 +612,12 @@ export default function AgentDetail() {
</button>
</div>
)}

{!chatSupported && (
<div className="ml-auto px-3 text-[10px] uppercase tracking-[0.12em] text-[#667085]">
Chat indisponível com provider OAuth
</div>
)}
</div>

{/* Content */}
Expand Down
36 changes: 33 additions & 3 deletions dashboard/terminal-server/src/claude-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}`);
Expand All @@ -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',
];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -417,4 +447,4 @@ class ClaudeBridge {

}

module.exports = ClaudeBridge;
module.exports = ClaudeBridge;
26 changes: 9 additions & 17 deletions start-services.sh
Original file line number Diff line number Diff line change
@@ -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 &