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.
Warm, persistent Claude Code sessions over a zsh coprocess — measured: same tokens and cost as repeated `claude -p --resume`, but ~37% faster wall clock
Warm, persistent Claude Code sessions over a zsh coprocess — measured: same tokens and cost as repeated `claude -p --resume`, but ~37% faster wall clock
On June 15 2026, Anthropic moved headless `claude -p` (with the Agent SDK and Claude Code GitHub Actions) out of the subscription pool onto a separate metered credit pool billed at API rates — Pro $20/mo, Max 5x $100, Max 20x $200, no rollover. The flag still works exactly as documented; it just stopped being subsidized.
That made me want to know what a headless turn actually costs, and whether holding one session open is cheaper than launching `claude -p` per question.
**I measured it. It is not cheaper — and I was wrong to assume it would be.**
Five sequential context-dependent turns, MCP disabled, Sonnet. Arm A launches a fresh `claude -p` per question and keeps context with `--resume`. Arm B feeds all five turns to one warm `stream-json` session, which is what the coprocess below does.
My first version of this benchmark was wrong and I am leaving that in on purpose. It ran the two arms back to back — but they share a server-side prompt cache, so whichever arm ran second read a cache the first arm had just paid to create. That alone made the second arm look ~68% cheaper. Redone with each arm handed a unique `--append-system-prompt` nonce, forcing it to create its own cache, and with arm order alternated across trials. Five isolated arm-pairs:
```
A: cold per call B: warm session delta
cost (mean) $0.260373 $0.260391 +0.0%
cost (range) $0.260348-260398 $0.260384-260398
cache_creation 31,221 31,228 +7
cache_read 242,507 242,535 +28
wall seconds 21.3 - 23.3 12.7 - 16.2 ~ -37%
```
Cost is identical — in the closest pair, to all six decimal places, and the ordering made no difference. `--resume` reuses the prompt cache exactly as well as a live session does, so there is no token arbitrage here. Anyone claiming a warm session is cheaper (I nearly did, twice) should isolate the cache before believing their own numbers.
What you actually get is roughly **a third off wall-clock time** — measured between 33% and 41% faster across runs — because you skip process spawn, config load and session rehydration on every question. Plus you stop juggling session IDs.
So this is a latency and ergonomics tool, not a billing one. That is still worth having if you are driving Claude from a shell loop.
Sure, you can always just:
```
echo 'make me a snake game in bash that runs in the terminal' | claude
```
But what if you wanted a persistent, always-running Claude headless background process that you could pipe prompts to... and read its output... and have it maintain the conversation exactly as if it were in the foreground?
That's `coproc`, and the zsh script below does it with shell builtins and `jq` — no wrapper, no daemon, no SDK. I've also driven it over a websocket down to the system layer the same way.
**Note:** this is zsh, not bash, despite the filename. `print -p` / `read -p` are zsh coprocess syntax; bash exposes its coproc fds as `${COPROC[0]}` / `${COPROC[1]}` and needs a different spelling.
**Benchmark method:** both arms ran the same five context-dependent questions (each answer depends on the previous) and both returned identical answers every time, confirming context was retained in both. Numbers come from the `result` event's own `usage` fields.
Two gotchas if you measure this yourself — both cost me a wrong answer first:
1. **`total_cost_usd` means different things in different output modes.** Under `--output-format json` it is **per-turn**. Under `--output-format stream-json` it is **cumulative for the session**. Same field name, same `result` event type. Observed on 5/5 isolated runs — the json arm reported `[0.1926, 0.0169, 0.0169, 0.0169, 0.0169]` while the stream-json arm reported `[0.0164, 0.0329, 0.0493, 0.0658, 0.0823]`, perfectly monotonic at about +0.01645 a turn. Sum the stream-json values and you roughly quadruple your total; take only the last of the json values and you undercount by ~4x. Safest to detect it from monotonicity rather than assume either way.
2. **The prompt cache is shared across processes.** Two arms run back to back without a cache-busting nonce will mostly measure the order you ran them in, not the thing you think you are measuring.
# 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