Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shell-output-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Fix shell tool occasionally returning "(no output)" for fast-exiting commands
6 changes: 4 additions & 2 deletions packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type * as Arr from "effect/Array"
import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
import * as NodePath from "@effect/platform-node/NodePath"
import { prepareCommand as prepareSandbox } from "@kilocode/sandbox" // kilocode_change
import { tap as tapStdio, tapped } from "./kilocode/stdio-tap" // kilocode_change - Bun drops buffered stdio on close
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
Expand Down Expand Up @@ -246,13 +247,13 @@ export const make = Effect.gen(function* () {
) => {
let stdout = proc.stdout
? NodeStream.fromReadable({
evaluate: () => proc.stdout!,
evaluate: () => tapped(proc, "stdout"), // kilocode_change - read the spawn-time tap
onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command),
})
: Stream.empty
let stderr = proc.stderr
? NodeStream.fromReadable({
evaluate: () => proc.stderr!,
evaluate: () => tapped(proc, "stderr"), // kilocode_change - read the spawn-time tap
onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command),
})
: Stream.empty
Expand All @@ -267,6 +268,7 @@ export const make = Effect.gen(function* () {
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
tapStdio(proc) // kilocode_change - must run in the same tick as spawn
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
proc.on("error", (err) => {
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/kilocode/stdio-tap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type * as NodeChildProcess from "node:child_process"
import { PassThrough, type Readable } from "node:stream"

// Bun's child_process drops buffered stdio data once the child emits "close", so
// stream readers that attach lazily (a tick or more after spawn) lose the output of
// fast-exiting processes entirely. To retain it, stdout/stderr are piped into
// PassThroughs synchronously at spawn time, before yielding to the event loop.
// PassThrough backpressure (default highWaterMark) keeps unconsumed output bounded.

const map = new WeakMap<NodeChildProcess.ChildProcess, { stdout: PassThrough | null; stderr: PassThrough | null }>()

const wrap = (src: Readable | null) => {
if (!src) return null
const out = new PassThrough()
// A destroy(err) before the lazy consumer attaches would otherwise emit an
// unhandled "error" event and crash the process (a hazard that also existed
// when readers attached lazily to the raw stdio streams). Consumers attached
// by then still get the error via their own listeners; in the rare pre-attach
// window the error is dropped instead of crashing the CLI.
out.on("error", () => {})
src.on("error", (err) => out.destroy(err instanceof Error ? err : new Error(String(err))))
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
src.pipe(out)
return out
}

/** Tap a freshly spawned process. Must be called in the same tick as spawn. */
export function tap(proc: NodeChildProcess.ChildProcess) {
map.set(proc, { stdout: wrap(proc.stdout), stderr: wrap(proc.stderr) })
}

/** The tapped stream for a process, falling back to the raw stdio stream. */
export function tapped(proc: NodeChildProcess.ChildProcess, fd: "stdout" | "stderr") {
return map.get(proc)?.[fd] ?? proc[fd]!
}
10 changes: 8 additions & 2 deletions packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Stream } from "effect"
import { Effect, Fiber, Stream } from "effect" // kilocode_change - Fiber
import os from "os"
import { createWriteStream } from "node:fs"
import * as Tool from "./tool"
Expand Down Expand Up @@ -548,7 +548,7 @@ export const ShellTool = Tool.define(
yield* Effect.addFinalizer(closeSink)
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))

yield* Effect.forkScoped(
const reader = yield* Effect.forkScoped( // kilocode_change - keep the fiber so trailing output can be drained
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
const size = Buffer.byteLength(chunk, "utf-8")
list.push({ text: chunk, size })
Expand Down Expand Up @@ -621,6 +621,12 @@ export const ShellTool = Tool.define(
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}

// kilocode_change start - closing the scope interrupts the reader fiber, which can drop
// buffered output that arrived just before the process exited. Wait for the stream to
// finish (it ends once stdio closes) so fast commands do not lose their final chunks.
yield* Fiber.await(reader).pipe(Effect.timeout("3 seconds"), Effect.ignore)
// kilocode_change end

return exit.kind === "exit" ? exit.code : null
}),
).pipe(Effect.orDie)
Expand Down
Loading