Belschak.dev ← All writing
·David Belschak

Headless agent sessions that survive your usage limit


A headless claude -p session that hits your subscription limit does not wait. It exits, and whatever it was doing stops there. If that happens while you are asleep, you find a dead process and a half-finished work package in the morning.

That is the specific problem parallel-streams solves: a small runner loop around claude -p that treats a limit as a pause instead of an ending, plus a working method for splitting a project across several of those loops at once. Waiting is the entire mechanism. The runner sleeps until your window refills on its own; it never touches your credentials or tokens and only launches the official CLI. The runner is three scripts per platform. Most of what follows is about why the surrounding method matters more than the scripts do.

#The runner loop

Each stream is a loop around claude -p --output-format stream-json:

  1. The first iteration sends the stream’s brief, a markdown file you write. The runner captures the session id from the stream output.
  2. Every following iteration runs claude --resume <session-id> with a short “continue, read your own progress file first” prompt.
  3. A non-zero exit code, or a session shorter than 120 seconds, counts as a limit hit. The runner sets state limit-sleep, waits 15 minutes, and tries again.
  4. A normal session end without a done marker resumes immediately, because sessions end on their own long before a large brief is finished.
  5. The loop stops when the stream writes .streams/status/<X>.done, or at the deadline hour, or after 60 iterations.

The heuristic in step 3 is deliberately crude. There is no reliable machine readable signal for “your window is empty”, so the runner treats “died fast” as “died from the limit”. It gets false positives from other fast failures, which is a known failure mode with its own troubleshooting entry: a stream whose brief is broken will also die in under 120 seconds and get patiently retried every 15 minutes all night.

without a runner
  23:40  claude -p building...
  01:55  usage limit hit
  01:55  process exits
  ...    nothing until you wake up

with parallel-streams
  23:40  claude -p building...
  01:55  limit hit (exit 1, 34s)
  01:55  sleeping 15 min, then auto-resume
  03:10  window refills -> fresh session,
         re-reads brief + progress file
  06:20  stream writes its done marker

#Why it starts a fresh session after a pause

The version that resumed the same session after a limit pause was the obvious design, and it was worse. After a pause of an hour or more the prompt cache is cold anyway, so resuming buys nothing on cost, and it does carry along a context that has been growing monotonically for hours. Long contexts make the model worse.

So after a limit pause the runner discards the session id and starts fresh. The next iteration re-reads the brief and the stream’s own progress file, which is why the brief has to enforce progress-file discipline: that file is the external memory that makes throwing away the context safe. Sessions that end normally are still resumed by id, with context intact. --no-context-rotate restores the old always-resume behavior.

This has a side effect worth naming, because it looks like a bug: after a pause, the stream’s log shows a new session id. That is the design, not a lost session.

#Observability, because you are asleep

An unattended loop that you cannot inspect is not something I would run against a real repository. The runner writes state (starting, claude-running, limit-sleep, paused, done, ended), a heartbeat file refreshed on every stream event, and a plain text log you can tail -f. A PID lock prevents two runners from landing on one stream.

The heartbeat exists for one specific failure: a runner whose claude child process freezes blocks in its own pipe and cannot rescue itself. It will sit at claude-running indefinitely, looking healthy by any check that only reads state. An external watcher is what fixes that, by killing and relaunching a runner whose state says claude-running while its heartbeat is older than about 15 minutes. That threshold is a heuristic and it does misfire: one long, silent tool call produces the same signature as a freeze, so the watcher will sometimes restart a stream that was working fine. One ordering detail matters too: check for a hang before trusting the done marker, because a stream can have written its marker and still hang. Relaunching stays reserved for runners that are dead with their work unfinished.

#Contracts first, parallelism second

Streams cannot see each other. That single fact determines the method, and it is where the actual failure risk lives, not in the runner.

Before any stream starts, the orchestrator freezes the contracts in a spec file: an ownership matrix in which every file belongs to exactly one stream. If two streams need the same file, the file moves to the orchestrator and the streams deliver integration snippets instead. Hub files (schema, router or registry, central UI shell, install and deploy scripts) always belong to the orchestrator. Shared changes get implemented up front, before launch, and registries get pre-wired defensively so that a stream only has to add its own module.

Every shared file is a merge accident waiting to happen, and every vague definition of done is a false “finished”. Both are cheap to prevent in the spec and expensive to repair afterwards.

There is also a triage rule for what can go into a stream at all. File-based, stateless, or API-key driven work runs headless exactly as it would interactively. Anything needing an interactive login, an OAuth popup, or a real browser session does not, because nobody is there to click it. That includes visual QA, which stays with you.

#Steering a stream without breaking it

Two intervention paths, both built to leave the auto-resume loop intact:

  • Steer, asynchronous. Write a note into .streams/steer/<X>.md. The runner appends it to the prompt at the start of the next iteration, then archives the file. One-shot delivery, latency up to one iteration.
  • Takeover, interactive. The takeover script pauses the runner cleanly, waiting until no iteration is mid-flight so two instances never end up on one session, then opens that same session interactively via claude --resume. You type along in the full TUI. On exit, the possibly forked session id is handed back and the autopilot continues from your new state.

Tool grants are per stream via allow and deny lists, and deny beats allow. That is the useful direction for an unattended run, with a caveat the README states plainly: --disallowed-tools 'Bash(git push:*),Bash(gh api:*)' blocks those two commands and nothing else. curl, wget and the WebFetch tool still reach the network, so a night where nothing may go online has to name every route it cares about. Another trap from the README: these are prefix matches, so git -C <path> push does not match a git push:* pattern.

#Limits, stated plainly

  • Three streams in parallel is the ceiling. Each stream is a full node process plus its MCP servers plus whatever it builds. More than three pushed a mid-range machine into paging in real runs. Queue extra packages instead.
  • N streams drain your subscription window roughly N times faster. The runner tolerates that by pausing, so what actually grows is wall-clock time.
  • Done markers lie. In real runs, done markers repeatedly appeared at least one iteration before the work actually met its definition of done. A stream that believes it is finished writes the marker, so verification is yours and it is not optional.
  • Sleep is the number one stream killer. The runner deliberately does not manage power settings, since that needs OS-specific daemons and elevated rights. You set it up once before a run, per platform.
  • The POSIX port has never been executed. The PowerShell runner has real mileage on Windows. The sh port is a careful line-by-line translation that passes dash -n, bash --posix -n, and shellcheck at error severity in CI, and has never run on a Mac or a Linux box. Static checks are the honest maximum for code that has not run on its target platform. Read it before you trust it with anything.
  • The deadline is wall-clock based, so across a DST switch the POSIX port’s deadline drifts by the offset.

The most useful contribution right now is a report from someone running the sh port on a real Mac or Linux machine, working or broken. There is an issue template for exactly that.

To state the boundary once more, because the title invites the wrong reading: the runner has no mechanism for evading or manipulating a usage limit. It waits until the window refills, the same thing that happens if you type “continue” the next morning. One account, one session, no extra keys or endpoints. Whether an automated loop fits your plan’s terms is between you and your provider.

Repo: github.com/belschak/parallel-streams.