fix(zellij): robust binary resolution and two-step Enter injection

Two reliability fixes for response injection:

1. **Zellij binary resolution** (context.py, state.py, control.py)
   
   When AMC is started via macOS launchctl, PATH is minimal and may not
   include Homebrew's bin directory. The new `_resolve_zellij_bin()`
   function tries `shutil.which("zellij")` first, then falls back to
   common installation paths:
   - /opt/homebrew/bin/zellij (Apple Silicon Homebrew)
   - /usr/local/bin/zellij (Intel Homebrew)
   - /usr/bin/zellij
   
   All subprocess calls now use ZELLIJ_BIN instead of hardcoded "zellij".

2. **Two-step Enter injection** (control.py)
   
   Previously, text and Enter were sent together, causing race conditions
   where Claude Code would receive only the Enter key (blank submit).
   Now uses `_inject_text_then_enter()`:
   - Send text (without Enter)
   - Wait for configurable delay (default 200ms)
   - Send Enter separately
   
   Delay is configurable via AMC_SUBMIT_ENTER_DELAY_MS env var (0-2000ms).

3. **Documentation updates** (README.md)
   
   - Update file table: dashboard-preact.html → dashboard/
   - Clarify plugin is required (not optional) for pane-targeted injection
   - Document AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK env var
   - Note about Zellij resolution for launchctl compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-25 15:01:47 -05:00
parent da08d7a588
commit be2dd6a4fb
4 changed files with 80 additions and 18 deletions

View File

@@ -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)}