diff --git a/README.md b/README.md index 7ff3f90..ed9e8ad 100644 --- a/README.md +++ b/README.md @@ -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. @@ -54,6 +53,7 @@ repld --trace LEVEL ... # 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 ``` @@ -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. diff --git a/go/daemon.go b/go/daemon.go index d19f007..438795d 100644 --- a/go/daemon.go +++ b/go/daemon.go @@ -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."} diff --git a/go/engine_test.go b/go/engine_test.go index 6ecbfd0..6593d57 100644 --- a/go/engine_test.go +++ b/go/engine_test.go @@ -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() diff --git a/go/main.go b/go/main.go index 76e367e..e83e094 100644 --- a/go/main.go +++ b/go/main.go @@ -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{ @@ -209,7 +220,7 @@ func usage() { Usage: repld [interp-args] (-- CODE | [--] [script-args] | -) - repld [] [--session L] # target a session: trace, interrupt + repld [] [--session L] # target a session: trace, interrupt, close repld # daemon-wide: sessions, stop, daemon is the interpreter to run (julia, python3, R, wolframscript, .venv/bin/python, /path/...). @@ -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') @@ -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 { @@ -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)") diff --git a/go/manager.go b/go/manager.go index c8f6958..c9d5fea 100644 --- a/go/manager.go +++ b/go/manager.go @@ -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()