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

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

View File

@@ -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"

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

View File

@@ -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,