diff --git a/.gitignore b/.gitignore index 1116a8de..a375117c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ agents_lineup.svg docs/ goals.py pr-reviews/ +AGENTS.md agentchattr-*.zip diff --git a/README.md b/README.md index 68cdfb5e..49212b64 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ A local chat server for real-time coordination between AI coding agents and huma Agents and humans talk in a shared chat room with multiple channels — when anyone @mentions an agent, the server auto-injects a prompt into that agent's terminal, the agent reads the conversation and responds, and the loop continues hands-free. No copy-pasting between ugly terminals. No manual prompting. +## What's New +- **Agent Identity**: Agents are now informed of their name (e.g., "You are claude-2") when triggered, improving coordination in multi-instance sessions. +- **Custom Agent Parameters**: + - **CLI Agents**: Support for custom `args` and `env` per agent in `config.toml`. + - **API Agents**: Support for `custom_params` to pass extra parameters to model endpoints. + *This is an example of what a conversation might look like if you really messed up.* ![screenshot](screenshot.png) @@ -64,7 +70,7 @@ On first launch, the script auto-creates a virtual environment, installs Python
All agent launchers (click to expand) -- `sh start.sh` — starts the chat server only +- `sh start.sh` — starts the chat server only (and cleans up existing tmux sessions) - `sh start_claude.sh` — starts Claude - `sh start_codex.sh` — starts Codex - `sh start_gemini.sh` — starts Gemini diff --git a/config.toml b/config.toml index ea58875e..e1676a97 100644 --- a/config.toml +++ b/config.toml @@ -2,6 +2,7 @@ port = 8300 host = "127.0.0.1" data_dir = "./data" +project_root = "/home/hamdan/Desktop/Password_Library/" # Global root directory for all agents. If set, agent 'cwd' is relative to this. # Add agents here. Each gets a status pill, @mention routing, and color. # "cwd" is the working directory for the agent's terminal session. @@ -47,6 +48,16 @@ cwd = ".." color = "#f7f677" label = "Kilo" +[agents.opencode] +command = "opencode" +args = ["-m google/gemma-4-26b-a4b-it"] +cwd = ".." +color = "#808080" +label = "OpenCode" +mcp_inject = "opencode_json" +mcp_settings_path = "opencode.json" +mcp_transport = "http" + [agents.codebuddy] command = "codebuddy" cwd = ".." diff --git a/macos-linux/start.sh b/macos-linux/start.sh index 5ed4892e..9bc674e0 100755 --- a/macos-linux/start.sh +++ b/macos-linux/start.sh @@ -33,6 +33,9 @@ ensure_venv() { ensure_venv +# Kill any existing tmux sessions to ensure a clean start +tmux kill-server 2>/dev/null + .venv/bin/python run.py code=$? echo "" diff --git a/macos-linux/start_opencode.sh b/macos-linux/start_opencode.sh new file mode 100644 index 00000000..3ac64580 --- /dev/null +++ b/macos-linux/start_opencode.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env sh +# agentchattr - starts server (if not running) + OpenCode wrapper +cd "$(dirname "$0")/.." + +PYTHON_BIN="" +if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" +else + echo "Python 3 is required but was not found on PATH." + exit 1 +fi + +ensure_venv() { + if [ -d ".venv" ] && [ ! -x ".venv/bin/python" ]; then + echo "Recreating .venv for this platform..." + rm -rf .venv + fi + + if [ ! -x ".venv/bin/python" ]; then + echo "Creating virtual environment..." + "$PYTHON_BIN" -m venv .venv || { + echo "Error: failed to create .venv with $PYTHON_BIN." + exit 1 + } + .venv/bin/python -m pip install -q -r requirements.txt || { + echo "Error: failed to install Python dependencies." + exit 1 + } + fi +} + +is_server_running() { + lsof -i :8300 -sTCP:LISTEN >/dev/null 2>&1 || \ + ss -tlnp 2>/dev/null | grep -q ':8300 ' +} + +ensure_venv + +if ! is_server_running; then + if [ "$(uname -s)" = "Darwin" ]; then + osascript -e "tell app \"Terminal\" to do script \"cd '$(pwd)' && .venv/bin/python run.py\"" > /dev/null 2>&1 + else + if command -v gnome-terminal >/dev/null 2>&1; then + gnome-terminal -- sh -c "cd '$(pwd)' && .venv/bin/python run.py; printf 'Press Enter to close... '; read _" + elif command -v xterm >/dev/null 2>&1; then + xterm -e sh -c "cd '$(pwd)' && .venv/bin/python run.py" & + else + .venv/bin/python run.py > data/server.log 2>&1 & + fi + fi + + i=0 + while [ "$i" -lt 30 ]; do + if is_server_running; then + break + fi + sleep 0.5 + i=$((i + 1)) + done +fi + +.venv/bin/python wrapper.py opencode diff --git a/macos-linux/start_opencode_yolo.sh b/macos-linux/start_opencode_yolo.sh new file mode 100644 index 00000000..f90be529 --- /dev/null +++ b/macos-linux/start_opencode_yolo.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env sh +# agentchattr - starts server (if not running) + OpenCode wrapper (yolo mode) +cd "$(dirname "$0")/.." + +PYTHON_BIN="" +if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" +else + echo "Python 3 is required but was not found on PATH." + exit 1 +fi + +ensure_venv() { + if [ -d ".venv" ] && [ ! -x ".venv/bin/python" ]; then + echo "Recreating .venv for this platform..." + rm -rf .venv + fi + + if [ ! -x ".venv/bin/python" ]; then + echo "Creating virtual environment..." + "$PYTHON_BIN" -m venv .venv || { + echo "Error: failed to create .venv with $PYTHON_BIN." + exit 1 + } + .venv/bin/python -m pip install -q -r requirements.txt || { + echo "Error: failed to install Python dependencies." + exit 1 + } + fi +} + +is_server_running() { + lsof -i :8300 -sTCP:LISTEN >/dev/null 2>&1 || \ + ss -tlnp 2>/dev/null | grep -q ':8300 ' +} + +ensure_venv + +if ! is_server_running; then + if [ "$(uname -s)" = "Darwin" ]; then + osascript -e "tell app \"Terminal\" to do script \"cd '$(pwd)' && .venv/bin/python run.py\"" > /dev/null 2>&1 + else + if command -v gnome-terminal >/dev/null 2>&1; then + gnome-terminal -- sh -c "cd '$(pwd)' && .venv/bin/python run.py; printf 'Press Enter to close... '; read _" + elif command -v xterm >/dev/null 2>&1; then + xterm -e sh -c "cd '$(pwd)' && .venv/bin/python run.py" & + else + .venv/bin/python run.py > data/server.log 2>&1 & + fi + fi + + i=0 + while [ "$i" -lt 30 ]; do + if is_server_running; then + break + fi + sleep 0.5 + i=$((i + 1)) + done +fi + +.venv/bin/python wrapper.py opencode -- --dangerously-skip-permissions diff --git a/windows/start_opencode.bat b/windows/start_opencode.bat new file mode 100644 index 00000000..75988019 --- /dev/null +++ b/windows/start_opencode.bat @@ -0,0 +1,40 @@ +@echo off +REM agentchattr — starts server (if not running) + OpenCode wrapper +cd /d "%~dp0.." + +REM Auto-create venv and install deps on first run +if not exist ".venv" ( + python -m venv .venv + .venv\Scripts\pip install -q -r requirements.txt >nul 2>nul +) +call .venv\Scripts\activate.bat + +REM Pre-flight: check that opencode CLI is installed +where opencode >nul 2>&1 +if %errorlevel% neq 0 ( + echo. + echo Error: "opencode" was not found on PATH. + echo Install it first, then try again. + echo. + pause + exit /b 1 +) + +REM Start server if not already running, then wait for it +netstat -ano | findstr :8300 | findstr LISTENING >nul 2>&1 +if %errorlevel% neq 0 ( + start "agentchattr server" cmd /c "python run.py" +) +:wait_server +netstat -ano | findstr :8300 | findstr LISTENING >nul 2>&1 +if %errorlevel% neq 0 ( + timeout /t 1 /nobreak >nul + goto :wait_server +) + +python wrapper.py opencode +if %errorlevel% neq 0 ( + echo. + echo Agent exited unexpectedly. Check the output above. + pause +) diff --git a/windows/start_opencode_yolo.bat b/windows/start_opencode_yolo.bat new file mode 100644 index 00000000..df83ca0a --- /dev/null +++ b/windows/start_opencode_yolo.bat @@ -0,0 +1,40 @@ +@echo off +REM agentchattr — starts server (if not running) + OpenCode wrapper (yolo mode) +cd /d "%~dp0.." + +REM Auto-create venv and install deps on first run +if not exist ".venv" ( + python -m venv .venv + .venv\Scripts\pip install -q -r requirements.txt >nul 2>nul +) +call .venv\Scripts\activate.bat + +REM Pre-flight: check that opencode CLI is installed +where opencode >nul 2>&1 +if %errorlevel% neq 0 ( + echo. + echo Error: "opencode" was not found on PATH. + echo Install it first, then try again. + echo. + pause + exit /b 1 +) + +REM Start server if not already running, then wait for it +netstat -ano | findstr :8300 | findstr LISTENING >nul 2>&1 +if %errorlevel% neq 0 ( + start "agentchattr server" cmd /c "python run.py" +) +:wait_server +netstat -ano | findstr :8300 | findstr LISTENING >nul 2>&1 +if %errorlevel% neq 0 ( + timeout /t 1 /nobreak >nul + goto :wait_server +) + +python wrapper.py opencode -- --dangerously-skip-permissions +if %errorlevel% neq 0 ( + echo. + echo Agent exited unexpectedly. Check the output above. + pause +) diff --git a/wrapper.py b/wrapper.py index c7fde420..5fd05171 100644 --- a/wrapper.py +++ b/wrapper.py @@ -31,9 +31,33 @@ SERVER_NAME = "agentchattr" -# --------------------------------------------------------------------------- -# Per-instance provider config -# --------------------------------------------------------------------------- +def _write_opencode_mcp_settings(config_file: Path, url: str, + *, token: str = "") -> Path: + """Write/merge an OpenCode-specific config file with 'mcp' key. + + OpenCode expects: + - "mcp" key instead of "mcpServers" + - "type": "remote" for remote servers + - "enabled": true + """ + config_file.parent.mkdir(parents=True, exist_ok=True) + existing: dict = {} + if config_file.exists(): + try: + existing = json.loads(config_file.read_text("utf-8")) + except Exception: + pass + + mcp_servers = existing.get("mcp", {}) + entry: dict = {"type": "remote", "url": url, "enabled": True, "oauth": False} + if token: + entry["headers"] = {"Authorization": f"Bearer {token}"} + + mcp_servers[SERVER_NAME] = entry + existing["mcp"] = mcp_servers + + config_file.write_text(json.dumps(existing, indent=2) + "\n", "utf-8") + return config_file def _write_json_mcp_settings(config_file: Path, url: str, transport: str = "http", *, token: str = "", http_key: str = "httpUrl") -> Path: @@ -160,7 +184,7 @@ def _write_claude_mcp_config( }, } -_VALID_INJECT_MODES = {"settings_file", "env", "flag", "proxy_flag", "env_content"} +_VALID_INJECT_MODES = {"settings_file", "env", "flag", "proxy_flag", "env_content", "opencode_json"} def _resolve_mcp_inject(agent: str, agent_cfg: dict) -> dict: @@ -290,12 +314,21 @@ def _apply_mcp_inject( payload = {"mcp": {SERVER_NAME: entry}} inject_env[env_var] = json.dumps(payload) - elif mode == "proxy_flag": - # Pass the proxy URL as CLI flags (e.g. codex -c ...) - template = inject_cfg.get("mcp_proxy_flag_template", - '-c mcp_servers.{server}.url="{url}"') - expanded = template.format(server=SERVER_NAME, url=proxy_url or "") - launch_args = expanded.split() + elif mode == "opencode_json": + # OpenCode specific config format (mcp key, type=remote, enabled=true) + raw_path = inject_cfg.get("mcp_settings_path", "") + if not raw_path: + raise ValueError(f"mcp_inject = 'opencode_json' requires mcp_settings_path") + target = Path(raw_path).expanduser() + if not target.is_absolute(): + base = Path(project_dir) if project_dir else Path.cwd() + target = base / target + settings_path = _write_opencode_mcp_settings(target, server_url, token=token) + + env_var = inject_cfg.get("mcp_env_var") + if env_var: + inject_env[env_var] = str(settings_path) + return launch_args, inject_env, settings_path @@ -348,7 +381,7 @@ def _build_provider_launch( project_dir: Path | None = None, ) -> tuple[list[str], dict[str, str], dict[str, str], Path | None]: """Return provider-specific launch args/env/inject_env/settings_path. - + inject_env: env vars that must propagate INTO the agent process. On Mac/Linux these are prefixed onto the tmux command via ``env VAR=val`` because subprocess.run(env=...) only affects the tmux client binary. @@ -360,12 +393,22 @@ def _build_provider_launch( token=token, mcp_cfg=mcp_cfg, project_dir=project_dir, ) - launch_args = [*mcp_args, *extra_args] - launch_env = dict(env) + # Merge config-defined args and env + config_args = agent_cfg.get("args", []) + if not isinstance(config_args, list): + config_args = [str(config_args)] + + config_env = agent_cfg.get("env", {}) + if not isinstance(config_env, dict): + config_env = {} + + launch_args = [*mcp_args, *config_args, *extra_args] + launch_env = {**env, **config_env} return launch_args, launch_env, inject_env, settings_path + def _register_instance(server_port: int, base: str, label: str | None = None) -> dict: import urllib.request @@ -456,7 +499,8 @@ def _queue_watcher(get_identity_fn, inject_fn, *, is_multi_instance: bool = Fals refresh_interval: int = 10): """Poll queue file and inject an MCP read task when triggered.""" first_mention = True - last_rules_epoch = 0 # 0 = unknown/cold start — will inject on first trigger + last_rules_epoch = 0 + last_injected_name = None trigger_count = 0 while True: try: @@ -513,6 +557,12 @@ def _queue_watcher(get_identity_fn, inject_fn, *, is_multi_instance: bool = Fals # Use current identity (may have changed via rename) current_name, _ = get_identity_fn() + + # Inform agent of its identity if it's the first time or name has changed + if last_injected_name != current_name: + prompt = f"You are {current_name}. {prompt}" + last_injected_name = current_name + # Append role if set — check both current name and base name role = _fetch_role(server_port, current_name) if not role and current_name != agent_name: @@ -587,7 +637,24 @@ def main(): agent = args.agent agent_cfg = config.get("agents", {}).get(agent, {}) - cwd = agent_cfg.get("cwd", ".") + + # Resolve project root (global) vs agent cwd (specific) + global_root = config.get("server", {}).get("project_root") + agent_cwd = agent_cfg.get("cwd", ".") + + if global_root: + # Resolve agent_cwd relative to global_root. + # We ignore ".." as it's usually a legacy value relative to the wrapper's ROOT. + project_dir = Path(global_root).expanduser().resolve() + if Path(agent_cwd).is_absolute(): + project_dir = Path(agent_cwd).resolve() + elif agent_cwd not in (".", ".."): + project_dir = (project_dir / agent_cwd).resolve() + else: + # Maintain existing behavior: resolve agent_cwd relative to ROOT + project_dir = (ROOT / agent_cwd).resolve() + + cwd = str(project_dir) command = agent_cfg.get("command", agent) data_dir = ROOT / config.get("server", {}).get("data_dir", "./data") data_dir.mkdir(parents=True, exist_ok=True) @@ -665,7 +732,7 @@ def _rewrite_mcp_config(instance_name: str, new_token: str): _apply_mcp_inject( inject_cfg, instance_name, data_dir, proxy_url, token=new_token, mcp_cfg=mcp_cfg, - project_dir=(ROOT / cwd).resolve(), + project_dir=project_dir, ) except Exception: pass @@ -739,6 +806,7 @@ def set_runtime_identity(new_name: str | None = None, new_token: str | None = No elif proxy_url: print(f" Local MCP proxy: {proxy_url}") print(f" @{assigned_name} mentions auto-inject MCP reads") + # Start the agent in the resolved directory print(f" Starting {command} in {cwd}...\n") def _heartbeat(): diff --git a/wrapper_api.py b/wrapper_api.py index 824a88ef..75bb4d10 100644 --- a/wrapper_api.py +++ b/wrapper_api.py @@ -93,6 +93,11 @@ def main(): temperature = 0.01 if temperature > 2.0: temperature = 2.0 + + custom_params = agent_cfg.get("custom_params", {}) + if not isinstance(custom_params, dict): + custom_params = {} + context_messages = int(agent_cfg.get("context_messages", 20)) system_prompt = agent_cfg.get("system_prompt", f"You are {agent_cfg.get('label', agent)}, a helpful AI assistant participating " @@ -227,7 +232,7 @@ def send_message(text, channel="general"): # Call OpenAI-compatible chat completions API def call_model(messages): url = f"{base_url}/chat/completions" - payload = {"messages": messages} + payload = {**custom_params, "messages": messages} if model: payload["model"] = model if temperature is not None: