Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.
Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.
Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
session arg + caches per-key; ResetSession derives session from UserID
and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.
Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.
Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.
Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
Phase B step 2: lands the Paliadin backend that talks to mRiver via
ssh + paliadin-shim. Local backend untouched — selection happens in
cmd/server/main.go based on PALIADIN_REMOTE_HOST.
Files:
- internal/services/paliadin_remote.go (new) — RemotePaliadinService
+ RemotePaliadinConfig, with five SSH knobs (Host/Port/User/KeyPath/
KnownHostsPath). RunTurn does insertTurnRow → healthGate → bootstrap
→ callShim run-turn → splitTrailer → completeTurn, mirroring the
local path's audit-row contract. ResetSession sends shim 'reset'.
callShim runs `ssh -F /dev/null -i <key> -p <port> -o … host -- verb
args`; ControlMaster intentionally not enabled (design §6.8).
- internal/services/paliadin_remote.go also adds DisabledPaliadinService
(returns ErrPaliadinDisabled from RunTurn/ResetSession; DB methods
inherited from paliadinDB still work) so cmd/server/main.go can wire
a non-nil Paliadin even when neither local tmux nor remote SSH is
available.
- ErrMRiverUnreachable sentinel for the friendly error code.
- classifySSHError translates ssh exit 124 / Permission denied /
network errors into the audit-row error_code field.
- Compile-time conformance: var _ Paliadin = (*Local|*Remote|*Disabled)
PaliadinService(nil).
cmd/server/main.go switch:
PALIADIN_REMOTE_HOST set → NewRemotePaliadinService
else: tmux on PATH → NewLocalPaliadinService
else: NewDisabledPaliadinService
buildPaliadinRemoteConfig materialises PALIADIN_SSH_PRIVATE_KEY +
PALIADIN_KNOWN_HOSTS (multi-line Dokploy secrets) into chmod-600/644
tmpfiles at boot. Defaults: SSHUser=m, SSHPort=22022 (bypasses
Tailscale SSH on :22, see design §4.5). Fails fast on a configured
remote-host without the matching key/known_hosts secrets.
Local-tmux mode now requires `tmux` actually be on PATH at boot
(exec.LookPath gate); previously the constructor unconditionally
returned a service whose RunTurn would fail at runtime with
ErrTmuxUnavailable. The handler-level "friendly error" UX is
unchanged: DisabledPaliadinService surfaces ErrPaliadinDisabled which
the frontend renders the same way.
Build green; existing paliadin_test.go still passes (it tests
package-level helpers, untouched). Remote-specific tests land in B4.
Refs m/paliad#12