diff --git a/README.md b/README.md index 1ec0ca7..48d5285 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ AMC requires Claude Code hooks to report session state. Add this to your `~/.cla | `bin/amc` | Launcher script — start/stop/status commands | | `bin/amc-server` | Python HTTP server serving the API and dashboard | | `bin/amc-hook` | Hook script called by Claude Code to write session state | -| `dashboard-preact.html` | Single-file Preact dashboard | +| `dashboard/` | Modular Preact dashboard (index.html, components/, lib/, utils/) | ### Data Storage @@ -134,8 +134,10 @@ The `/api/respond/{id}` endpoint injects text into a session's Zellij pane. Requ - `optionCount` — Number of options in the current question (used for freeform) Response injection works via: -1. **Zellij plugin** (`~/.config/zellij/plugins/zellij-send-keys.wasm`) — Preferred, no focus change -2. **write-chars fallback** — Uses `zellij action write-chars`, changes focus +1. **Zellij plugin** (`~/.config/zellij/plugins/zellij-send-keys.wasm`) — Required for pane-targeted sends and Enter submission +2. **Optional unsafe fallback** (`AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK=1`) — Uses focused-pane `write-chars` only when explicitly enabled + +AMC resolves the Zellij binary from PATH plus common Homebrew locations (`/opt/homebrew/bin/zellij`, `/usr/local/bin/zellij`) so response injection still works when started via `launchctl`. ## Session Statuses @@ -159,9 +161,17 @@ Response injection works via: - Zellij (for response injection) - Claude Code with hooks support -## Optional: Zellij Plugin +## Testing -For seamless response injection without focus changes, install the `zellij-send-keys` plugin: +Run the server test suite: + +```bash +python3 -m unittest discover -s tests -v +``` + +## Zellij Plugin + +For pane-targeted response injection (including reliable Enter submission), install the `zellij-send-keys` plugin: ```bash # Build and install the plugin diff --git a/amc_server/context.py b/amc_server/context.py index 78daed6..aaac728 100644 --- a/amc_server/context.py +++ b/amc_server/context.py @@ -1,3 +1,4 @@ +import shutil from pathlib import Path import threading @@ -10,6 +11,27 @@ CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions" # Plugin path for zellij-send-keys ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm" + +def _resolve_zellij_bin(): + """Resolve zellij binary even when PATH is minimal (eg launchctl).""" + from_path = shutil.which("zellij") + if from_path: + return from_path + + common_paths = ( + "/opt/homebrew/bin/zellij", # Apple Silicon Homebrew + "/usr/local/bin/zellij", # Intel Homebrew + "/usr/bin/zellij", + ) + for candidate in common_paths: + p = Path(candidate) + if p.exists() and p.is_file(): + return str(p) + return "zellij" # Fallback for explicit error reporting by subprocess + + +ZELLIJ_BIN = _resolve_zellij_bin() + # Runtime data lives in XDG data dir DATA_DIR = Path.home() / ".local" / "share" / "amc" SESSIONS_DIR = DATA_DIR / "sessions" diff --git a/amc_server/mixins/control.py b/amc_server/mixins/control.py index aab9439..d400f0b 100644 --- a/amc_server/mixins/control.py +++ b/amc_server/mixins/control.py @@ -3,11 +3,14 @@ import os import subprocess import time -from amc_server.context import SESSIONS_DIR, ZELLIJ_PLUGIN, _DISMISSED_MAX, _dismissed_codex_ids +from amc_server.context import SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_PLUGIN, _DISMISSED_MAX, _dismissed_codex_ids from amc_server.logging_utils import LOGGER class SessionControlMixin: + _FREEFORM_MODE_SWITCH_DELAY_SEC = 0.30 + _DEFAULT_SUBMIT_ENTER_DELAY_SEC = 0.20 + def _dismiss_session(self, session_id): """Delete a session file (manual dismiss from dashboard).""" safe_id = os.path.basename(session_id) @@ -87,16 +90,40 @@ class SessionControlMixin: self._send_json(500, {"ok": False, "error": f"Failed to activate freeform mode: {result['error']}"}) return # Delay for Claude Code to switch to text input mode - time.sleep(0.3) + time.sleep(self._FREEFORM_MODE_SWITCH_DELAY_SEC) - # Inject the actual text (with Enter) - result = self._inject_to_pane(zellij_session, pane_id, text, send_enter=True) + # Inject the actual text first, then submit with delayed Enter. + result = self._inject_text_then_enter(zellij_session, pane_id, text) if result["ok"]: self._send_json(200, {"ok": True}) else: self._send_json(500, {"ok": False, "error": result["error"]}) + def _inject_text_then_enter(self, zellij_session, pane_id, text): + """Send text and trigger Enter in two steps to avoid newline-only races.""" + result = self._inject_to_pane(zellij_session, pane_id, text, send_enter=False) + if not result["ok"]: + return result + + time.sleep(self._get_submit_enter_delay_sec()) + # Send Enter as its own action after the text has landed. + return self._inject_to_pane(zellij_session, pane_id, "", send_enter=True) + + def _get_submit_enter_delay_sec(self): + raw = os.environ.get("AMC_SUBMIT_ENTER_DELAY_MS", "").strip() + if not raw: + return self._DEFAULT_SUBMIT_ENTER_DELAY_SEC + try: + ms = float(raw) + if ms < 0: + return 0.0 + if ms > 2000: + ms = 2000 + return ms / 1000.0 + except ValueError: + return self._DEFAULT_SUBMIT_ENTER_DELAY_SEC + def _parse_pane_id(self, zellij_pane): """Extract numeric pane ID from various formats.""" if not zellij_pane: @@ -127,7 +154,7 @@ class SessionControlMixin: # Pane-accurate routing requires the plugin. if ZELLIJ_PLUGIN.exists(): - result = self._try_plugin_inject(env, pane_id, text, send_enter) + result = self._try_plugin_inject(env, zellij_session, pane_id, text, send_enter) if result["ok"]: return result LOGGER.warning( @@ -142,7 +169,7 @@ class SessionControlMixin: # `write-chars` targets whichever pane is focused, which is unsafe for AMC. if self._allow_unsafe_write_chars_fallback(): LOGGER.warning("Using unsafe write-chars fallback (focused pane only)") - return self._try_write_chars_inject(env, text, send_enter) + return self._try_write_chars_inject(env, zellij_session, text, send_enter) return { "ok": False, @@ -156,7 +183,7 @@ class SessionControlMixin: value = os.environ.get("AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK", "").strip().lower() return value in ("1", "true", "yes", "on") - def _try_plugin_inject(self, env, pane_id, text, send_enter=True): + def _try_plugin_inject(self, env, zellij_session, pane_id, text, send_enter=True): """Try injecting via zellij-send-keys plugin (no focus change).""" payload = json.dumps({ "pane_id": pane_id, @@ -167,7 +194,9 @@ class SessionControlMixin: try: result = subprocess.run( [ - "zellij", + ZELLIJ_BIN, + "--session", + zellij_session, "action", "pipe", "--plugin", @@ -194,12 +223,12 @@ class SessionControlMixin: except Exception as e: return {"ok": False, "error": str(e)} - def _try_write_chars_inject(self, env, text, send_enter=True): + def _try_write_chars_inject(self, env, zellij_session, text, send_enter=True): """Inject via write-chars (UNSAFE: writes to focused pane).""" try: # Write the text result = subprocess.run( - ["zellij", "action", "write-chars", text], + [ZELLIJ_BIN, "--session", zellij_session, "action", "write-chars", text], env=env, capture_output=True, text=True, @@ -212,7 +241,7 @@ class SessionControlMixin: # Send Enter if requested if send_enter: result = subprocess.run( - ["zellij", "action", "write", "13"], # 13 = Enter + [ZELLIJ_BIN, "--session", zellij_session, "action", "write", "13"], # 13 = Enter env=env, capture_output=True, text=True, @@ -227,6 +256,6 @@ class SessionControlMixin: except subprocess.TimeoutExpired: return {"ok": False, "error": "write-chars timed out"} except FileNotFoundError: - return {"ok": False, "error": "zellij not found in PATH"} + return {"ok": False, "error": f"zellij not found (resolved binary: {ZELLIJ_BIN})"} except Exception as e: return {"ok": False, "error": str(e)} diff --git a/amc_server/mixins/state.py b/amc_server/mixins/state.py index 54af6e4..1fe74fe 100644 --- a/amc_server/mixins/state.py +++ b/amc_server/mixins/state.py @@ -9,6 +9,7 @@ from amc_server.context import ( SESSIONS_DIR, STALE_EVENT_AGE, STALE_STARTING_AGE, + ZELLIJ_BIN, _state_lock, _zellij_cache, ) @@ -143,7 +144,7 @@ class StateMixin: try: result = subprocess.run( - ["zellij", "list-sessions", "--no-formatting"], + [ZELLIJ_BIN, "list-sessions", "--no-formatting"], capture_output=True, text=True, timeout=2,