m's dogfood 2026-05-08 20:35: "the paliadin hook does not always work — it does not confirm the claude / terminal command... like lacking an enter key. Or too fast." Race between two consecutive tmux send-keys calls: the first writes the prompt literally with `-l`; the second sends an Enter key event. Claude Code's TUI debounces keyboard input. When the Enter lands while the paste is still being absorbed, the carriage-return collapses into the input buffer as a literal newline character instead of registering as a "submit" gesture — the prompt sits typed but unsubmitted, and the backend's pollForResponse then times out on the missing response file. Fix: sleep 200ms between the literal paste and the Enter. Below the human-perceptible threshold but well above tmux's pty flush window and the TUI's input-debounce window. Applied to both code paths: - scripts/paliadin-shim:send_to_pane (the SSH/RPC production path) - internal/services/paliadin.go:LocalPaliadinService.sendToPane (the laptop-only direct-tmux path) The Go-side variant uses a context-aware sleep so request cancellation still propagates correctly. Production shim copy at /home/m/.local/bin/paliadin-shim refreshed locally on mRiver so the next turn picks up the fix without waiting for redeploy. (The Dokploy container does not run paliadin — gate on PaliadinOwnerEmail is owner-only and prod has no claude+tmux anyway — so no deploy step required for the shim path.)
231 lines
8.3 KiB
Bash
Executable File
231 lines
8.3 KiB
Bash
Executable File
#!/bin/bash
|
||
# paliadin-shim — server-side RPC for paliad's remote-tmux turns.
|
||
#
|
||
# Invoked via mRiver's ~/.ssh/authorized_keys command= restriction. The
|
||
# client's requested command is exposed in $SSH_ORIGINAL_COMMAND; this
|
||
# script parses it and dispatches to a fixed verb set.
|
||
#
|
||
# Design: docs/design-paliadin-tailscale-ssh-2026-05-07.md §5.4 +
|
||
# t-paliad-155 (per-user session keying + skill-based persona).
|
||
#
|
||
# Verbs (every verb takes the tmux session name as the first positional
|
||
# argument; per-user sessions are created on demand):
|
||
#
|
||
# health <session> -> "ok" iff tmux + claude reachable
|
||
# run-turn <session> <uuid> <msg-base64> -> send framed prompt, poll, return
|
||
# reset <session> -> kill the session entirely
|
||
#
|
||
# The persona + response protocol live in the Paliadin skill at
|
||
# ~/.claude/skills/paliadin/SKILL.md (see scripts/skills/paliadin/SKILL.md
|
||
# in the repo). Claude's skill router auto-matches the [PALIADIN:<uuid>]
|
||
# envelope and writes the response to /tmp/paliadin/<uuid>.txt — that is
|
||
# the contract this shim polls on. There is no longer a bootstrap step.
|
||
#
|
||
# All multi-character payloads (messages) are base64-encoded by the Go
|
||
# caller so we never have to quote them through ssh's argv.
|
||
#
|
||
# Errors go to stderr with a non-zero exit. The Go side maps the exit
|
||
# status into a friendly error code.
|
||
set -euo pipefail
|
||
umask 077
|
||
|
||
readonly RESPONSE_DIR="${PALIADIN_RESPONSE_DIR:-/tmp/paliadin}"
|
||
readonly TIMEOUT_S="${PALIADIN_TIMEOUT_S:-120}"
|
||
# Working directory for the claude pane. Must be the paliad repo root so
|
||
# claude picks up .mcp.json (project-scoped Supabase MCP) — without it,
|
||
# the SKILL.md SQL recipes fail with no DB tool. Override via env var if
|
||
# the repo lives elsewhere on this host.
|
||
readonly CLAUDE_CWD="${PALIADIN_REMOTE_CWD:-/home/m/dev/paliad}"
|
||
readonly PANE_READY_S=60 # max wait for claude pane to settle
|
||
readonly TURN_ID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||
# Session names are constructed by the Go side as `paliad-paliadin-<userid8>`;
|
||
# allow the same shape m might dial by hand. Stays defensive against shell
|
||
# metacharacters since this string is interpolated into tmux targets.
|
||
readonly SESSION_RE='^[A-Za-z0-9_.-]{1,64}$'
|
||
|
||
mkdir -p "$RESPONSE_DIR"
|
||
chmod 700 "$RESPONSE_DIR"
|
||
|
||
# Parse $SSH_ORIGINAL_COMMAND into argv. Format: "<verb> <arg1> <arg2> …".
|
||
# We never `eval` this; `read -r -a` splits on $IFS without word-expansion.
|
||
read -r -a argv <<< "${SSH_ORIGINAL_COMMAND:-}"
|
||
verb="${argv[0]:-}"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
log_err() { printf 'paliadin-shim: %s\n' "$*" >&2; }
|
||
|
||
# require_session validates argv[1] as a tmux session name. Echoes the
|
||
# validated name on success; logs + exits on failure.
|
||
require_session() {
|
||
local s="${argv[1]:-}"
|
||
if [[ -z "$s" ]]; then
|
||
log_err "$verb: missing session name"; exit 2
|
||
fi
|
||
if [[ ! "$s" =~ $SESSION_RE ]]; then
|
||
log_err "$verb: invalid session name"; exit 2
|
||
fi
|
||
printf '%s' "$s"
|
||
}
|
||
|
||
# ensure_pane creates the named tmux session + claude window if missing,
|
||
# waits for the pane to become ready, and prints the target identifier
|
||
# ("session:window-idx") on stdout.
|
||
#
|
||
# Per-user sessions are independently namespaced inside tmux; multiple
|
||
# paliad-paliadin-* sessions can coexist on mRiver without interfering.
|
||
ensure_pane() {
|
||
local session="$1"
|
||
|
||
if ! tmux has-session -t "$session" 2>/dev/null; then
|
||
tmux new-session -d -s "$session"
|
||
fi
|
||
|
||
# Look for an existing window tagged with @paliadin-scope=chat.
|
||
local target=""
|
||
local idx scope
|
||
while read -r idx; do
|
||
[[ -z "$idx" ]] && continue
|
||
scope=$(tmux show-window-option -t "$session:$idx" -v @paliadin-scope 2>/dev/null || true)
|
||
if [[ "$scope" == "chat" ]]; then
|
||
target="$session:$idx"
|
||
break
|
||
fi
|
||
done < <(tmux list-windows -t "$session" -F '#{window_index}' 2>/dev/null || true)
|
||
|
||
if [[ -z "$target" ]]; then
|
||
if ! command -v claude >/dev/null 2>&1; then
|
||
log_err "claude CLI not found in PATH"
|
||
exit 3
|
||
fi
|
||
if [[ ! -d "$CLAUDE_CWD" ]]; then
|
||
log_err "claude cwd $CLAUDE_CWD does not exist — set PALIADIN_REMOTE_CWD"
|
||
exit 3
|
||
fi
|
||
idx=$(tmux new-window -c "$CLAUDE_CWD" -t "$session" -n claude-paliadin -P -F '#{window_index}' claude)
|
||
target="$session:$idx"
|
||
|
||
# Wait for claude to settle. Matches Go waitForPaneReady (paliadin.go).
|
||
local deadline=$(( $(date +%s) + PANE_READY_S ))
|
||
local pane=""
|
||
while [[ $(date +%s) -lt $deadline ]]; do
|
||
pane=$(tmux capture-pane -t "$target" -p 2>/dev/null || true)
|
||
if [[ "$pane" == *"❯"* || "$pane" == *"│"* ]]; then
|
||
break
|
||
fi
|
||
sleep 0.5
|
||
done
|
||
|
||
tmux set-window-option -t "$target" @paliadin-scope chat >/dev/null
|
||
tmux set-window-option -t "$target" @fix-name claude-paliadin >/dev/null
|
||
fi
|
||
|
||
printf '%s' "$target"
|
||
}
|
||
|
||
# send_to_pane writes a literal string then Enter.
|
||
#
|
||
# Settle delay between the literal paste and the Enter: Claude Code's
|
||
# TUI debounces keyboard input; if Enter lands while the paste is still
|
||
# being absorbed, the carriage-return collapses into the input buffer
|
||
# as a literal newline character instead of registering as a "submit"
|
||
# gesture, leaving the prompt typed but unsubmitted (m's dogfood
|
||
# 2026-05-08 20:35: "lacking an enter key... or too fast"). 200ms is
|
||
# below the human-perceptible threshold but well above tmux's pty flush
|
||
# window.
|
||
send_to_pane() {
|
||
local target="$1" msg="$2"
|
||
tmux send-keys -t "$target" -l -- "$msg"
|
||
sleep 0.2
|
||
tmux send-keys -t "$target" Enter
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# verb dispatch
|
||
# ---------------------------------------------------------------------------
|
||
|
||
case "$verb" in
|
||
|
||
health)
|
||
# Used by the Go side's healthGate to short-circuit when mRiver is
|
||
# offline or tmux/claude is broken. Output is parsed verbatim.
|
||
# Session is required (per-user) but health is *not* expected to
|
||
# spin up the claude pane — only validates tooling + that we could
|
||
# in principle create the session.
|
||
session=$(require_session)
|
||
if ! command -v tmux >/dev/null 2>&1; then
|
||
log_err "tmux not in PATH"; exit 1
|
||
fi
|
||
if ! command -v claude >/dev/null 2>&1; then
|
||
log_err "claude not in PATH"; exit 1
|
||
fi
|
||
if ! tmux has-session -t "$session" 2>/dev/null; then
|
||
tmux new-session -d -s "$session"
|
||
fi
|
||
echo ok
|
||
;;
|
||
|
||
run-turn)
|
||
# $1 = session, $2 = turn_id (UUID), $3 = base64-encoded user message.
|
||
session=$(require_session)
|
||
turn_id="${argv[2]:-}"
|
||
if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then
|
||
log_err "run-turn: bad turn_id"; exit 2
|
||
fi
|
||
if [[ -z "${argv[3]:-}" ]]; then
|
||
log_err "run-turn: missing message"; exit 2
|
||
fi
|
||
if ! msg=$(printf '%s' "${argv[3]}" | base64 -d 2>/dev/null); then
|
||
log_err "run-turn: invalid base64 message"; exit 2
|
||
fi
|
||
target=$(ensure_pane "$session")
|
||
out="$RESPONSE_DIR/$turn_id.txt"
|
||
rm -f "$out"
|
||
|
||
# Envelope. The Paliadin skill (~/.claude/skills/paliadin/SKILL.md)
|
||
# description-matches on this exact prefix, so Claude routes to the
|
||
# skill on every turn regardless of conversation state — surviving
|
||
# /clear, fresh sessions, and pane restarts.
|
||
send_to_pane "$target" "[PALIADIN:$turn_id] $msg"
|
||
|
||
# Poll for the response file. Same shape as Go pollForResponse
|
||
# (paliadin.go). Settle delay so we don't read mid-flush.
|
||
deadline=$(( $(date +%s) + TIMEOUT_S ))
|
||
while [[ $(date +%s) -lt $deadline ]]; do
|
||
if [[ -s "$out" ]]; then
|
||
sleep 0.05
|
||
cat "$out"
|
||
rm -f "$out"
|
||
exit 0
|
||
fi
|
||
sleep 0.2
|
||
done
|
||
log_err "response timeout after ${TIMEOUT_S}s"
|
||
exit 124
|
||
;;
|
||
|
||
reset)
|
||
# Kill the user's session entirely so the next run-turn boots a
|
||
# fresh claude pane. With skill-based persona load, /clear would
|
||
# also work — but kill-session is simpler and removes any chance
|
||
# of leftover conversation state confusing the next turn.
|
||
session=$(require_session)
|
||
if tmux has-session -t "$session" 2>/dev/null; then
|
||
tmux kill-session -t "$session"
|
||
fi
|
||
echo ok
|
||
;;
|
||
|
||
'')
|
||
log_err "no verb (set SSH_ORIGINAL_COMMAND via authorized_keys command=)"
|
||
exit 2
|
||
;;
|
||
|
||
*)
|
||
log_err "unknown verb '$verb'"
|
||
exit 2
|
||
;;
|
||
esac
|