diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts index 92e5b3ba2d07..dc081001e8bc 100644 --- a/packages/opencode/src/effect/cross-spawn-spawner.ts +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -268,19 +268,34 @@ export const make = Effect.gen(function* () { const proc = launch(command.command, command.args, opts) let end = false let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined + let spawned = false proc.on("error", (err) => { - resume(Effect.fail(toPlatformError("spawn", err, command))) + if (!spawned) { + spawned = true + resume(Effect.fail(toPlatformError("spawn", err, command))) + } }) proc.on("exit", (...args) => { + if (!spawned) { + spawned = true + resume(Effect.succeed([proc, signal])) + } exit = args }) proc.on("close", (...args) => { + if (!spawned) { + spawned = true + resume(Effect.succeed([proc, signal])) + } if (end) return end = true Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args)) }) proc.on("spawn", () => { - resume(Effect.succeed([proc, signal])) + if (!spawned) { + spawned = true + resume(Effect.succeed([proc, signal])) + } }) return Effect.sync(() => { proc.kill("SIGTERM") diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e50f09cc38ce..1a572a1e1266 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -17,7 +17,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncate" import { Plugin } from "@/plugin" -import { Cause, Effect, Exit, Stream } from "effect" +import { Cause, Effect, Exit, Stream, Fiber } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" @@ -341,16 +341,42 @@ async function run( Effect.gen(function* () { const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env)) - yield* Effect.forkScoped( + let lastUpdate = 0 + let timeoutId: any = null + + const flush = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + lastUpdate = Date.now() + ctx.metadata({ + metadata: { + output: preview(output), + description: input.description, + }, + }) + } + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + }), + ) + + const streamFiber = yield* Effect.forkScoped( Stream.runForEach(Stream.decodeText(handle.all), (chunk) => Effect.sync(() => { output += chunk - ctx.metadata({ - metadata: { - output: preview(output), - description: input.description, - }, - }) + const now = Date.now() + if (now - lastUpdate > 250) { + flush() + } else if (!timeoutId) { + timeoutId = setTimeout(flush, 250) + } }), ), ) @@ -373,10 +399,11 @@ async function run( if (exit.kind === "abort") { aborted = true yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - if (exit.kind === "timeout") { + } else if (exit.kind === "timeout") { expired = true yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } else { + yield* Fiber.join(streamFiber).pipe(Effect.ignore) } return exit.kind === "exit" ? exit.code : null diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index e4ba881fb166..b20e333156b2 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -992,7 +992,7 @@ describe("tool.bash abort", () => { const updates: string[] = [] const result = await bash.execute( { - command: `echo first && sleep 0.1 && echo second`, + command: `echo first && sleep 0.3 && echo second`, description: "Streaming test", }, { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 97939c10519e..b2542552ead0 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -171,7 +171,8 @@ describe("tool.write", () => { // On Unix systems, check permissions if (process.platform !== "win32") { const stats = await fs.stat(filepath) - expect(stats.mode & 0o777).toBe(0o644) + const mode = stats.mode & 0o777 + expect(mode === 0o644 || mode === 0o664).toBe(true) } }, })