Back to blog
·by Patrick Hofmann

How Do I Know When Bash Is Done?

When you build a shell wrapper that controls a persistent bash across multiple commands, you stumble into a surprisingly deep question: when is bash done with the current command? A small deep dive into prompt markers, PROMPT_COMMAND, and why the answer isn't to read PS1 — but to override it.

Systems ProgrammingShellBashTechnical Deep DiveOpenApe

Yesterday I wrote here about ape-shell — a shell wrapper that routes commands through a grant system before they're executed. The original version was a pure one-shot mode: you pass in a command, ape-shell gets a grant, bash executes, done. Every command got a fresh bash instance.

That works for $SHELL -c patterns. It doesn't work for interactive sessions. And the reason is a question that looks trivial at first glance and then turns out to be surprisingly deep:

How do I know when bash is done?

This article is the answer. No ape-shell pitch, no grant system discussion — just a concrete systems programming problem and how to solve it. If you've ever tried to build a shell wrapper that controls a persistent bash across multiple commands, you've probably asked the same question.

The Problem

The naive version of a shell wrapper is simple:

spawn bash
write command
read output
kill bash

This works but is useless for many use cases. Because if every command gets a new bash instance, shell state is lost between commands:

  • cd /foo in the first command → in the second command you're back in the old directory
  • export FOO=bar → in the next command $FOO is empty
  • alias ll='ls -la' → gone
  • Shell functions → gone
  • Loaded .bashrc configuration → read, then buried along with the bash instance

For a wrapper that's supposed to feel like a real shell, this is a dead end. You need one bash that stays alive, and you push commands into it.

Then the question becomes: when is bash done with the current command and ready for the next one?

Naive Approaches and Why They Break

Approach 1: Just Wait a Bit

write command
sleep 500ms
read whatever accumulated

Breaks immediately. What happens with find / -name '*.log'? That runs for minutes. What happens with yes | head -n 1000000? That spews megabytes in milliseconds and will certainly extend beyond 500ms. Timeouts are not an answer to a question about semantics.

Approach 2: Wait for Newlines

"If no new newline came for 200ms, bash is done." Also wrong. tail -f log.txt sends lines occasionally, then pauses, then lines again. Newline-based heuristics produce flaky results that look different on every tenth invocation.

Approach 3: Wait for the Prompt

Bash shows a prompt after every command. If you see the prompt, bash is done. Logical, right?

Only: which prompt? The user's PS1 is freely configurable. Mine looks roughly like this:

patrick@mbp ~/code/openape (main *) $ 

With ANSI colors, with git branch info, with a dirty-state marker, with Unicode decoration. Sometimes multiline. Sometimes with a newline before it. A parser that wants to recognize arbitrary user PS1 is doomed to fail.

Approach 4: Parse PS1

"Then let's parse PS1 from ~/.bashrc!" Invalid. PS1 is assembled from environment variables, functions, git hooks, virtualenv wrappers, async status providers, and ten other sources. Statically parsing .bashrc only sees a fraction of that. And even if you had the complete definition — what bash draws at the terminal is the result of expansion, not the source form.

The Core Insight

Bash doesn't tell you "I'm done." The prompt is the only signal, and it's not reliably readable by default.

So the right answer is not to read PS1 — it's to override PS1.

The Trick: Your Own Marker

If bash won't give you a reliable "done" indicator, give it one. Override PS1 with a sentinel sequence you've defined yourself, and scan the PTY output for it. When you see the marker, you know bash has finished the last command and is waiting for the next one.

This sounds trivial, but it's the moment where the architecture clicks: bash doesn't need to understand that it's running inside a wrapper. You only change how it communicates "done."

The idea in three steps:

  1. Generate a marker that can't accidentally appear in user output.
  2. Inject it as PS1 at the start of the bash session.
  3. Scan the PTY stream for the marker. When you see it, everything before it was command output, and bash is ready for the next line.

The Details That Actually Matter

Random Marker

If you use "PROMPT>" as a marker and the user types echo "PROMPT>", you're confused. If you use "___END___", there's probably a log file somewhere in the world that contains that string.

Solution: 16 bytes of crypto-random as hex. 32 hex characters, 2^128 possible values. Collision-resistant in any realistic world. In ape-shell it looks like this:

import { randomBytes } from 'node:crypto'

this.marker = randomBytes(16).toString('hex')

With some structure around it so the regex gets a clear anchor:

__APES_<32-hex-chars>__:<exit-code>:__END__

The __APES_ prefix makes it human-readable when debugging. The :__END__ suffix gives the regex an unambiguous terminator. And the <exit-code> in the middle is the trick for bonus feature number one: you get the exit code of the last command together with the done signal, in a single pattern-match operation.

PROMPT_COMMAND, Not Just PS1

This is where almost every first implementation introduces a bug. It's not enough to set PS1 once at startup. Because:

The user's .bashrc is read after your start. If it contains PS1='...' — and that's the rule, not the exception — your carefully set marker PS1 gets overwritten. The user isn't at fault, but your wrapper breaks.

The solution is a bash variable most people don't know: PROMPT_COMMAND. This is a shell command that bash executes before every prompt rendering. If you set PS1 there, you override all of the user's .bashrc configurations before the next prompt is drawn:

