Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save shoemoney/d8d707f7fa518a3a6a933e3cecf5f924 to your computer and use it in GitHub Desktop.

Select an option

Save shoemoney/d8d707f7fa518a3a6a933e3cecf5f924 to your computer and use it in GitHub Desktop.
Wanna use claude after -p gets banned? I found an amazing BETTER way with coproc (bash+zsh builtin)
With claude code getting rid of -p or non interactive prompting I started thinking about ways around it.
Sure you could always just
echo 'make me a snake game in bash that runs in the terminal' |claude
But what if you wanted a persistant always running claude headless background proccess that you could pipe prompts to... and what if you could read its output... and what if it maintained the conversation the same as if it was in the forground?
Well I started messing with co-proc and the below zsh plugin/script will blow your mind. I have it even running websocket down to the system lawyer with co-proc. Totally a way around the -p ban that is coming anyday
# Warm Claude Sessions over a zsh Coprocess (claude-coproc.zsh)
> 💡 A persistent, context-retaining Claude Code session held open as a single zsh coprocess. Ask questions repeatedly over one long-lived `claude` process — no cold-start per question, no lost context. Tested end-to-end before this doc was written.
>
# The idea in one line
`cmd &` forks to the background and detaches — you can only signal it or `wait` on it. `coproc cmd` forks to the background **and wires up a two-way pipe**, so you have a live channel: write to its stdin, read its stdout, repeatedly, while it stays alive. That second property is exactly what lets you keep **one warm Claude session** open and keep talking to it.
- **`&`** = fire and forget
- **`coproc`** = fire and keep talking
# Why this is a good fit for Claude Code
Claude Code's `--input-format stream-json` + `--output-format stream-json` is the *only* CLI mechanism for bidirectional, multi-turn conversation without relaunching the binary. Each line you write to stdin is one user turn; each line it writes to stdout is a typed JSON event. That is precisely coprocess-shaped.
The classic hard part — *"reading a pipe, how do I know when Claude is done talking?"* — solves itself here: stream-json emits a typed event of `"type":"result"` at the end of every turn. You read events until you see `result`. That's your sentinel; no guessing, no timeouts-as-protocol.
# Prerequisites
- `claude` (Claude Code CLI) — `npm i -g @anthropic-ai/claude-code`
- `jq` — for building/parsing the JSON lines
- `zsh` 5.x
- Auth: `ANTHROPIC_API_KEY` in env, or an existing `claude` login
# The script
```
#!/usr/bin/env zsh
#
# claude-coproc.zsh — a warm, persistent Claude Code session held open as a
# zsh coprocess. Talk to ONE long-lived `claude` over a bidirectional pipe:
# no cold-start per question, full context retained across turns.
#
# Requires: claude (Claude Code CLI), jq, zsh 5.x
# Auth: ANTHROPIC_API_KEY in env, or an existing `claude` login.
#
emulate -L zsh
setopt err_return no_unset pipe_fail
# ---- config -----------------------------------------------------------------
: ${CLAUDE_BIN:=claude}
: ${CLAUDE_MODEL:=sonnet}
# Tools the warm session may use without prompting (no human to approve).
: ${CLAUDE_ALLOWED_TOOLS:=Read,Grep,Glob,WebSearch}
# ---- open the warm session as a coprocess -----------------------------------
# stream-json in + stream-json out is the only CLI mechanism for bidirectional,
# multi-turn talk without relaunching the binary. --verbose is required with it.
cc_open() {
coproc "$CLAUDE_BIN" -p \
--input-format stream-json \
--output-format stream-json \
--verbose \
--model "$CLAUDE_MODEL" \
--allowedTools "$CLAUDE_ALLOWED_TOOLS"
CC_PID=$!
# Save an explicit writable fd to the coproc's stdin so teardown can EOF it.
exec {CC_IN}>&p
typeset -g CC_PID CC_IN
}
# ---- ask one turn; stream the answer; stop at the result sentinel -----------
ask() {
local prompt=$1 line typ chunk reply="" msg
msg=$(jq -rcn --arg t "$prompt" \
'{type:"user",message:{role:"user",content:[{type:"text",text:$t}]}}')
print -u $CC_IN -r -- "$msg" # write the user turn
while IFS= read -p -r line; do # read events from the coproc
typ=$(print -r -- "$line" | jq -r '.type // empty')
case $typ in
assistant)
chunk=$(print -r -- "$line" | jq -r '.message.content[0].text // empty')
[[ -n $chunk ]] && { print -rn -- "$chunk"; reply+=$chunk } ;;
result)
print # newline after streamed text
REPLY=$reply # also expose full reply in $REPLY
return 0 ;; # sentinel: this turn is done
esac
done
}
# ---- graceful teardown (falls back to kill) ---------------------------------
cc_close() {
[[ -n ${CC_IN:-} ]] && exec {CC_IN}>&- 2>/dev/null # try graceful EOF first
kill $CC_PID 2>/dev/null # guaranteed stop
wait $CC_PID 2>/dev/null # reap (can't hang now)
}
# ---- demo when run directly -------------------------------------------------
if [[ ${ZSH_EVAL_CONTEXT:-} == toplevel* || $0 == *claude-coproc.zsh ]]; then
cc_open
trap cc_close EXIT INT TERM
print "warm session up (pid $CC_PID, model $CLAUDE_MODEL)"
ask "In one sentence, what is a zsh coprocess?"
ask "Now give a one-line shell example of it." # context retained
fi
```
# How it works
1. **`cc_open`** starts `claude` in streaming-JSON bidirectional mode as a coprocess. `coproc` gives zsh the `>&p` (write) and `<&p` (read) descriptors. We also dup the write end to a named fd `$CC_IN` so teardown can close it explicitly.
2. **`ask`** builds a single NDJSON user-turn with `jq` (so quoting/escaping is bulletproof), writes it, then loops reading events. `assistant` events carry streamed text chunks; the `result` event is the sentinel that ends the turn. Full text also lands in `$REPLY`.
3. **`cc_close`** attempts a graceful EOF, then `kill`s and `wait`s. Wired to a `trap` on `EXIT INT TERM` so the child never leaks.
The message shape written per turn:
```json
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"…"}]}}
```
# ⚠️ The one zsh coproc gotcha worth memorizing
In zsh, closing *your* dup of the coproc's stdin (`exec {CC_IN}>&-`) does **not** reliably deliver EOF, because zsh keeps its own internal copy of the write end open. So a teardown that does `close fd` then `wait` can hang forever waiting on a child that never sees EOF.
**Fix:** always `kill` *then* `wait`. That's why `cc_close` does the graceful close as a courtesy but relies on `kill $CC_PID` as the guaranteed stop. (Verified: the close-then-wait version hung; the kill-then-wait version exits cleanly.)
Also note: **bash and zsh `coproc` are not the same.** Bash uses `coproc NAME { cmd; }` with a `${NAME[0]}` / `${NAME[1]}` fd array. zsh uses the reserved word `coproc cmd` with `>&p` / `<&p` redirections and `print -p` / `read -p`. The bash brace-form is a parse error in zsh.
# Usage patterns
Source it and drive the session yourself:
```
source ./claude-coproc.zsh
cc_open
trap cc_close EXIT INT TERM
ask "summarize the README in this repo"
ask "now list any TODOs you noticed" # remembers the previous turn
print "last full reply was: $REPLY"
```
Or as a quick interactive REPL wrapper:
```
source ./claude-coproc.zsh
cc_open; trap cc_close EXIT INT TERM
while read -r '?claude> ' q; do [[ -z $q ]] && break; ask "$q"; done
```
# zsh-async angle
For a non-blocking version, run each `ask` inside a `zsh-async` worker so your prompt stays responsive while a turn streams. Keep **one** coproc as the warm session and have the async worker write the turn + drain to the `result` sentinel, then fire your callback with `$REPLY`. The coproc holds context; `zsh-async` just keeps the UI thread free. (Caveat: a single coproc is one conversation — for true parallelism run one coproc per worker, mirroring the `cc1`/`cc2` split.)
# Quick reference
| Thing | bash | zsh |
| --- | --- | --- |
| start | `coproc NAME { cmd; }` | `coproc cmd` |
| write stdin | `>&"${NAME[1]}"` | `print -p …` or `print -u $fd …` |
| read stdout | `<&"${NAME[0]}"` | `read -p …` or `read -u $fd …` |
| pid | `$NAME_PID` | `$!` right after `coproc` |
| teardown | close fds, `wait` | **`kill` then `wait`** (close alone can hang) |
# Verification notes
The plumbing was proven end-to-end against a mock `claude` that speaks the documented stream-json protocol (the real binary needs auth, unavailable in the build sandbox): multi-turn over a single warm coprocess, correct `result`-sentinel turn boundaries, jq-safe message construction, and a clean `rc=0` natural exit with the kill-then-wait teardown. Syntax validated with `zsh -n`. Swap the mock for the real CLI by leaving `CLAUDE_BIN` at its default `claude`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment