zsh has a useful feature hiding in plain sight: coproc.

A coprocess is just a process connected to the shell by pipes in both directions. The shell can write to the process’s stdin and read from its stdout. That makes it a natural fit for calculators, interpreters, protocol adapters, local test services, and anything else that should stay warm while a shell script talks to it.

The strange part is not that zsh has coprocesses.

The strange part is that zsh exposes only one active coprocess through the special p redirection target.

coproc bc -l
print -r -- '2 ^ 10' >&p
read -r answer <&p

That works. But if you start another coprocess, p moves. The old process may still exist, the operating system is perfectly capable of handling more pipes, and nothing fundamental has run out. The shell abstraction simply stopped at “the current coprocess.”

That felt too small.

The Trick

The core idea behind co-proc is that p does not need to remain special forever.

Immediately after starting a native zsh coprocess, co-proc duplicates the current p descriptors into ordinary numbered file descriptors:

coproc COMMAND
exec {outfd}<&p
exec {infd}>&p

Those numbered descriptors remain usable even after zsh retargets p for a later coprocess.

Once you have that, the rest is bookkeeping:

  • store outfd, infd, and the child process id,
  • key them by a user-provided name,
  • provide commands to send, read, list, switch, stop, wait, and prune,
  • and clean everything up when the shell exits.

The result is a small sourceable zsh layer for named, simultaneous coprocesses.

source ./co-proc.zsh

co-proc start calc bc -l
co-proc start echo cat

co-proc send calc '2 ^ 16'
co-proc send echo 'hello'

co-proc read -t 1 calc
co-proc read -t 1 echo

co-proc stop calc
co-proc stop echo

This is not a new process model. It is native zsh plus descriptor ownership.

Why Not Replace coproc?

The tempting design was to make a shell function named coproc and extend the syntax directly:

coproc calc bc -l
coproc send calc '2 ^ 10'
coproc read calc

But coproc is not an ordinary command in zsh. It is parsed as a reserved word. That matters because native forms like these are grammar, not function calls:

coproc bc
coproc { cat }
coproc while read -r line; do print -r -- "$line"; done

If co-proc replaced coproc, it would break exactly the native syntax it was trying to build on. That was the wrong trade.

So the project splits the interface in two:

  • co-proc ... is the explicit, scriptable API.
  • coproc ... remains native zsh.
  • Interactive users can opt into a conservative ZLE rewrite for simple extended forms.

With the optional interactive layer enabled:

co-proc enable-zle

coproc calc bc -l
coproc send calc '2 ^ 10'
coproc read calc

The rewrite intentionally avoids complex shell syntax. Lines with redirections, pipes, separators, grouped commands, or control words are left alone. Native zsh remains native.

That constraint is the point. Compatibility is a feature.

Where It Helps

Named coprocesses are most useful when startup cost, state, or protocol context matters.

For a tiny example, bc -l becomes a persistent calculator:

co-proc start calc bc -l

co-proc send calc 'scale=4; 22 / 7'
co-proc read -t 1 calc

co-proc stop calc

A Python snippet can become a small line-oriented service:

co-proc start upper python3 -u -c '
import sys

for line in sys.stdin:
    print(line.rstrip().upper(), flush=True)
'

co-proc send upper 'hello'
co-proc read -t 1 upper
co-proc stop upper

The same pattern works for database shells, local protocol adapters, model or agent servers, and test fixtures. Start the thing once, keep it alive, and talk to it by name.

For structured protocols, the right move is usually to wrap co-proc send and co-proc read in small domain-specific helper functions so callers do not have to think about raw line ordering.

What I Like About This Solution

The satisfying part of co-proc is that it does not require zsh to grow a new primitive.

The primitive was already there.

The missing piece was ownership. zsh owns one moving p handle. co-proc captures the underlying descriptors before they move, gives them names, and tracks their lifecycle.

That makes the feature feel larger without making the shell less itself.

There is a broader lesson here that shows up often in systems work: sometimes a feature is not missing because it is impossible. Sometimes the available abstraction stopped one layer too early.