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 docs
workdir: /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
Summary
I observed an intermittent issue where the
bash/shell tool is called with a validworkdir, 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-hubThe next command using an absolute path succeeds, which makes this look like
workdirdid not take effect. However, after reproducing againstShellTooldirectly, 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 therunfunction.Current shape:
The output reader is forked with
forkScoped, but the main flow only waits forhandle.exitCode. When the scoped block ends, the output-reading fiber can be interrupted before it has appended output intolist.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
workdirresolution bugWhen 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. Ifworkdirwere wrong anddocsdid not exist, the command should exit non-zero and include anlserror. Instead, the command exits successfully but output is missing.Reproduction Script
Run from
packages/opencodeafter dependencies are installed.Create
tmp-shelltool-repro.ts:Then run fresh-process attempts:
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:
A deterministic regression test could use a fake
ChildProcessSpawnerwhereexitCoderesolves immediately buthandle.allemits 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