GitHub - danieltanfh95/replsh: The thinking medium for LLM agents

6 min read Original article ↗

License: EPL 2.0

A thinking medium for LLM agents. Bash gives agents hands — they can move files, invoke tools, run commands. replsh gives them a scratchpad — a persistent, stateful environment where they eval expressions, inspect runtime state, and test hypotheses before committing to code.

Supports Clojure (deps.edn, Leiningen, Babashka), Python (native bridge — zero deps, or Jupyter for rich output), Node.js, and Bash (persistent shell — env vars, cwd, and functions survive across evals). All output is structured JSON. Sessions persist across invocations.

Why a REPL?

LLMs today write code blind. They produce a function, run the test suite, read the failure, and iterate. A REPL changes the loop — instead of write then verify, agents can verify then write:

  • Think before you commit — eval an expression to check your assumption before writing it into a file
  • Build up context — imports, variables, and state persist across evals, so you can explore incrementally
  • Never lose output — timeouts return partial results, not errors. Even an interrupted eval teaches you something
  • Work at any timescale — fast checks (sync), progressive output (streaming), long-running tasks (background)

The difference: bash runs commands. A REPL is where you think.

Install

Requires Babashka.

# Via bbin (recommended)
bbin install io.github.danieltanfh95/replsh

# Or clone and install locally
git clone https://github.com/danieltanfh95/replsh.git
cd replsh
bbin install . --as replsh

Add as a Skill

Generate a skills.sh-compatible skill document that teaches LLM agents how to use the REPL proactively:

replsh --install-skill                  # writes to skills/replsh/SKILL.md
replsh --install-skill --path my.md    # custom path

Or just point agents at the built-in help directly in your CLAUDE.md or AGENTS.md:

Run `replsh --help` and read the output to learn the replsh skill. Follow it.

Quick Start

# Launch a REPL (from project config or explicit)
replsh launch --name dev
replsh launch nrepl --name dev --cmd "bb --nrepl-server {port}"
replsh launch python --name py --cmd "python3 {bridge} --port {port}"

# Eval — the core loop
replsh eval --name dev '(+ 1 2)'
# → {"ok":true,"data":{"value":"3","ns":"user",...}}

# Stream output in real time
replsh eval --name dev --stream '(run-tests)'

# Background eval for long-running work
replsh eval --name dev --bg '(train-model data)'
# → {"ok":true,"data":{"eval-id":"eval-a1b2c3d4",...}}
replsh output --eval-id eval-a1b2c3d4

Eval Modes

Default: sync with graceful timeout

replsh eval --name dev '(my-fn input)'           # 30s default timeout
replsh eval --name dev '(slow-fn)' --timeout 60000

If it times out, you get partial output (whatever accumulated) as a success with "status": "partial" — not an error. The eval may still run server-side. You never lose output to a timeout.

Streaming

replsh eval --name dev --stream '(doseq [i (range 10)] (println i) (Thread/sleep 500))'

NDJSON — one JSON line per chunk as it arrives. Use for test suites, data processing, anything where progressive output matters.

Background

replsh eval --name dev --bg '(train-model data)' --timeout 0 --hard-timeout 600000
replsh evals                              # list background evals
replsh output --eval-id <id>              # read output
replsh output --eval-id <id> --follow     # tail output live

Forks to a background process. Returns an eval-id immediately. Check results later.

Hard timeout

replsh eval --name dev '(risky-fn)' --timeout 5000 --hard-timeout 60000

Soft timeout (partial output at 5s) + hard timeout (interrupt eval at 60s). --hard-timeout sends a backend interrupt — guaranteed to stop the eval.

Exec Mode (Inject REPL via a Via Chain)

Inject a REPL bridge into any target reachable through an ordered chain of SSH, Docker, and environment layers — without touching the container entrypoint or exposing a port.

The chain is described left-to-right after --, with --runtime opening each layer:

# Into an already-running container
replsh launch --name api -- \
  --runtime docker --container my-flask-app \
  --toolchain python

# SSH → Docker (local → remote Docker host)
replsh launch --name remote -- \
  --runtime ssh --host my-machine \
  --runtime docker --container my-app \
  --toolchain python

