Repository navigation
Description
Summary
I observed an intermittent issue where the bash/shell tool is called with a valid workdir, runs a command that should produce output, but returns (no output) even though the command appears to have succeeded.
In the attached screenshot/log, the tool call uses:
command:ls -la docsworkdir:/home/flintylemming/Projects/leycode-hub
The next command using an absolute path succeeds, which makes this look like workdir did not take effect. However, after reproducing against ShellTool directly, the more likely issue is an output collection race: the process exits successfully before the output reader has drained stdout/stderr.
Suspected Code Location
The race appears to be in packages/opencode/src/tool/shell.ts, inside the run function.
Current shape:
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
// append chunk into list/full/metadata
}),
)
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])The output reader is forked with forkScoped, but the main flow only waits for handle.exitCode. When the scoped block ends, the output-reading fiber can be interrupted before it has appended output into list.
That can produce:
{
"exit": 0,
"output": "(no output)"
}even though the command actually succeeded and should have emitted stdout.
Why this is likely not a workdir resolution bug
When reproduced directly against ShellTool.execute, I hit this result:
{
"iterations": 1,
"command": "ls -la docs",
"emptyCount": 1,
"failureCount": 0,
"firstEmpty": {
"index": 1,
"exit": 0,
"output": "(no output)"
}
}The important detail is exit: 0. If workdir were wrong and docs did not exist, the command should exit non-zero and include an ls error. Instead, the command exits successfully but output is missing.
Reproduction Script
Run from packages/opencode after dependencies are installed.
Create tmp-shelltool-repro.ts:
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Layer } from "effect"
import { Shell } from "@opencode-ai/core/shell"
import { Config } from "@/config/config"
import { Agent } from "@/agent/agent"
import { MessageID, SessionID } from "@/session/schema"
import { Plugin } from "@/plugin"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ShellTool } from "@/tool/shell"
import { Truncate } from "@/tool/truncate"
import { provideInstance, testInstanceStoreLayer } from "./test/fixture/fixture"
const workdir = "/Users/flintylemming/Projects/leycode-hub"
const iterations = Number(process.argv[2] ?? 1)
const command = process.argv[3] ?? "ls -la docs"
const shellLayer = Layer.mergeAll(
LayerNode.compile(
LayerNode.group([
CrossSpawnSpawner.node,
FSUtil.node,
Plugin.node,
Truncate.node,
Config.node,
Agent.node,
RuntimeFlags.node,
]),
),
testInstanceStoreLayer,
)
const ctx = {
sessionID: SessionID.make("ses_repro"),
messageID: MessageID.make("msg_repro"),
callID: "call_repro",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const program = Effect.gen(function* () {
Shell.acceptable.reset()
const info = yield* ShellTool
const shell = yield* info.init()
const results = yield* Effect.forEach(
Array.from({ length: iterations }, (_, index) => index + 1),
(index) =>
shell.execute({ command, workdir }, ctx).pipe(
Effect.map((result) => ({
index,
exit: result.metadata.exit,
output: result.output,
})),
),
{ concurrency: 1 },
)
return results
}).pipe(provideInstance(workdir), Effect.provide(shellLayer))
const results = await Effect.runPromise(program)
const empty = results.filter((result) => result.exit === 0 && result.output === "(no output)")
const failures = results.filter((result) => result.exit !== 0)
console.log(JSON.stringify({ iterations, command, emptyCount: empty.length, failureCount: failures.length, firstEmpty: empty[0], firstResult: results[0] }, null, 2))Then run fresh-process attempts:
for i in {1..50}; do
out=$(bun tmp-shelltool-repro.ts 1 2>/dev/null)
if printf '%s' "$out" | rg -q '"emptyCount": 1'; then
echo "hit iteration $i"
printf '%s\n' "$out"
exit 0
fi
done
echo "no hit"This is intermittent. In one run, it reproduced on fresh process iteration 8.
Suggested Fix Direction
Do not fire-and-forget the output reader. Store the fiber returned by Effect.forkScoped(...) and wait for it before leaving the scoped block, or restructure the code so process exit and output drain are awaited together.
For example:
const outputFiber = yield* Effect.forkScoped(readOutput)
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
if (exit.kind === "abort" || exit.kind === "timeout") {
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
yield* Fiber.join(outputFiber)A deterministic regression test could use a fake ChildProcessSpawner where exitCode resolves immediately but handle.all emits output after a short delay. The current code should return (no output); the fixed code should return the delayed output.
Plugins
Superpowers
OpenCode version
1.17.10
Activity
github-actions commentedon Jul 6, 2026
This issue might be a duplicate of existing issues. Please check:
ShellToolreturns(no output)with exit 0 due to a race condition where the output-reader fiber is forked but not joined before the main fiber reads the result. That issue also identifies the fix as joining the output fiber before computing the final result.github-actions commentedon Sep 5, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.