this.term = pty.spawn('bash', ['--login', '-i'], {
  name: 'xterm-256color',
  cols,
  rows,
  cwd: options.cwd ?? process.cwd(),
  env: {
    ...process.env,
    // Force our marker PS1 on every prompt — survives .bashrc overrides.
    PROMPT_COMMAND: `PS1='__APES_${this.marker}__:$?:__END__'`,
    // Also set it initially so the very first prompt carries the marker.
    PS1: `__APES_${this.marker}__:$?:__END__`,
    PS2: '> ',
    BASH_SILENCE_DEPRECATION_WARNING: '1',
  },
})

Three details that aren't obvious:

  • --login -i: you want the user's rcfiles to be read, otherwise aliases, functions, and environment the user expects are missing. The trade-off is exactly why you need the PROMPT_COMMAND trick.
  • PS2='> ': this is the secondary prompt bash uses when a command spans multiple lines (unclosed quote, continued pipe, if block). You set it to something simple so you can recognize it during multi-line handling.
  • BASH_SILENCE_DEPRECATION_WARNING=1: on macOS, the system bash prints a deprecation warning to stderr on every start. It pollutes your output stream. Away with it.

The Regex

With the marker in the output stream, you can build a regex that matches it and extracts the exit code:

this.markerRegex = new RegExp(
  `__APES_${this.marker}__:(-?\\d+):__END__\\r?\\n?`,
)

The \\r?\\n? at the end is a subtle but important point: depending on how bash renders the prompt (on a fresh line or directly after the last output), a newline may or may not follow. The regex tolerates both cases. The group (-?\\d+) captures the exit code, including negative values for signals like 130 or unusual conventions.

The Output Parser

Every PTY chunk that comes in gets appended to a pending buffer and scanned for the marker. When the marker is found, everything before it is the output of the just-finished command:

private handleData(chunk: string): void {
  this.pending += chunk

  for (;;) {
    const match = this.pending.match(this.markerRegex)
    if (!match || match.index === undefined) break

    const before = this.pending.slice(0, match.index)
    const exitCode = Number(match[1])

    // Everything before the marker is command output.
    if (before.length > 0) {
      this.currentLineBuffer += before
      this.events.onOutput(before)
    }

    // Remove marker and everything before it from the buffer.
    this.pending = this.pending.slice(match.index + match[0].length)

    // Command is done — frame to the consumer.
    const frame = { output: this.currentLineBuffer, exitCode }
    this.currentLineBuffer = ''
    this.events.onLineDone(frame)
  }

  // What remains in `pending` is either partial output
  // or a started marker that continues in the next chunk.
}

The subtle point is handling partial markers. A PTY chunk can end in the middle of the marker — bash wrote the beginning, the rest arrives with the next data event. If you advance the pending buffer in between (for example, trimming to the last newline), you destroy the partial marker and detection fails. The solution: keep all unmatched bytes in the pending buffer until either the marker arrives completely or the stream ends.

The Bootstrap Phase

One last detail that ruins a first run: when you start bash, ~/.bashrc is loaded first. This often produces output — MOTDs, shell init messages, nvm status prints, all sorts of things. The first marker you see is not the end of a user command. It's the end of the startup process.

This means: two phases in the state.

  • Phase 1 — Bootstrap: wait for the first marker. Discard everything that came before it (startup noise). Signal to the consumer "bash is ready."
  • Phase 2 — Normal: every subsequent marker is the end of a user command. Frames go via onLineDone to the consumer.

In ape-shell this is a single boolean called readyForFirstLine:

if (!this.readyForFirstLine) {
  // Bootstrap prompt: discard startup noise, signal ready.
  // onLineDone deliberately does NOT fire here — that would be
  // a fake frame from the consumer's perspective.
  this.readyForFirstLine = true
  this.currentLineBuffer = ''
  const resolve = this.awaitingInitialPrompt
  this.awaitingInitialPrompt = null
  if (resolve) resolve()
  continue
}

// Real command end: deliver frame.
const frame = { output: this.currentLineBuffer, exitCode }
this.currentLineBuffer = ''
this.events.onLineDone(frame)

Without this separation, the consumer gets a broken frame at startup with all the rcfile noise as "output" and an arbitrary exit code. This is the kind of bug you notice at the fifth user, and then the cause is hard to find.

One Application: ape-shell's PtyBridge

I use this pattern in ape-shell, a grant-secured shell wrapper I'm building for AI agent workflows. The concrete implementation lives in packages/apes/src/shell/pty-bridge.ts — a bit more than 200 lines of TypeScript that cover the complete cycle: spawn, bootstrap, line detection, streaming output, exit handling.

In ape-shell, between user input and bash there's a grant check. The PtyBridge itself knows nothing about it — it only cares about the clean abstraction "bash is done with this line, here's the output and the exit code." The grant layer above decides whether a line even reaches writeLine. The separation of concerns is one reason the pattern feels so natural: marker detection is a universal problem, grant logic is specific.

Closing

The pattern isn't new. Terminal emulators, REPL orchestrators, shell testing frameworks — they've all solved some variant of this for decades. expect, pexpect, bash-it's test suite, IPython kernel, Jupyter frontends: they all have a marker trick somewhere. But it's rarely described explicitly. Most developers building a shell wrapper stumble onto the solution themselves, sometimes only after the third naive implementation with timeouts and newline heuristics.

If you ever build a tool that needs to control a persistent shell (or any other REPL with a prompt-based "ready" signal): this is probably the pattern you're looking for. Boring infrastructure in its best sense — invisible when it works, critical when it's missing.