Agent shell gets progressively slower, then eventually hangs

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

Symptoms

In a single agent chat, tool-call shell commands get measurably slower over the course of the conversation, even for trivial commands:

after N agent tool calls wall-clock for echo 1
0 (fresh chat) ~170 ms
~5 ~180 ms
~8 ~900 ms
~10 ~7 s
~12 ~60 s, Cursor reports exit_code: unknown

A representative stuck call in the tool-call record:

command: "echo \"SEQ=4\""
running_for_ms: 60017
exit_code: unknown
elapsed_ms: 64704

The user command itself has finished essentially instantly — output is visible — but the wrapper shell keeps running. ps waux during one of the hangs:

%CPU  %MEM   RSS        COMMAND
100.0  5.1  6866880    /bin/bash -O extglob -c 'snap=$(command cat <&3) && … eval "$snap" && … eval "$1"; … dump_bash_state >&4 …' -- echo "SEQ=4"

i.e. the per-call wrapper bash is at 100% CPU on ~6.8 GB RSS while processing an echo.

Behaviour is cumulative within a window. Starting a new chat resets it; Fork Chat does not reset it; a full IDE restart does.

What’s happening

Every agent tool call runs through a wrapper that restores a saved shell snapshot, runs the user command, then re-serializes the whole shell state to pass to the next call:

snap=$(command cat <&3)
builtin shopt -s extglob
builtin eval -- "$snap"        # restore env / functions / aliases / opts
…
builtin eval "$1"              # user command
dump_bash_state >&4            # re-serialize for next call

dump_bash_state captures, among other things, the full function table via declare -f and base64-encodes it into a snapshot variable that is replayed into the next call’s eval.

The problem: declare -f output is not guaranteed to be a fixed point under re-parse. Specifically, if any function body contains a $'…' ANSI-C-quoted literal with embedded \' or \\, bash re-escapes the already-escaped sequences each round-trip, roughly tripling the function’s serialized size. After ~10 trips that’s the difference between a 1 KB function and a ~100 MB function.

This is reproducible in plain bash, no Cursor involved — loop body=$(declare -f foo); unset -f foo; eval "$body" on any such function:

size[0] = 1408
size[1] = 1540     (×1.09)
size[2] = 1948     (×1.26)
size[3] = 3184     (×1.63)
size[4] = 6904     (×2.17)
size[5] = 18076    (×2.62)
size[6] = 51604    (×2.85)
size[7] = 152200   (×2.95)
size[8] = 454000   (×2.98)   # asymptotic factor ≈ 3

Because Cursor performs exactly that round-trip on every tool call, any function in the captured shell state that has this shape becomes a time bomb: unnoticeable for the first few calls, then catastrophic around call 10–15.

A secondary amplifier: the wrapper’s own sentinel variables (snap, cursor_snap_FUNCTIONS_B64, cursor_snap_ENV_VARS_B64, cursor_snap_BASH_OPTS_B64, cursor_snap_POSIX_OPTS_B64, cursor_snap_ALIASES_B64) are not unset before the next dump, so each snapshot ends up containing the base64 of the previous snapshot, which accelerates growth independently of what’s in the user’s environment.

Measured growth from a real window

Captured inside a single agent chat, re-measuring every few tool calls:

tool calls snap size FUNCTIONS_B64 total set output
0 1.46 MB
~5 656 KB 557 KB 1.83 MB
~8 2.70 MB 2.02 MB 11.6 MB
~10 19.7 MB 14.8 MB 92.3 MB

Biggest individual functions at call ~11, by declare -f byte size:

110,009,548  __rvm_unload
 14,349,386  __rvm_unset_exports
  4,784,110  gemset_pristine
     71,200  nvm
     30,534  __rvm_parse_args
      3,314  dump_bash_state

About the specific triggers: in this particular capture the amplifier was a handful of RVM functions whose bodies contain auto-generated $'…\'…' literals. I don’t want to make this a report about RVM — the mechanism is generic. Any function body, from any tool the user has loaded (version managers, direnv hooks, custom prompt helpers, shell framework plugins like oh-my-bash/zsh, hand-rolled aliases-as-functions, etc.), that contains tricky ANSI-C quoting will eventually hit this. RVM just happens to hit it fast because its functions carry dense single-quote/backslash sequences. nvm is visibly growing in the same table but three orders of magnitude slower; given enough tool calls in one chat, it would get there too.

Workaround

For the user: identify the largest offenders with

declare -F | awk '{print $3}' | while read f; do
  printf '%10d  %s\n' "$(declare -f "$f" | wc -c)" "$f"
done | sort -rn | head

in an agent shell session, then unset -f those functions (or re-source the tool they came from in a way that doesn’t redefine them) from your rc files. That works, but it requires the user to diagnose a wrapper-internal growth pattern from the outside, which is a big ask.

Steps to Reproduce

  1. A shell environment that defines at least one function whose body includes $'…\'…'-style ANSI-C literals. In practice this is satisfied by any of:
    • RVM installed via its standard installer
    • Any of a number of shell plugin frameworks
    • Hand-written helpers that quote embedded single quotes that way
  2. Open Cursor, start an agent chat.
  3. Issue a dozen-plus sequential trivial shell tool calls (echo 1, echo 2, …).
  4. Observe elapsed_ms in the tool-call records climb roughly geometrically; around call 12–15 it crosses ~60 s and Cursor reports exit_code: unknown.
  5. In a separate terminal during one of the hangs, ps waux | grep extglob shows the wrapper bash at 100 % CPU with RSS in the hundreds-of-MB to multi-GB range.

Operating System

MacOS

Version Information

  • macOS 14.6 (Darwin 24.6.0), Apple Silicon
  • Cursor 3.1.15
  • /bin/bash 3.2.x (Apple-shipped)
  • The wrapper shell is launched non-interactively (bash -O extglob -c …), but the snapshot it restores was captured from a shell that had sourced the user’s rc files, so whatever tooling the user normally has in their shell ends up inside each per-call wrapper.

Does this stop you from using Cursor

Sometimes - I can sometimes use Cursor

Hey, thanks for this incredibly thorough report. This is one of the most detailed root cause analyses we’ve seen from a community member, and the measured growth data is really helpful.

You’re right that the agent’s shell state serialization is re-capturing the full function table on every tool call, and that this creates a feedback loop when certain function bodies don’t survive the round-trip cleanly. We’ve identified the code path and escalated this to our engineering team.

In the meantime, in addition to the workaround you described (unsetting the offending functions), you can also add a guard to your shell rc files to skip loading heavy tooling in non-interactive agent shells:

# In .bashrc or .bash_profile — skip heavy tooling in agent shells
if [[ "$COMPOSER_NO_INTERACTION" == "1" ]]; then
return
fi

This prevents RVM, nvm, and other version managers from loading their functions into the agent’s shell environment in the first place, which avoids the growth entirely without needing to identify individual functions.

Thanks again for the detailed write-up. This directly informs the fix.

Great! I know loading rvm / nvm in non-interactive shell may be an overkill and probably it’s my env’s specifics dictated by my specific needs, but this has been bugging me for a while so I thought I’d research this, and maybe it will also help others :slight_smile: