Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ Eval flags use each language's native spelling — Julia: `-e` / `-E`, Python: `

Per-language docs: [julia](skills/repld/references/julia.md) · [python](skills/repld/references/python.md) · [R](skills/repld/references/r.md) · [Wolfram](skills/repld/references/wolfram.md).


## Agent skill

The skill at `skills/repld/SKILL.md` teaches agents how to use repld.
Expand All @@ -54,6 +53,7 @@ repld --trace LEVEL <exe> ... # error traceback level: short | smart | full

repld trace [--session=L | exe] # last saved traceback
repld interrupt [--session=L | exe] # interrupt in-flight eval
repld close [--session=L | exe]
repld sessions # list active sessions
repld stop # shutdown daemon
```
Expand All @@ -67,12 +67,12 @@ One Go binary is both the CLI client and the background daemon (auto-started on
## Alternatives

- [Jupyter](https://jupyter.org) (IJulia, ipykernel) is the established polyglot, heavier version of this idea: persistent kernels over ZeroMQ with rich display and browser frontend, kernels for ~100 languages. However
- Agent needs none of the UI layer. Rich display protocol (interactive widgets, HTML reprs, plot embedding) is nice for humans but irrelevant for headless agents.
- Notebook `.ipynb` format is noisy. JSON wrapper with cell metadata, output mime-bundles, execution counts. Agent thinks better with plain code + stdout.
- This tool is the minimal slice: one dependency-free binary, a plain text protocol — trading rich MIME output for simplicity and zero setup.
- Agent needs none of the UI layer. Rich display protocol (interactive widgets, HTML reprs, plot embedding) is nice for humans but irrelevant for headless agents.
- Notebook `.ipynb` format is noisy. JSON wrapper with cell metadata, output mime-bundles, execution counts. Agent thinks better with plain code + stdout.
- This tool is the minimal slice: one dependency-free binary, a plain text protocol — trading rich MIME output for simplicity and zero setup.
- Model Context Protocol (MCP) Server is less composable than shell tools and infeasible to use from outside AI sessions.
- [mcp-repl](https://github.com/posit-dev/mcp-repl) for persistent Python/R with sandboxing, inline plot images, curated oversized output.
- [julia-mcp](https://github.com/aplavin/julia-mcp?tab=readme-ov-file) for Julia
- [mcp-repl](https://github.com/posit-dev/mcp-repl) for persistent Python/R with sandboxing, inline plot images, curated oversized output.
- [julia-mcp](https://github.com/aplavin/julia-mcp?tab=readme-ov-file) for Julia
- Julia
- [DaemonicCabal.jl](https://github.com/tecosaur/DaemonicCabal.jl) only runs on Linux
- [Malt.jl](https://github.com/JuliaPluto/Malt.jl) manages isolated Julia worker processes *from within Julia* (used by Pluto). Both run code in persistent, crash-isolated subprocesses, but Malt is a Julia library: its driver must be Julia, and it returns native typed values over Julia's serialization. This tool targets non-Julia callers — a single dependency-free binary speaking a text protocol, so any language/shell/agent can drive it and interpreter versions can be mixed freely.
- [DaemonicCabal.jl](https://github.com/tecosaur/DaemonicCabal.jl) only runs on Linux
- [Malt.jl](https://github.com/JuliaPluto/Malt.jl) manages isolated Julia worker processes _from within Julia_ (used by Pluto). Both run code in persistent, crash-isolated subprocesses, but Malt is a Julia library: its driver must be Julia, and it returns native typed values over Julia's serialization. This tool targets non-Julia callers — a single dependency-free binary speaking a text protocol, so any language/shell/agent can drive it and interpreter versions can be mixed freely.
7 changes: 7 additions & 0 deletions go/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ func handleRequest(state *daemonState, req protocolRequest) response {
}
return response{Output: msg}

case "close":
msg, err := state.manager.close(req.Lang, req.Session, req.Cwd, discFor(req))
if err != nil {
return errResp(err.Error())
}
return response{Output: msg}

case "stop":
state.stopOnce.Do(func() { close(state.stopCh) })
return response{Output: "Daemon stopping."}
Expand Down
19 changes: 19 additions & 0 deletions go/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,25 @@ func TestInterruptUnknownSession(t *testing.T) {
require.Contains(t, resp.Error, "no session")
}

func TestCloseUnknownSession(t *testing.T) {
resp := handleRequest(newTestState(), protocolRequest{Action: "close", Session: "nope", Cwd: t.TempDir()})
require.NotEmpty(t, resp.Error)
require.Contains(t, resp.Error, "no session")
}

func TestCloseSession(t *testing.T) {
state := newTestState()
sess := newSession(julia.Adapter{}, "s", nil, nil)
sess.lang = "julia"
state.manager.sessions["~scratch"] = sess

resp := handleRequest(state, protocolRequest{Action: "close", Session: "scratch", Cwd: t.TempDir()})
require.Empty(t, resp.Error)
require.Contains(t, resp.Output, "closed")
require.Empty(t, state.manager.sessions)
require.False(t, sess.isAlive())
}

func TestSessionManagerKey(t *testing.T) {
m := newSessionManager()
defer m.shutdown()
Expand Down
21 changes: 18 additions & 3 deletions go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,17 @@ func cmdInterrupt(socketPath, lang, exe, session string, fwd []string) {
}, false)
}

func cmdClose(socketPath, lang, exe, session string, fwd []string) {
run(socketPath, protocolRequest{
Action: "close",
Lang: lang,
Exe: exe,
Cwd: mustGetwd(),
Session: session,
Args: fwd,
}, false)
}

func cmdTrace(socketPath, lang, exe, session, traceLevel string, fwd []string) {
traceLevel = cmp.Or(traceLevel, "full")
run(socketPath, protocolRequest{
Expand All @@ -209,7 +220,7 @@ func usage() {

Usage:
repld <exe> [interp-args] (--<eval> CODE | [--] <file> [script-args] | -)
repld <command> [<exe>] [--session L] # target a session: trace, interrupt
repld <command> [<exe>] [--session L] # target a session: trace, interrupt, close
repld <command> # daemon-wide: sessions, stop, daemon

<exe> is the interpreter to run (julia, python3, R, wolframscript, .venv/bin/python, /path/...).
Expand All @@ -223,10 +234,11 @@ repld flags:
--fresh Clear the targeted session before evaluating
--trace LEVEL Error traceback level: short, smart, or full (eval default: smart)

Commands (trace/interrupt take an optional [exe] and/or --session to locate the session):
Commands (trace/interrupt/close take an optional [exe] and/or --session to locate the session):
sessions List active sessions (all languages)
trace Print the last saved error traceback for the session
interrupt Interrupt the in-flight eval (SIGKILL after 3s if unresponsive)
close Kill the session's interpreter and discard its state
stop Stop the daemon
daemon Run the daemon in the foreground (normally auto-started)
--idle-timeout SECS Shut down after idle (default: 0 = never; use 'stop')
Expand All @@ -238,7 +250,7 @@ Global flags:
}

var subcommands = map[string]bool{
"sessions": true, "trace": true, "interrupt": true, "stop": true, "daemon": true,
"sessions": true, "trace": true, "interrupt": true, "close": true, "stop": true, "daemon": true,
}

type parsed struct {
Expand Down Expand Up @@ -468,6 +480,9 @@ func dispatchSubcommand(p parsed) {
case "interrupt":
tg := parseTarget(p)
cmdInterrupt(p.socket, tg.lang, resolveExeStr(tg.exe, tg.lang), tg.session, tg.fwd)
case "close":
tg := parseTarget(p)
cmdClose(p.socket, tg.lang, resolveExeStr(tg.exe, tg.lang), tg.session, tg.fwd)
case "daemon":
fs := flag.NewFlagSet("daemon", flag.ExitOnError)
idleTimeout := fs.Float64("idle-timeout", 0, "Shut down after this many idle seconds (0 = never)")
Expand Down
14 changes: 14 additions & 0 deletions go/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ func (m *SessionManager) restart(lang, session, cwd, disc string) {
}
}

func (m *SessionManager) close(lang, session, cwd, disc string) (string, error) {
key := m.key(lang, session, cwd, disc)
m.mu.Lock()
sess := m.sessions[key]
delete(m.sessions, key)
delete(m.lastErrors, key)
m.mu.Unlock()
if sess == nil {
return "", fmt.Errorf("no session for %s", key)
}
sess.kill()
return fmt.Sprintf("Session %s closed.", key), nil
}

func (m *SessionManager) hasLiveSession(lang, session, cwd, disc string) bool {
key := m.key(lang, session, cwd, disc)
m.mu.Lock()
Expand Down
Loading