Skip to content

Commit a9c85b7

Browse files
authored
refactor(shell): use Effect ChildProcess for shell command execution (#20494)
1 parent 897d83c commit a9c85b7

File tree

4 files changed

+136
-70
lines changed

4 files changed

+136
-70
lines changed

packages/app/e2e/prompt/prompt-shell.spec.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client"
22
import { test, expect } from "../fixtures"
33
import { sessionIDFromUrl } from "../actions"
44
import { promptSelector } from "../selectors"
5-
import { createSdk } from "../utils"
65

76
const isBash = (part: unknown): part is ToolPart => {
87
if (!part || typeof part !== "object") return false
@@ -14,10 +13,9 @@ const isBash = (part: unknown): part is ToolPart => {
1413
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
1514
test.setTimeout(120_000)
1615

17-
await withProject(async ({ directory, gotoSession, trackSession }) => {
18-
const sdk = createSdk(directory)
16+
await withProject(async ({ directory, gotoSession, trackSession, sdk }) => {
1917
const prompt = page.locator(promptSelector)
20-
const cmd = process.platform === "win32" ? "dir" : "ls"
18+
const cmd = process.platform === "win32" ? "dir" : "command ls"
2119

2220
await gotoSession()
2321
await prompt.click()

packages/opencode/src/effect/cross-spawn-spawner.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,17 @@ export const make = Effect.gen(function* () {
386386
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
387387
return yield* Effect.void
388388
}
389-
return yield* kill((command, proc, signal) =>
390-
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
391-
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
389+
const send = (s: NodeJS.Signals) =>
390+
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
391+
const sig = command.options.killSignal ?? "SIGTERM"
392+
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
393+
const escalated = command.options.forceKillAfter
394+
? Effect.timeoutOrElse(attempt, {
395+
duration: command.options.forceKillAfter,
396+
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
397+
})
398+
: attempt
399+
return yield* Effect.ignore(escalated)
392400
}),
393401
)
394402

@@ -413,14 +421,17 @@ export const make = Effect.gen(function* () {
413421
),
414422
)
415423
}),
416-
kill: (opts?: ChildProcess.KillOptions) =>
417-
timeout(
418-
proc,
419-
command,
420-
opts,
421-
)((command, proc, signal) =>
422-
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
423-
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
424+
kill: (opts?: ChildProcess.KillOptions) => {
425+
const sig = opts?.killSignal ?? "SIGTERM"
426+
const send = (s: NodeJS.Signals) =>
427+
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
428+
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
429+
if (!opts?.forceKillAfter) return attempt
430+
return Effect.timeoutOrElse(attempt, {
431+
duration: opts.forceKillAfter,
432+
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
433+
})
434+
},
424435
})
425436
}
426437
case "PipedCommand": {

packages/opencode/src/session/prompt.ts

Lines changed: 38 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ import { ReadTool } from "../tool/read"
2828
import { FileTime } from "../file/time"
2929
import { Flag } from "../flag/flag"
3030
import { ulid } from "ulid"
31-
import { spawn } from "child_process"
31+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
32+
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
33+
import * as Stream from "effect/Stream"
3234
import { Command } from "../command"
3335
import { pathToFileURL, fileURLToPath } from "url"
3436
import { ConfigMarkdown } from "../config/markdown"
@@ -96,6 +98,7 @@ export namespace SessionPrompt {
9698
const filetime = yield* FileTime.Service
9799
const registry = yield* ToolRegistry.Service
98100
const truncate = yield* Truncate.Service
101+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
99102
const scope = yield* Scope.Scope
100103

101104
const state = yield* InstanceState.make(
@@ -809,30 +812,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the
809812
fish: { args: ["-c", input.command] },
810813
zsh: {
811814
args: [
812-
"-c",
813815
"-l",
816+
"-c",
814817
`
818+
__oc_cwd=$PWD
815819
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
816820
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
821+
cd "$__oc_cwd"
817822
eval ${JSON.stringify(input.command)}
818823
`,
819824
],
820825
},
821826
bash: {
822827
args: [
823-
"-c",
824828
"-l",
829+
"-c",
825830
`
831+
__oc_cwd=$PWD
826832
shopt -s expand_aliases
827833
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
834+
cd "$__oc_cwd"
828835
eval ${JSON.stringify(input.command)}
829836
`,
830837
],
831838
},
832839
cmd: { args: ["/c", input.command] },
833840
powershell: { args: ["-NoProfile", "-Command", input.command] },
834841
pwsh: { args: ["-NoProfile", "-Command", input.command] },
835-
"": { args: ["-c", `${input.command}`] },
842+
"": { args: ["-c", input.command] },
836843
}
837844

838845
const args = (invocations[shellName] ?? invocations[""]).args
@@ -842,51 +849,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
842849
{ cwd, sessionID: input.sessionID, callID: part.callID },
843850
{ env: {} },
844851
)
845-
const proc = yield* Effect.sync(() =>
846-
spawn(sh, args, {
847-
cwd,
848-
detached: process.platform !== "win32",
849-
windowsHide: process.platform === "win32",
850-
stdio: ["ignore", "pipe", "pipe"],
851-
env: {
852-
...process.env,
853-
...shellEnv.env,
854-
TERM: "dumb",
855-
},
856-
}),
857-
)
858852

859-
let output = ""
860-
const write = () => {
861-
if (part.state.status !== "running") return
862-
part.state.metadata = { output, description: "" }
863-
void Effect.runFork(sessions.updatePart(part))
864-
}
865-
866-
proc.stdout?.on("data", (chunk) => {
867-
output += chunk.toString()
868-
write()
869-
})
870-
proc.stderr?.on("data", (chunk) => {
871-
output += chunk.toString()
872-
write()
853+
const cmd = ChildProcess.make(sh, args, {
854+
cwd,
855+
extendEnv: true,
856+
env: { ...shellEnv.env, TERM: "dumb" },
857+
stdin: "ignore",
858+
forceKillAfter: "3 seconds",
873859
})
874860

861+
let output = ""
875862
let aborted = false
876-
let exited = false
877-
let finished = false
878-
const kill = Effect.promise(() => Shell.killTree(proc, { exited: () => exited }))
879-
880-
const abortHandler = () => {
881-
if (aborted) return
882-
aborted = true
883-
void Effect.runFork(kill)
884-
}
885863

886864
const finish = Effect.uninterruptible(
887865
Effect.gen(function* () {
888-
if (finished) return
889-
finished = true
890866
if (aborted) {
891867
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
892868
}
@@ -908,20 +884,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
908884
}),
909885
)
910886

911-
const exit = yield* Effect.promise(() => {
912-
signal.addEventListener("abort", abortHandler, { once: true })
913-
if (signal.aborted) abortHandler()
914-
return new Promise<void>((resolve) => {
915-
const close = () => {
916-
exited = true
917-
proc.off("close", close)
918-
resolve()
919-
}
920-
proc.once("close", close)
921-
})
887+
const exit = yield* Effect.gen(function* () {
888+
const handle = yield* spawner.spawn(cmd)
889+
yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
890+
Effect.sync(() => {
891+
output += chunk
892+
if (part.state.status === "running") {
893+
part.state.metadata = { output, description: "" }
894+
void Effect.runFork(sessions.updatePart(part))
895+
}
896+
}),
897+
)
898+
yield* handle.exitCode
922899
}).pipe(
923-
Effect.onInterrupt(() => Effect.sync(abortHandler)),
924-
Effect.ensuring(Effect.sync(() => signal.removeEventListener("abort", abortHandler))),
900+
Effect.scoped,
901+
Effect.onInterrupt(() =>
902+
Effect.sync(() => {
903+
aborted = true
904+
}),
905+
),
906+
Effect.orDie,
925907
Effect.ensuring(finish),
926908
Effect.exit,
927909
)
@@ -1735,6 +1717,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
17351717
Layer.provide(Session.defaultLayer),
17361718
Layer.provide(Agent.defaultLayer),
17371719
Layer.provide(Bus.layer),
1720+
Layer.provide(CrossSpawnSpawner.defaultLayer),
17381721
),
17391722
),
17401723
)

packages/opencode/test/session/prompt-effect.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NodeFileSystem } from "@effect/platform-node"
22
import { expect, spyOn } from "bun:test"
33
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
4+
import path from "path"
45
import z from "zod"
56
import type { Agent } from "../../src/agent/agent"
67
import { Agent as AgentSvc } from "../../src/agent/agent"
@@ -887,6 +888,79 @@ unix("shell captures stdout and stderr in completed tool output", () =>
887888
),
888889
)
889890

891+
unix("shell completes a fast command on the preferred shell", () =>
892+
provideTmpdirInstance(
893+
(dir) =>
894+
Effect.gen(function* () {
895+
const { prompt, chat } = yield* boot()
896+
const result = yield* prompt.shell({
897+
sessionID: chat.id,
898+
agent: "build",
899+
command: "pwd",
900+
})
901+
902+
expect(result.info.role).toBe("assistant")
903+
const tool = completedTool(result.parts)
904+
if (!tool) return
905+
906+
expect(tool.state.input.command).toBe("pwd")
907+
expect(tool.state.output).toContain(dir)
908+
expect(tool.state.metadata.output).toContain(dir)
909+
yield* prompt.assertNotBusy(chat.id)
910+
}),
911+
{ git: true, config: cfg },
912+
),
913+
)
914+
915+
unix("shell lists files from the project directory", () =>
916+
provideTmpdirInstance(
917+
(dir) =>
918+
Effect.gen(function* () {
919+
const { prompt, chat } = yield* boot()
920+
yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n"))
921+
922+
const result = yield* prompt.shell({
923+
sessionID: chat.id,
924+
agent: "build",
925+
command: "command ls",
926+
})
927+
928+
expect(result.info.role).toBe("assistant")
929+
const tool = completedTool(result.parts)
930+
if (!tool) return
931+
932+
expect(tool.state.input.command).toBe("command ls")
933+
expect(tool.state.output).toContain("README.md")
934+
expect(tool.state.metadata.output).toContain("README.md")
935+
yield* prompt.assertNotBusy(chat.id)
936+
}),
937+
{ git: true, config: cfg },
938+
),
939+
)
940+
941+
unix("shell captures stderr from a failing command", () =>
942+
provideTmpdirInstance(
943+
(dir) =>
944+
Effect.gen(function* () {
945+
const { prompt, chat } = yield* boot()
946+
const result = yield* prompt.shell({
947+
sessionID: chat.id,
948+
agent: "build",
949+
command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
950+
})
951+
952+
expect(result.info.role).toBe("assistant")
953+
const tool = completedTool(result.parts)
954+
if (!tool) return
955+
956+
expect(tool.state.output).toContain("not found")
957+
expect(tool.state.metadata.output).toContain("not found")
958+
yield* prompt.assertNotBusy(chat.id)
959+
}),
960+
{ git: true, config: cfg },
961+
),
962+
)
963+
890964
unix(
891965
"shell updates running metadata before process exit",
892966
() =>

0 commit comments

Comments
 (0)