Skip to content
Open
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
12 changes: 12 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { PlanExitTool } from "./plan"
import { Session } from "@/session/session"
Expand All@@ -26,6 +27,7 @@ import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider"

import { WebSearchTool } from "./websearch"
import { TerminalTool } from "./terminal"
import { LspTool } from "./lsp"
import * as Truncate from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
Expand DownExpand Up@@ -103,6 +105,7 @@ const layer = Layer.effect(
const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool
const shell = yield* ShellTool
const terminal = yield* TerminalTool
const globtool = yield* GlobTool
const writetool = yield* WriteTool
const edit = yield* EditTool
Expand DownExpand Up@@ -204,6 +207,7 @@ const layer = Layer.effect(
const tool = yield* Effect.all({
invalid: Tool.init(invalid),
shell: Tool.init(shell),
terminal: Tool.init(terminal),
read: Tool.init(read),
glob: Tool.init(globtool),
grep: Tool.init(greptool),
Expand All@@ -227,6 +231,7 @@ const layer = Layer.effect(
tool.invalid,
...(questionEnabled ? [tool.question] : []),
tool.shell,
tool.terminal,
tool.read,
tool.glob,
tool.grep,
Expand DownExpand Up@@ -419,6 +424,12 @@ function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

const locationServiceMapNode = LayerNode.make({
service: LocationServiceMap.Service,
layer: locationServiceMapLayer,
deps: [],
})

export const node = LayerNode.make({
service: Service,
layer,
Expand All@@ -444,6 +455,7 @@ export const node = LayerNode.make({
MCP.node,
Database.node,
Ripgrep.node,
locationServiceMapNode,
],
})

Expand Down
184 changes: 184 additions & 0 deletions packages/opencode/src/tool/terminal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
import { Effect, Schema } from "effect"
import path from "path"
import { InstanceState } from "@/effect/instance-state"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Pty } from "@opencode-ai/core/pty"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { ShellID } from "./shell/id"
import * as Tool from "./tool"
import DESCRIPTION from "./terminal.txt"

type Cell = {
cursor: number
}

type Snapshot = {
output: string
exited: boolean
exitCode?: number
}

export const Parameters = Schema.Struct({
action: Schema.optional(Schema.Literals(["open", "input", "read", "close"])),
command: Schema.optional(Schema.String).annotate({
description:
'The interactive command to run in a new terminal (only for action "open"). For example: ssh user@host',
}),
ptyID: Schema.optional(PtyID).annotate({
description: 'The ptyID of an existing interactive session (required for actions "input", "read", and "close").',
}),
data: Schema.optional(Schema.String).annotate({
description: 'Keystrokes to send to the running command (only for action "input").',
}),
enter: Schema.optional(Schema.Boolean).annotate({
description: 'When true (action "input"), append a newline after data to submit the prompt.',
}),
workdir: Schema.optional(Schema.String).annotate({
description: 'Working directory for a new terminal (action "open"). Defaults to the project root.',
}),
shell: Schema.optional(Schema.Literals(["powershell", "cmd", "bash"])).annotate({
description:
'The shell to run the command in (action "open"). Defaults to PowerShell on Windows and bash elsewhere. "bash" uses the default shell on POSIX.',
}),
})

export const TerminalTool = Tool.define(
"terminal",
Effect.gen(function* () {
const state = yield* InstanceState.make<Map<string, Cell>>(() => Effect.succeed(new Map()))
const locations = yield* LocationServiceMap.Service

return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const ins = yield* InstanceState.context
const cells = yield* InstanceState.get(state)
const location = Location.Ref.make({ directory: AbsolutePath.make(ins.directory) })
const scoped = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.provide(locations.get(location)))

const readMore = Effect.fnUntraced(function* (id: PtyID) {
const from = cells.get(id)?.cursor ?? 0
const attachment = yield* scoped(
Pty.Service.use((service) =>
service.attach(id, {
cursor: from,
onData: () => {},
onEnd: () => {},
}),
),
).pipe(
Effect.catchTag("Pty.ExitedError", () => Effect.succeed(undefined)),
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(undefined)),
)
if (!attachment) {
const info = yield* scoped(Pty.Service.use((service) => service.get(id))).pipe(
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(undefined)),
)
if (!info) return { output: "Terminal session no longer exists.", exited: true }
cells.delete(id)
return { output: "", exited: true, exitCode: info.exitCode }
}
const replay = attachment.replay
cells.set(id, { cursor: attachment.cursor })
attachment.detach()
return { output: replay || "(no new output)", exited: false }
})

const action = params.action ?? "open"

if (action === "open") {
const command = params.command
if (!command) throw new Error('terminal "open" requires a `command`')
yield* ctx.ask({
permission: ShellID.ToolID,
patterns: [command],
always: [command],
metadata: { command, interactive: true },
})
const cwd = params.workdir ? path.resolve(ins.directory, params.workdir) : ins.directory
// Pty sessions launch an executable file plus args; run the free-form
// command string through a shell. Default to PowerShell on Windows and
// bash elsewhere, but let the model pick cmd/powershell/bash explicitly.
const win = process.platform === "win32"
const kind = params.shell ?? (win ? "powershell" : "bash")
const systemRoot = process.env.SystemRoot ?? "C:\\Windows"
const shell =
kind === "bash" && !win
? (process.env.SHELL ?? "/bin/bash")
: kind === "cmd"
? path.join(systemRoot, "System32", "cmd.exe")
: path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
const args =
kind === "bash" && !win
? ["-lc", command]
: kind === "cmd"
? ["/d", "/s", "/c", command]
: ["-NoLogo", "-NoProfile", "-Command", command]
const info = yield* scoped(Pty.Service.use((service) => service.create({ command: shell, args, cwd })))
const attachment = yield* scoped(
Pty.Service.use((service) =>
service.attach(info.id, {
cursor: 0,
onData: () => {},
onEnd: () => {},
}),
),
)
const initial = attachment.replay
cells.set(info.id, { cursor: attachment.cursor })
attachment.detach()
return {
title: command,
metadata: { ptyID: info.id, status: "running" },
output: initial || `Interactive terminal started. ptyID: ${info.id}.`,
}
}

const id = params.ptyID
if (!id) throw new Error(`terminal "${action}" requires a \`ptyID\``)

if (action === "close") {
yield* scoped(Pty.Service.use((service) => service.remove(id))).pipe(
Effect.catchTag("Pty.NotFoundError", () => Effect.void),
)
cells.delete(id)
return {
title: "terminal close",
metadata: { ptyID: id, status: "closed" },
output: `Terminal session ${id} closed.`,
}
}

if (action === "input") {
const data = params.data ?? ""
if (!data && !params.enter) throw new Error('terminal "input" requires `data` or `enter: true`')
// Windows consoles expect CR (not LF) to submit a line; normalize any
// \n / \r\n the model sends so keystrokes never appear "lost".
const send = data.replace(/\r?\n/g, "\r") + (params.enter ? "\r" : "")
yield* scoped(Pty.Service.use((service) => service.write(id, send)))
// Give the PTY a moment to echo/produce output before reading so the
// result doesn't seem to be missing.
yield* Effect.sleep("120 millis")
const next = yield* readMore(id)
return {
title: "terminal input",
metadata: { ptyID: id, status: next.exited ? "exited" : "running" },
output: next.output,
}
}

const next = yield* readMore(id)
return {
title: "terminal read",
metadata: { ptyID: id, status: next.exited ? "exited" : "running" },
output: next.output,
}
}).pipe(Effect.orDie),
}
}),
)
16 changes: 16 additions & 0 deletions packages/opencode/src/tool/terminal.txt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
Open and drive an interactive terminal (a real PTY) for running commands that need keyboard interaction — for example `ssh user@host`, database shells, or interactive CLIs that prompt for a password, confirmation, or an editor.

