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:
20
README.md
20
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user