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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user