anomalyco / anomalyco/opencode
bash tool describes itself as a persistent shell session but every call is a fresh shell
@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.16and1.18.18(latest release at time of filing) git diff v1.18.16 v1.18.18 -- packages/opencode/src/tool/shell.tsis 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.
-
Make the session genuinely persistent across calls, so
cd,export, and$?behave as described. -
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.