Unlike the `bash` tool, which runs non-interactively with stdin disabledags and cannot receive user input, this tool lets the user type directly into the running command. The terminal is rendered in the user's IDE terminal when available, so they can answer password and confirmation prompts in real time.

Prefer non-interactive execution when no real interactivity is needed: for one-shot remote commands or queries where nobody needs to type into a live TTY, use `bash` (or a single non-interactive SSH invocation such as `plink -pw`) and read the result — it is more reliable than multi-step interactive input. Reserve `terminal` for genuinely interactive work (password/confirmation prompts, shells, daemons that must stay alive).

The session persists between tool calls. Use the `terminal` tool repeatedly with the returned `ptyID` to:

- `open` — start a new interactive command. Provide `command` and optional `workdir`, `shell`, and `title`. Returns the `ptyID` and any initial output. `shell` may be `"powershell"`, `"cmd"`, or `"bash"`; it defaults to PowerShell on Windows and bash elsewhere.
- `read` — fetch any new output that has been produced since the last read.
- `input` — send keystrokes to the running command. Provide `ptyID` and `data`; set `enter: true` to append a newline (needed to submit a prompt or password). Returns new output.
- `close` — terminate the session. Provide `ptyID`.

After `open`, new output appears asynchronously as the command runs)Skip. When the command is waiting for the user (for example a `password:` prompt), the user types in the connected IDE terminal; you then call `read` to see the resulting output and, if needed, send more `input`.

Never put secrets in the tool output. Prefer instructing the user to type sensitive values (passwords) directly into the interactive terminal rather than sending them through `input`, which is recorded in the session transcript.
Loading
Loading