# Docker → SSH (reversed — from inside a container, reach an internal host)
replsh launch --name dind -- \
  --runtime docker --container outer \
  --runtime ssh --host internal.host \
  --toolchain python

# SSH → Docker → Bash (environment setup: rbenv, conda, nvm, ...)
replsh launch --name rbenv -- \
  --runtime ssh --host my-machine \
  --runtime docker --container my-app \
  --runtime bash --setup "source /etc/profile.d/rbenv.sh" \
  --toolchain python

# State persists across evals
replsh eval --name api 'import sys; print(sys.version)'
replsh eval --name api 'x = 42'
replsh eval --name api 'print(x)'   # → 42

replsh stop api  # kills bridge; leaves unowned containers running

Chains can also live in config:

;; .replsh/config.edn
{:sessions
 {"remote"  {:via [{:type :ssh :host "my-machine"}
                   {:type :docker :container "my-app"}]
             :toolchain "python"}
  "rbenv"   {:via [{:type :ssh :host "my-machine"}
                   {:type :docker :container "my-app"}
                   {:type :bash :setup ["source /etc/profile.d/rbenv.sh"]}]
             :toolchain "python"}}}

Then just replsh launch --name remote.

The bridge deploys via stdin piping through the chain and runs persistently on localhost inside the target (no port exposure). Each eval opens an ephemeral proxy through the same chain. --host accepts any ~/.ssh/config alias — ProxyJump, user, port, and identity file are all inherited.

Session Management

replsh ls                    # list all sessions
replsh status --name dev     # reachability + process info
replsh restart dev           # restart server, re-run init
replsh stop dev              # kill and remove
replsh interrupt --name dev  # cancel running eval
replsh logs --name dev       # read server process logs

Config

Project config (.replsh/config.edn)

{:sessions
 {"backend"  {:toolchain "clojure.bb"
              :init      "(require '[my.app])"}
  "ml"       {:toolchain "python.poetry"
              :cwd       "ml/"}}}

Built-in toolchains

Name Backend Command template
clojure.deps nrepl clj -M:nrepl -m nrepl.cmdline --port {port}
clojure.lein nrepl lein repl :headless :port {port}
clojure.bb nrepl bb --nrepl-server {port}
python python python3 {bridge} --port {port}
python.poetry python poetry run python {bridge} --port {port}
python.venv python {cwd}/.venv/bin/python {bridge} --port {port}
python.poetry.jupyter jupyter poetry run jupyter server --port {port}
python.venv.jupyter jupyter {cwd}/.venv/bin/jupyter server --port {port}
bash bash python3 {bridge} --port {port} --backend bash
node node node -e "require('net').createServer(...)..."

Run replsh toolchains for the full list including Docker port-mode variants. Custom toolchains go in ~/.replsh/config.edn under :toolchains.

Output Format

All sync commands emit one JSON object. Streaming commands emit NDJSON.

{"ok": true, "command": "eval", "status": "complete", "data": {"value": "3", "ns": "user", "chunks": [...]}}
{"ok": true, "command": "eval", "status": "partial", "data": {"chunks": [...]}}

Exit codes: 0 success (including partial), 1 eval error, 2 client error, 3 hard timeout.

Architecture

CLI args + config → Session Config → state.edn
                         ↓
                    backend/open! → eval! → close!
                         ↓
              sync / stream (NDJSON) / background (fork)
  • Backends (nREPL, Python, Jupyter, Node, Bash) handle wire protocols via multimethods
  • Eval modes — sync (default), streaming (--stream), background (--bg)
  • Timeout — soft (partial output, exit 0) and hard (interrupt, exit 3)
  • Process management spawns/kills servers, tracks PIDs
  • State persists to ~/.replsh/state.edn — no daemon

Development

replsh is developed using replsh:

replsh launch --name dev
replsh eval --name dev --stream \
  '(require (quote [replsh.test-runner])) (replsh.test-runner/run-all :unit-only? true)'
replsh stop dev

Documentation

License

Eclipse Public License 2.0