Subprocesses, portable.
sh runs a command to completion; spawn keeps a long-lived child with piped
stdin/stdout, which is what an MCP stdio transport needs and what sh cannot
express.
Both have an OFF SWITCH: sh takes :timeout-ms, and a spawned child takes
kill!. A subprocess is the most dangerous thing most programs do, and one
that cannot be stopped is a program that cannot be stopped.
Subprocesses, portable. `sh` runs a command to completion; `spawn` keeps a long-lived child with piped stdin/stdout, which is what an MCP stdio transport needs and what `sh` cannot express. Both have an OFF SWITCH: `sh` takes `:timeout-ms`, and a spawned child takes `kill!`. A subprocess is the most dangerous thing most programs do, and one that cannot be stopped is a program that cannot be stopped.
(alive? child)True while the child is running.
True while the child is running.
(close! child)Close the child's stdin, wait for it to exit, and return the exit code.
This is the POLITE shutdown and it WAITS: a child that ignores its stdin
closing will hang here forever. When you need a guarantee rather than a
request, use kill!.
Close the child's stdin, wait for it to exit, and return the exit code. This is the POLITE shutdown and it WAITS: a child that ignores its stdin closing will hang here forever. When you need a guarantee rather than a request, use `kill!`.
(exit-code child)The status the child exited with, or nil while it is still running.
This is the difference between a peer that DIED and a peer that went QUIET,
and read-line! alone cannot tell you: it returns nil for both. A transport
reading nil has two very different situations to distinguish —
(when (nil? (proc/read-line! c))
(if-let [code (proc/exit-code c)]
(peer-died code) ; it exited; `code` says how
(still-running c))) ; stdout closed or is merely idle
Without it the only recourse is a timeout — waiting a while and then killing the child to find out whether it was ever going to answer. That is a guess standing in for a fact, and it is wrong in both directions: too short kills a slow peer, too long hangs on a dead one. Asked for by the toolnexus port, whose MCP transport was carrying exactly that guess in shipped code.
nil means RUNNING, never "exited with no status" — a process always exits
with a status. A child killed by kill! has exited, so this reports whatever
the host recorded for a signalled process; do not read meaning into that
number, since the hosts do not agree on it. kill! itself still returns nil
for the same reason.
The status the child exited with, or nil while it is still running.
This is the difference between a peer that DIED and a peer that went QUIET,
and `read-line!` alone cannot tell you: it returns nil for both. A transport
reading nil has two very different situations to distinguish —
(when (nil? (proc/read-line! c))
(if-let [code (proc/exit-code c)]
(peer-died code) ; it exited; `code` says how
(still-running c))) ; stdout closed or is merely idle
Without it the only recourse is a timeout — waiting a while and then killing
the child to find out whether it was ever going to answer. That is a guess
standing in for a fact, and it is wrong in both directions: too short kills a
slow peer, too long hangs on a dead one. Asked for by the toolnexus port,
whose MCP transport was carrying exactly that guess in shipped code.
nil means RUNNING, never "exited with no status" — a process always exits
with a status. A child killed by `kill!` has exited, so this reports whatever
the host recorded for a signalled process; do not read meaning into that
number, since the hosts do not agree on it. `kill!` itself still returns nil
for the same reason.(kill! child)Force-terminate the child. Returns nil — always, on both hosts.
Returns nil rather than an exit code on purpose: a killed process did not
choose one. The JVM would report 137 and Go -1, and koine does not invent
agreement between two host-specific numbers. Ask alive? if you need to know
it is gone.
This is also the way OUT of a blocked read-line!. A reader parked on a hung
peer cannot be interrupted portably, but killing the child closes its stdout,
so the parked read-line! hits EOF and returns nil. One mechanism ends a
runaway command and frees a stuck transport — asked for by the toolnexus port
as two separate things, 2026-07-31.
Force-terminate the child. Returns nil — always, on both hosts. Returns nil rather than an exit code on purpose: a killed process did not choose one. The JVM would report 137 and Go -1, and koine does not invent agreement between two host-specific numbers. Ask `alive?` if you need to know it is gone. This is also the way OUT of a blocked `read-line!`. A reader parked on a hung peer cannot be interrupted portably, but killing the child closes its stdout, so the parked `read-line!` hits EOF and returns nil. One mechanism ends a runaway command and frees a stuck transport — asked for by the toolnexus port as two separate things, 2026-07-31.
(read-line! child)Block for one line from the child's stdout, WITHOUT the terminator. nil at EOF.
Block for one line from the child's stdout, WITHOUT the terminator. nil at EOF.
(run-async! f)Run f on a background thread that must NOT keep the program alive.
NOT future on the JVM. Clojure's future pool threads are non-daemon with a
60-second keep-alive, so a program that called ONE koine.process fn sat there
for a full minute after printing its answer — no output, no CPU, nothing to
see. (shutdown-agents) fixes it, but a library cannot demand that of a
consumer, and a library must never decide when the host program may exit.
Measured 2026-07-31: 60.5s for a sh ["true"].
On cljgo the equivalent is a goroutine, which never holds up exit.
PUBLIC, and that is the point. This was private while koine used it to fix its
own 60-second hang, which quietly handed the same hang to every consumer: a
caller running its own reader loop over read-line! reaches for future,
gets the non-daemon pool, and cannot call (shutdown-agents) from library
code either. Measured by the toolnexus port on their MCP suite — 64.9s with
future, 4.3s with the pool shut down, cljgo 4.5s either way. Fixing that
only inside koine was fixing it for koine, not for anyone using koine.
Returns immediately. f takes no arguments; its value is discarded, so
communicate through an atom or a promise. Nothing waits for it — if you need
to know it finished, deliver a promise at the end of f.
Run `f` on a background thread that must NOT keep the program alive. NOT `future` on the JVM. Clojure's future pool threads are non-daemon with a 60-second keep-alive, so a program that called ONE koine.process fn sat there for a full minute after printing its answer — no output, no CPU, nothing to see. `(shutdown-agents)` fixes it, but a library cannot demand that of a consumer, and a library must never decide when the host program may exit. Measured 2026-07-31: 60.5s for a `sh ["true"]`. On cljgo the equivalent is a goroutine, which never holds up exit. PUBLIC, and that is the point. This was private while koine used it to fix its own 60-second hang, which quietly handed the same hang to every consumer: a caller running its own reader loop over `read-line!` reaches for `future`, gets the non-daemon pool, and cannot call `(shutdown-agents)` from library code either. Measured by the toolnexus port on their MCP suite — 64.9s with `future`, 4.3s with the pool shut down, cljgo 4.5s either way. Fixing that only inside koine was fixing it for koine, not for anyone using koine. Returns immediately. `f` takes no arguments; its value is discarded, so communicate through an atom or a promise. Nothing waits for it — if you need to know it finished, deliver a promise at the end of `f`.
(send-line! child s)Write s + newline to the child's stdin and flush. Returns nil.
Write `s` + newline to the child's stdin and flush. Returns nil.
(sh command)(sh command {:keys [in dir env timeout-ms]})Run command (a vector) to completion. Returns
{:out :err :exit :timed-out?}. Never throws on a non-zero exit — that is a
normal result.
opts: :in (string on stdin) :dir :env :timeout-ms
:timeout-ms force-kills the command after n milliseconds. A killed command
reports :timed-out? true and :exit nil — NOT a number. There is no exit
code when a process is killed: the JVM would report 137 and Go -1, both of
them inventions, and a caller doing (zero? (:exit r)) must not silently read
a kill as a clean run. :timed-out? is always present, so it is safe to test
on every result. :out / :err carry whatever the command managed to write
before it died, which is best-effort and NOT guaranteed to match across hosts.
Without :timeout-ms the command runs to completion, however long that is.
Asked for by the toolnexus port: an agent's bash tool needs an off switch.
Run `command` (a vector) to completion. Returns
{:out :err :exit :timed-out?}. Never throws on a non-zero exit — that is a
normal result.
opts: :in (string on stdin) :dir :env :timeout-ms
`:timeout-ms` force-kills the command after n milliseconds. A killed command
reports `:timed-out? true` and `:exit nil` — NOT a number. There is no exit
code when a process is killed: the JVM would report 137 and Go -1, both of
them inventions, and a caller doing `(zero? (:exit r))` must not silently read
a kill as a clean run. `:timed-out?` is always present, so it is safe to test
on every result. `:out` / `:err` carry whatever the command managed to write
before it died, which is best-effort and NOT guaranteed to match across hosts.
Without `:timeout-ms` the command runs to completion, however long that is.
Asked for by the toolnexus port: an agent's `bash` tool needs an off switch.(spawn command)(spawn command {:keys [dir env]})Start command (a vector) as a LONG-LIVED child with piped stdin/stdout and
return a child handle — a map of closures, see above. This is what a
line-delimited JSON-RPC transport (MCP stdio) requires; sh cannot express it.
The child's STDERR is drained from the moment it starts — see the comment
above; not draining it deadlocks the child once the pipe buffer fills. Read it
with stderr-lines.
opts: :dir :env
Start `command` (a vector) as a LONG-LIVED child with piped stdin/stdout and return a child handle — a map of closures, see above. This is what a line-delimited JSON-RPC transport (MCP stdio) requires; `sh` cannot express it. The child's STDERR is drained from the moment it starts — see the comment above; not draining it deadlocks the child once the pipe buffer fills. Read it with `stderr-lines`. opts: :dir :env
(stderr-lines child)The child's most recent stderr lines (up to 200), oldest first, as a vector.
Always available — stderr is drained from the moment the child starts, so this is a snapshot of a buffer that is being filled for you, not a read that could block. Returns [] when the child has written nothing.
The child's most recent stderr lines (up to 200), oldest first, as a vector. Always available — stderr is drained from the moment the child starts, so this is a snapshot of a buffer that is being filled for you, not a read that could block. Returns [] when the child has written nothing.
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 |