Owned, session-scoped nREPL lifecycle for the Clojure pack.
OWNERSHIP: each vis SESSION owns its own nREPL subprocess(es). The processes
atom is keyed by [session-id dir], so two sessions in the same directory get
two independent REPLs and neither can see or stop the other's. A managed REPL
lives and dies with THIS vis process — there is NO persistent registry and NO
PID re-attach across a vis restart. Restarting vis means a fresh REPL, exactly
like the Python pack.
PORT: we PICK a free ephemeral port ourselves and pass it to the launcher
EXPLICITLY (nrepl.cmdline --port N, lein repl :headless :port N,
bb nrepl-server N), so we always KNOW our port without ever reading a
.nrepl-port file back. Any stray .nrepl-port a tool drops in the project is
deleted after boot — vis never depends on it and never leaves it behind.
ALIASES: a REPL is ALWAYS booted with the project's :dev :test deps + paths
on its classpath (full dependency spec), with the user's :main-opts dropped
(our synthetic :vis/nrepl-launch alias appends last so -m nrepl.cmdline
wins). Unknown :dev/:test aliases are silently ignored by tools.deps, so
this is safe in any project.
ATTACHMENTS: connect! registers an EXTERNAL nREPL the user already runs in a
SEPARATE attachments atom, never in processes. They are different kinds:
one is a process we own and must eventually kill, the other is an address we
were invited to use. Keeping them apart is what lets ONE project have both at
once — the managed JVM REPL repl start booted for its .clj, and the
shadow-cljs nREPL its own watch runs for the .cljs — instead of the second
connect answering "already-running" about the first and handing back a JVM
REPL nobody asked for.
Starting/stopping is CORE and ALWAYS allowed — never gated behind a flag.
Owned, session-scoped nREPL lifecycle for the Clojure pack. OWNERSHIP: each vis SESSION owns its own nREPL subprocess(es). The `processes` atom is keyed by `[session-id dir]`, so two sessions in the same directory get two independent REPLs and neither can see or stop the other's. A managed REPL lives and dies with THIS vis process — there is NO persistent registry and NO PID re-attach across a vis restart. Restarting vis means a fresh REPL, exactly like the Python pack. PORT: we PICK a free ephemeral port ourselves and pass it to the launcher EXPLICITLY (`nrepl.cmdline --port N`, `lein repl :headless :port N`, `bb nrepl-server N`), so we always KNOW our port without ever reading a `.nrepl-port` file back. Any stray `.nrepl-port` a tool drops in the project is deleted after boot — vis never depends on it and never leaves it behind. ALIASES: a REPL is ALWAYS booted with the project's `:dev :test` deps + paths on its classpath (full dependency spec), with the user's `:main-opts` dropped (our synthetic `:vis/nrepl-launch` alias appends last so `-m nrepl.cmdline` wins). Unknown `:dev`/`:test` aliases are silently ignored by tools.deps, so this is safe in any project. ATTACHMENTS: `connect!` registers an EXTERNAL nREPL the user already runs in a SEPARATE `attachments` atom, never in `processes`. They are different kinds: one is a process we own and must eventually kill, the other is an address we were invited to use. Keeping them apart is what lets ONE project have both at once — the managed JVM REPL `repl start` booted for its `.clj`, and the shadow-cljs nREPL its own `watch` runs for the `.cljs` — instead of the second `connect` answering "already-running" about the first and handing back a JVM REPL nobody asked for. Starting/stopping is CORE and ALWAYS allowed — never gated behind a flag.
(attachment-health session-id dir)Coarse LIVE health of the EXTERNAL attachment for dir: :up while its address
answers, :down once it stops (or when nothing is attached). An attachment owns
no process to watch, so the probe IS its health — never :starting or :failed.
Coarse LIVE health of the EXTERNAL attachment for `dir`: :up while its address answers, :down once it stops (or when nothing is attached). An attachment owns no process to watch, so the probe IS its health — never :starting or :failed.
(connect! session-id dir {:keys [host port build]})Attach THIS session to an EXTERNAL nREPL the USER already runs (their editor
jack-in, a clj -M:nrepl, a shadow-cljs watch) — the OPT-IN inverse of
start!: vis never spawns, never kills and never reaps that process; it only
registers the address so eval targets it like a managed REPL. Explicit consent
only — nothing ever auto-connects and no port is ever scanned.
Opts {:host :port :build}:
:build names a shadow-cljs build ("app"). It makes this a ClojureScript
attachment: the build is SELECTED in the nREPL session every eval reuses, so
repl_eval lands in that build's JS runtime instead of the JVM the very same
server also serves. With no :port, the port is read from the project's own
.shadow-cljs/nrepl.port — the file that watch published, under the dir the
caller named.stop! / detach! on it only detaches.Every refusal names what to run next: "no-port", "unreachable", "not-shadow" (a plain JVM nREPL has no build to select), "unknown-build" (with the ids that server loaded), "no-watch" (a build's REPL needs its RUNNING worker), "select-failed". Model-facing: STRING keys + STRING enums.
Attach THIS session to an EXTERNAL nREPL the USER already runs (their editor
jack-in, a `clj -M:nrepl`, a `shadow-cljs watch`) — the OPT-IN inverse of
`start!`: vis never spawns, never kills and never reaps that process; it only
registers the address so eval targets it like a managed REPL. Explicit consent
only — nothing ever auto-connects and no port is ever scanned.
Opts `{:host :port :build}`:
- `:build` names a shadow-cljs build ("app"). It makes this a ClojureScript
attachment: the build is SELECTED in the nREPL session every eval reuses, so
`repl_eval` lands in that build's JS runtime instead of the JVM the very same
server also serves. With no `:port`, the port is read from the project's own
`.shadow-cljs/nrepl.port` — the file that watch published, under the dir the
caller named.
- The address is PROBED first (bounded): a dead host:port is REFUSED
("unreachable") instead of registered.
- An attachment is INDEPENDENT of the MANAGED REPL for the same dir: both stay
live, each under its own id. A second connect for the same dir REPLACES the
attachment ("reconnected"), so changing build or port needs no detach.
- `stop!` / `detach!` on it only detaches.
Every refusal names what to run next: "no-port", "unreachable",
"not-shadow" (a plain JVM nREPL has no build to select), "unknown-build"
(with the ids that server loaded), "no-watch" (a build's REPL needs its
RUNNING worker), "select-failed". Model-facing: STRING keys + STRING enums.(detach! session-id dir)DETACH THIS session's external attachment for dir: vis never kills a process
it did not spawn, so this drops the address — and evicts the client connection
whose socket would otherwise leak — while the user's server keeps running
exactly as it was. No-op-safe. Model-facing STRING-keyed result.
DETACH THIS session's external attachment for `dir`: vis never kills a process it did not spawn, so this drops the address — and evicts the client connection whose socket would otherwise leak — while the user's server keeps running exactly as it was. No-op-safe. Model-facing STRING-keyed result.
(eval! {:keys [host port build] :as target} opts)Evaluate opts (an nrepl-client/eval! map minus its address) over target —
resolve-target!'s map, or any {:host :port}. Returns nrepl-client's
STRING-keyed result, and never throws for a shadow-cljs condition.
A target carrying a shadow-cljs :build is the whole reason this exists. The
build is (re)SELECTED in the nREPL session before the eval whenever it is not
the one already selected THERE — a replaced session answers as JVM Clojure,
and a SIBLING build selected on the same server answers from the wrong
runtime, both without erroring. The result carries "build", and a build
whose JS runtime is not connected answers with the instruction that starts
one instead of shadow's bare No available JS runtime.
Evaluate `opts` (an `nrepl-client/eval!` map minus its address) over `target` —
`resolve-target!`'s map, or any `{:host :port}`. Returns nrepl-client's
STRING-keyed result, and never throws for a shadow-cljs condition.
A target carrying a shadow-cljs `:build` is the whole reason this exists. The
build is (re)SELECTED in the nREPL session before the eval whenever it is not
the one already selected THERE — a replaced session answers as JVM Clojure,
and a SIBLING build selected on the same server answers from the wrong
runtime, both without erroring. The result carries "build", and a build
whose JS runtime is not connected answers with the instruction that starts
one instead of shadow's bare `No available JS runtime`.(health session-id dir)Coarse LIVE health of THIS session's REPL for dir:
:up — managed process alive AND the port answers
:starting — managed process alive, port not answering yet
:failed — no live process but an UNEXPECTED death is on record
:down — nothing managed (intentional stop) and nothing attached
With no managed REPL the answer is the ATTACHMENT's health, because for a dir
whose only REPL is attached that is the REPL being asked about. Used as the
resource registry's :health-fn, so footer/F4/ctx status tracks reality
instead of the status frozen at registration time.
Coarse LIVE health of THIS session's REPL for `dir`: :up — managed process alive AND the port answers :starting — managed process alive, port not answering yet :failed — no live process but an UNEXPECTED death is on record :down — nothing managed (intentional stop) and nothing attached With no managed REPL the answer is the ATTACHMENT's health, because for a dir whose only REPL is attached that is the REPL being asked about. Used as the resource registry's `:health-fn`, so footer/F4/ctx status tracks reality instead of the status frozen at registration time.
(home-relativize dir)Collapse a leading user-home prefix to ~, so a REPL id reads ~/vis instead
of the noisy machine-absolute /Users/you/vis. Paths outside home (and blanks)
pass through unchanged.
Collapse a leading user-home prefix to `~`, so a REPL id reads `~/vis` instead of the noisy machine-absolute `/Users/you/vis`. Paths outside home (and blanks) pass through unchanged.
(id-of dir)Stable session-resource id for the REPL rooted at dir. The dir is CANONICALIZED
first — so .., a trailing slash, and symlinks all collapse to ONE id per
physical dir (no nrepl:.../vis vs nrepl:.../vis/.. near-duplicates spawning
twin REPLs) — then its home prefix is homogenized to ~, so a REPL always
addresses as nrepl:~/vis.
Stable session-resource id for the REPL rooted at `dir`. The dir is CANONICALIZED first — so `..`, a trailing slash, and symlinks all collapse to ONE id per physical dir (no `nrepl:.../vis` vs `nrepl:.../vis/..` near-duplicates spawning twin REPLs) — then its home prefix is homogenized to `~`, so a REPL always addresses as `nrepl:~/vis`.
(inherited-jvm-opts dir aliases)JVM options a nested project should INHERIT from an ancestor deps.edn.
The nREPL is launched with -M:dev:test:vis/nrepl-launch, so any :jvm-opts
dir's OWN deps.edn declares for those aliases already reach the JVM — in that
case nothing is inherited (returns nil, keeping the top-level project unchanged).
But a NESTED project whose deps.edn declares no such aliases (e.g. an extension
with a bare {:deps …} map) would otherwise boot a BARE JVM — missing the
workspace's flags (--enable-native-access, --enable-preview,
--sun-misc-unsafe-memory-access=allow, …) that its code needs, so tests crash
before they run. For that case we walk UP from dir to the nearest ancestor
whose deps.edn declares :jvm-opts for aliases and return them, so the nested
nREPL inherits the workspace's JVM options.
JVM options a nested project should INHERIT from an ancestor deps.edn.
The nREPL is launched with `-M:dev:test:vis/nrepl-launch`, so any `:jvm-opts`
`dir`'s OWN deps.edn declares for those aliases already reach the JVM — in that
case nothing is inherited (returns nil, keeping the top-level project unchanged).
But a NESTED project whose deps.edn declares no such aliases (e.g. an extension
with a bare `{:deps …}` map) would otherwise boot a BARE JVM — missing the
workspace's flags (`--enable-native-access`, `--enable-preview`,
`--sun-misc-unsafe-memory-access=allow`, …) that its code needs, so tests crash
before they run. For that case we walk UP from `dir` to the nearest ancestor
whose deps.edn declares `:jvm-opts` for `aliases` and return them, so the nested
nREPL inherits the workspace's JVM options.(last-failure session-id dir)The last UNEXPECTED launcher death recorded for [session-id dir], or nil.
STRING-keyed ("exit" "at" "log" "log_tail") — safe to splice into
model-facing results.
The last UNEXPECTED launcher death recorded for `[session-id dir]`, or nil.
STRING-keyed ("exit" "at" "log" "log_tail") — safe to splice into
model-facing results.(launcher-for dir aliases port)Subprocess command to boot a project nREPL in dir on the EXPLICIT port,
honouring aliases (deps.edn aliases / lein profiles). Returns
{:tool kw :cmd [strings]} or nil when no known Clojure build file is present.
Subprocess command to boot a project nREPL in `dir` on the EXPLICIT `port`,
honouring `aliases` (deps.edn aliases / lein profiles). Returns
`{:tool kw :cmd [strings]}` or nil when no known Clojure build file is present.(live-repl-for-dir session-id dir)The REPL session-id ALREADY has for dir, and only while it ANSWERS — else nil.
NEVER starts, stops or replaces a server: run_tests reuses a REPL the session
deliberately keeps up, and with none it runs the suite in a clean JVM instead of
spawning one behind the caller's back. repl_start is the ONE way a managed
REPL comes into existence.
The MANAGED REPL wins: it needs a live process AND a describe round-trip inside
its remaining cold-boot window (health-probe-ms), so a still-booting server
counts and a wedged one does not. An ATTACHMENT is offered only when it is a
JVM one and only while its own probe answers — a session SELECTED on a
shadow-cljs build cannot load a .clj test namespace, and handing it to a JVM
test run would fail as if the tests were broken.
The REPL `session-id` ALREADY has for `dir`, and only while it ANSWERS — else nil. NEVER starts, stops or replaces a server: `run_tests` reuses a REPL the session deliberately keeps up, and with none it runs the suite in a clean JVM instead of spawning one behind the caller's back. `repl_start` is the ONE way a managed REPL comes into existence. The MANAGED REPL wins: it needs a live process AND a describe round-trip inside its remaining cold-boot window (`health-probe-ms`), so a still-booting server counts and a wedged one does not. An ATTACHMENT is offered only when it is a JVM one and only while its own probe answers — a session SELECTED on a shadow-cljs build cannot load a `.clj` test namespace, and handing it to a JVM test run would fail as if the tests were broken.
(repl-by-id session-id id)The session's live REPL info matching resource id, or nil.
The session's live REPL info matching resource `id`, or nil.
(resolve-target! session-id id default-dir)Resolve the RUNNING REPL an eval should hit for session-id.
id is an optional explicit resource id; default-dir
picks the implicit default among several live REPLs. Returns {:id :dir :port}.
Rules (the ownership contract):
id → that REPL (throws :clj/unknown-repl-id if no such live REPL);id = default (any case) → sentinel, treated as no explicit id (below);1 REPLs → use the DEFAULT: the REPL owning
default-dir(the workspace root) when present, else the first (dir-sorted). Never throws on ambiguity — eval always resolves and the result reports which REPL ran it, so the model can pass an explicitidto override.
Resolve the RUNNING REPL an eval should hit for `session-id`.
`id` is an optional explicit resource id; `default-dir`
picks the implicit default among several live REPLs. Returns `{:id :dir :port}`.
Rules (the ownership contract):
- explicit `id` → that REPL (throws :clj/unknown-repl-id if no such live REPL);
- `id` = `default` (any case) → sentinel, treated as no explicit id (below);
- 0 REPLs → throw :clj/no-repl (start one with repl_start("clojure"));
- 1 REPL → use it (the implicit default);
- >1 REPLs → use the DEFAULT: the REPL owning `default-dir` (the
workspace root) when present, else the first (dir-sorted).
Never throws on ambiguity — eval always resolves and the
result reports which REPL ran it, so the model can pass an
explicit `id` to override.(session-repls session-id)Live REPLs OWNED by (or ATTACHED to) session-id, as a vec of
{:id :dir :port :tool :aliases :pid} (+ :log for managed; :external? :host :dialect and, for a shadow-cljs one, :build :target for
attached) sorted by dir. Prunes dead managed entries as a side effect.
This is the SINGLE source of truth for ctx + eval/test targeting — external
REPLs enter it ONLY via an explicit connect!, never by discovery.
Within one dir the MANAGED REPL sorts FIRST, so a dir that has both keeps the JVM REPL as its implicit default and attaching a ClojureScript build never silently redirects an eval that named no target.
Live REPLs OWNED by (or ATTACHED to) `session-id`, as a vec of
`{:id :dir :port :tool :aliases :pid}` (+ `:log` for managed; `:external? :host
:dialect` and, for a shadow-cljs one, `:build :target` for
attached) sorted by dir. Prunes dead managed entries as a side effect.
This is the SINGLE source of truth for ctx + eval/test targeting — external
REPLs enter it ONLY via an explicit `connect!`, never by discovery.
Within one dir the MANAGED REPL sorts FIRST, so a dir that has both keeps the
JVM REPL as its implicit default and attaching a ClojureScript build never
silently redirects an eval that named no target.(start! session-id dir)(start! session-id dir {:keys [aliases env]})Self-start a project nREPL subprocess OWNED by session-id in dir.
Always allowed — never flag-gated. Default :aliases are [:dev :test] (merged
with any explicitly passed). We pick a FREE port, pass it to the launcher, drop
any stray .nrepl-port, and wait for OUR port — SYNCHRONOUSLY: the wait ends
the moment the launcher dies (fast :failed with exit + log tail), and only a
still-alive-but-slow boot can outlive start-deadline-ms (then :starting,
with an .onExit watcher recording any later death as a failure).
[session-id dir] → :already-running — unless this
start named a DIFFERENT :env, which is refused by the keys that differ: a
REPL IS its environment, and reusing one started with another would report
success for a process that never saw the variables this call asked for.Model-facing: STRING keys + STRING enum values (crosses as a tool :result).
Self-start a project nREPL subprocess OWNED by `session-id` in `dir`. Always allowed — never flag-gated. Default `:aliases` are [:dev :test] (merged with any explicitly passed). We pick a FREE port, pass it to the launcher, drop any stray `.nrepl-port`, and wait for OUR port — SYNCHRONOUSLY: the wait ends the moment the launcher dies (fast :failed with exit + log tail), and only a still-alive-but-slow boot can outlive `start-deadline-ms` (then :starting, with an `.onExit` watcher recording any later death as a failure). - Already ours + alive for `[session-id dir]` → :already-running — unless this start named a DIFFERENT `:env`, which is refused by the keys that differ: a REPL IS its environment, and reusing one started with another would report success for a process that never saw the variables this call asked for. - No known build file → :no-launcher. - Launcher exits before binding → :failed with exit code + log tail. - Else :started (port up) or :starting (still coming up; ctx will show it). Model-facing: STRING keys + STRING enum values (crosses as a tool `:result`).
(status session-id dir)Live view of THIS session's REPLs for dir. Always safe. Model-facing: STRING
keys + STRING enum values (crosses as a tool :result).
The MANAGED REPL is the subject; an EXTERNAL attachment for the same dir rides along under "attached" — and IS the subject when there is no managed REPL, because then it is the only REPL this dir has.
Live view of THIS session's REPLs for `dir`. Always safe. Model-facing: STRING keys + STRING enum values (crosses as a tool `:result`). The MANAGED REPL is the subject; an EXTERNAL attachment for the same dir rides along under "attached" — and IS the subject when there is no managed REPL, because then it is the only REPL this dir has.
(stop! session-id dir)Stop THIS session's REPL for dir. A MANAGED subprocess is destroyed (graceful,
then forced); the entry is DEREGISTERED FIRST so the .onExit watcher reads
that death as an intentional stop, never a failure, and any remembered
failure/crash history for dir is cleared too.
With no managed REPL but an attachment for dir, this DETACHES it: stop on
the only REPL a dir has must never answer not-managed about the one REPL it
can see. No-op-safe. Model-facing STRING-keyed result.
Stop THIS session's REPL for `dir`. A MANAGED subprocess is destroyed (graceful, then forced); the entry is DEREGISTERED FIRST so the `.onExit` watcher reads that death as an intentional stop, never a failure, and any remembered failure/crash history for `dir` is cleared too. With no managed REPL but an attachment for `dir`, this DETACHES it: `stop` on the only REPL a dir has must never answer `not-managed` about the one REPL it can see. No-op-safe. Model-facing STRING-keyed result.
(tail-log log-path)(tail-log log-path n)Tail a managed nREPL launcher log as line strings, reading ONLY the last
tail-read-bytes of the file (never the whole thing). Returns [] when the
log does not exist yet or cannot be read; resource viewers treat that as an
empty but still log-capable resource.
Tail a managed nREPL launcher log as line strings, reading ONLY the last `tail-read-bytes` of the file (never the whole thing). Returns [] when the log does not exist yet or cannot be read; resource viewers treat that as an empty but still log-capable resource.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |