anomalyco / anomalyco/opencode

bash tool describes itself as a persistent shell session but every call is a fresh shell

Open
#43,003 0 comments 0 reactions 1 assignee View on GitHub

@neriousy is already working on this.

Since Aug 17, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Description

Summary

The bash tool's description tells the model it executes commands "in a persistent shell session". Nothing persists between calls: working directory, exported environment variables, and $? are all reset. Because the tool also omits the command's exit code from its output, a model that follows the documented mental model and checks $? in a follow-up call receives a misleading 0 and reports a failed command as successful.

The problem is not that state is per-call, which is a reasonable design. It is that the description promises the opposite, and the only status channel the description implies is therefore silently wrong.

Version

  • Reproduced on 1.18.16 and 1.18.18 (latest release at time of filing)
  • git diff v1.18.16 v1.18.18 -- packages/opencode/src/tool/shell.ts is empty
  • The relevant code is unchanged on dev
  • Linux x64

Reproduction

Ask for two separate bash tool calls:

Call 1: cd /tmp && export TESTVAR=persisted && false
Call 2: pwd; echo "TESTVAR=[$TESTVAR]"; echo "LASTEXIT=[$?]"

Actual

call 1  cmd : cd /tmp && export TESTVAR=persisted && false
        out : "(no output)"
        metadata.exit : 1

call 2  cmd : pwd; echo "TESTVAR=[$TESTVAR]"; echo "LASTEXIT=[$?]"
        out : /home/user/workspace
              TESTVAR=[]
              LASTEXIT=[0]

Three separate contradictions of "persistent shell session" in one run:

state set in call 1 observed in call 2
working directory /tmp workspace path
exported variable persisted empty
exit status 1 0

Expected

Either the session is persistent as described, or the description states that each invocation runs in its own shell so the model does not rely on carried-over state.

Why this matters: the model is led into a wrong conclusion

The exit code is captured by the runtime but is not part of the model-visible tool output:

command       : ./check.sh          # #!/bin/sh + exit 3
status        : completed
output        : "(no output)"
metadata.exit : 3

status: completed is fine; it reports that the tool ran the command. The issue is that output, the only content delivered to the model, carries no completion status, and the documented alternative for obtaining it does not work.

Worked example. A setup script writes one artifact, then aborts on a failed precondition:

#!/bin/sh
set -e
echo "STEP1_DONE" > step1.txt
grep -q "REQUIRED_MARKER" config.txt   # config.txt lacks it -> exit 1
echo "STEP2_DONE" > step2.txt          # never runs

The command runs to completion. The task does not: step1.txt exists, step2.txt does not, and nothing is printed. Asked to run it and report whether it completed, one run produced:

bash  ./setup.sh   -> "(no output)"
bash  echo $?      -> "0"
answer: "The script completed successfully. Exit status: 0.
         Stdout/stderr: no output was produced (empty)."

step2.txt was verifiably absent. The model did not skip verification; it attempted exactly the check the tool description implies is available, and the platform returned a value describing a different shell.

In the same 8-run sample, the runs that reached the correct answer did so by reading setup.sh and config.txt and reasoning about what grep -q and set -e would do, not by observing the failure. A capable model can often route around the missing signal, which reduces how often this surfaces but does not remove the mismatch.

An earlier probe that asked an agent to run a script exiting 2 and report the exit code produced a confidently false success claim in 19 of 29 runs across 1.18.16 and 1.18.18.

Root cause

Description, packages/opencode/src/tool/shell/prompt.ts:259:

intro:
  "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.",

Execution, packages/opencode/src/tool/shell.ts:484, spawns a new child process for every invocation, with cwd and env supplied per call:

const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))

There is no cross-call shell reuse anywhere in the file, so no shell state can survive between calls.

Separately, shell.ts around lines 561-594 builds the model-visible output and the structured metadata independently:

const meta: string[] = []
if (expired) meta.push(`shell tool terminated command after exceeding timeout ...`)
if (aborted) meta.push("User aborted the command")
...
return {
  metadata: { output: last || preview(output), exit: code, truncated: cut },
  output,   // what the model receives
}

meta[] receives entries only for timeout and user abort, so the exit code reaches metadata.exit and never reaches output. Grepping shell.ts and shell/prompt.ts for exited with code returns nothing.

Suggested fix

Either direction resolves the mismatch. The second is much cheaper.

  1. Make the session genuinely persistent across calls, so cd, export, and $? behave as described.

  2. Correct the description to state that each invocation runs in its own shell, and surface the completion status in the model-visible output. The second half is required if this option is chosen: once the model is told not to rely on $?, it needs some in-band way to observe failure.

Plugins

No response

OpenCode version

No response

Steps to reproduce

No response

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.