2026-06-29 04:43:04 +00:00
export * as ShellTool from "./shell"
2026-06-04 03:02:17 +00:00
2026-07-15 19:10:38 +00:00
import { ToolFailure } from "@opencode-ai/ai"
2026-07-27 00:08:55 +00:00
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
2026-07-23 21:13:31 +00:00
import { Deferred , Effect , Schema , Scope } from "effect"
2026-07-21 14:34:29 +00:00
import { FSUtil } from "@opencode-ai/util/fs-util"
2026-07-27 00:08:55 +00:00
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
import { NonNegativeInt } from "../../schema"
import { SessionSchema } from "../../session/schema"
import { Shell } from "../../shell"
2026-06-04 03:02:17 +00:00
2026-06-29 04:43:04 +00:00
export const name = "shell"
2026-06-04 03:02:17 +00:00
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1 _000
export const MAX_CAPTURE_BYTES = 1024 * 1024
2026-07-04 15:33:45 +00:00
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
2026-07-28 20:53:19 +00:00
const OS =
process . platform === "darwin"
? "macOS"
: process . platform === "win32"
? "Windows"
: process . platform === "linux"
? "Linux"
: process . platform
const description = ( shell? : string ) = >
[
"Execute a shell command and return its output." ,
. . . ( shell ? [ ` Commands run on ${ OS } using ${ shell } . ` ] : [ ] ) ,
"Quote file paths containing spaces or special characters." ,
"Prefer dedicated tools over shell commands when possible." ,
"When output is large, the full result is saved to a file and a truncated preview is returned." ,
"Rely on automatic truncation unless filtering the output is more useful." ,
"Commands accept an optional timeout, background commands have no timeout by default." ,
"Background commands return immediately, and you will be notified when they complete." ,
] . join ( " " )
2026-06-29 21:01:04 +00:00
2026-06-07 01:55:34 +00:00
export const Input = Schema . Struct ( {
2026-06-04 03:02:17 +00:00
command : Schema.String.annotate ( { description : "Shell command string to execute" } ) ,
2026-07-27 21:04:48 +00:00
workdir : Schema.optionalKey ( Schema . String ) . annotate ( {
2026-07-28 20:53:19 +00:00
description :
"Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead." ,
} ) ,
timeout : Schema.optionalKey ( NonNegativeInt ) . annotate ( {
description : ` Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${ DEFAULT_TIMEOUT_MS } for foreground commands. Background commands have no timeout by default. ` ,
2026-06-04 03:02:17 +00:00
} ) ,
2026-07-27 21:04:48 +00:00
background : Schema.optionalKey ( Schema . Boolean ) . annotate ( {
2026-06-29 21:01:04 +00:00
description :
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress." ,
} ) ,
2026-06-04 03:02:17 +00:00
} )
2026-06-27 00:56:36 +00:00
const StructuredOutput = Schema . Struct ( {
2026-07-27 21:04:48 +00:00
exit : Schema.optionalKey ( Schema . Number ) ,
shellID : Schema.optionalKey ( Schema . String ) ,
2026-06-27 00:56:36 +00:00
truncated : Schema.Boolean ,
2026-07-27 21:04:48 +00:00
timeout : Schema.optionalKey ( Schema . Boolean ) ,
2026-06-27 00:56:36 +00:00
} )
2026-06-07 01:55:34 +00:00
const Output = Schema . Struct ( {
2026-06-27 00:56:36 +00:00
. . . StructuredOutput . fields ,
2026-06-04 03:02:17 +00:00
output : Schema.String ,
2026-07-27 21:04:48 +00:00
status : Schema.optionalKey ( Schema . Literals ( [ "completed" , "running" ] ) ) ,
2026-06-04 03:02:17 +00:00
} )
2026-06-07 01:55:34 +00:00
type Output = typeof Output . Type
2026-06-04 03:02:17 +00:00
2026-06-29 21:01:04 +00:00
const modelOutput = ( output : Output ) : string | undefined = > {
2026-07-29 16:16:45 +00:00
if ( output . status === "running" ) return BACKGROUND_INSTRUCTION
if ( output . timeout ) return "Command timed out before completion."
return ` Command exited with code ${ output . exit } . `
2026-06-04 03:02:17 +00:00
}
/ * *
2026-07-27 00:08:55 +00:00
* Minimal core shell boundary . Keep parity debt visible without pulling the
2026-06-04 03:02:17 +00:00
* legacy shell runtime into core .
* /
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
2026-07-27 00:08:55 +00:00
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
2026-06-30 03:53:35 +00:00
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
2026-06-04 03:02:17 +00:00
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
2026-06-30 17:18:56 +00:00
export const Plugin = {
2026-07-05 19:51:15 +00:00
id : "opencode.tool.shell" ,
2026-06-30 17:18:56 +00:00
effect : Effect.fn ( "ShellTool.Plugin" ) ( function * ( ctx : PluginContext ) {
const runtime = yield * PluginRuntime . Service
2026-06-29 21:01:04 +00:00
const scope = yield * Scope . Scope
2026-06-29 21:27:24 +00:00
const fsUtil = yield * FSUtil . Service
2026-06-30 17:18:56 +00:00
const mutation = yield * LocationMutation . Service
const shell = yield * Shell . Service
2026-07-27 00:08:55 +00:00
const permission = yield * Permission . Service
2026-06-29 21:01:04 +00:00
2026-06-30 03:53:35 +00:00
const notifyWhenDone = Effect . fn ( "ShellTool.notifyWhenDone" ) ( function * (
2026-06-29 21:01:04 +00:00
sessionID : SessionSchema.ID ,
callID : string ,
command : string ,
) {
2026-06-30 17:18:56 +00:00
yield * runtime . job . wait ( { id : callID } ) . pipe (
2026-06-29 21:01:04 +00:00
Effect . flatMap ( ( result ) = > {
const state =
result . info ? . status === "completed"
? "completed"
: result . info ? . status === "error"
? "error"
: result . info ? . status === "cancelled"
? "cancelled"
: undefined
if ( state === undefined ) return Effect . void
const text =
state === "completed"
2026-06-30 03:53:35 +00:00
? ( result . info ! . output ? ? "" )
2026-06-29 21:01:04 +00:00
: state === "error"
2026-06-30 03:53:35 +00:00
? ( result . info ! . error ? ? "Command failed" )
2026-06-29 21:01:04 +00:00
: "Command cancelled"
2026-06-30 17:18:56 +00:00
return runtime . session . synthetic ( {
2026-06-29 21:01:04 +00:00
sessionID ,
text : ` <shell id=" ${ callID } " state=" ${ state } " command=" ${ command } "> \ n ${ text } \ n</shell> ` ,
2026-07-13 01:01:36 +00:00
description : command ,
metadata : { source : "shell" , state } ,
2026-06-29 21:01:04 +00:00
} )
} ) ,
Effect . forkIn ( scope , { startImmediately : true } ) ,
)
} )
2026-06-07 00:49:12 +00:00
2026-06-30 17:18:56 +00:00
yield * ctx . tool
2026-07-06 17:36:19 +00:00
. transform ( ( draft ) = >
draft . add (
2026-07-27 00:08:55 +00:00
( {
name ,
options : { codemode : false } ,
2026-07-28 20:53:19 +00:00
description : description ( ) ,
2026-07-06 17:36:19 +00:00
input : Input ,
output : Output ,
execute : ( input , context ) = >
Effect . gen ( function * ( ) {
const source = {
type : "tool" as const ,
2026-07-14 19:57:16 +00:00
messageID : context.messageID ,
callID : context.callID ,
2026-07-06 17:36:19 +00:00
}
const target = yield * mutation . resolve ( { path : input.workdir ? ? "." , kind : "directory" } )
const external = target . externalDirectory
if ( external )
yield * permission . assert ( {
. . . LocationMutation . externalDirectoryPermission ( external ) ,
sessionID : context.sessionID ,
agent : context.agent ,
source ,
} )
2026-06-07 00:49:12 +00:00
yield * permission . assert ( {
2026-07-06 17:36:19 +00:00
action : name ,
resources : [ input . command ] ,
save : [ input . command ] ,
2026-06-07 00:49:12 +00:00
sessionID : context.sessionID ,
agent : context.agent ,
source ,
} )
2026-06-29 21:01:04 +00:00
2026-07-06 17:36:19 +00:00
if ( ( yield * fsUtil . stat ( target . canonical ) ) . type !== "Directory" )
return yield * Effect . fail ( new Error ( ` Working directory is not a directory: ${ target . canonical } ` ) )
2026-07-06 23:32:22 +00:00
const timeout = input . background === true ? ( input . timeout ? ? 0 ) : ( input . timeout ? ? DEFAULT_TIMEOUT_MS )
2026-07-06 17:36:19 +00:00
const info = yield * shell . create ( {
command : input.command ,
cwd : target.canonical ,
timeout ,
metadata : { sessionID : context.sessionID } ,
} )
2026-07-23 21:13:31 +00:00
yield * context . progress ( { shellID : info.id } )
2026-06-29 21:01:04 +00:00
2026-07-15 01:29:02 +00:00
const captureShell = Effect . fn ( "ShellTool.captureShell" ) ( function * ( ) {
2026-07-28 21:32:11 +00:00
const latest = yield * shell . output ( info . id , { cursor : Number.MAX_SAFE_INTEGER } )
const truncated = latest . size > MAX_CAPTURE_BYTES
const page = yield * shell . output ( info . id , {
cursor : Math.max ( 0 , latest . size - MAX_CAPTURE_BYTES ) ,
limit : MAX_CAPTURE_BYTES ,
} )
2026-07-15 01:29:02 +00:00
const notice = truncated ? ` \ n \ n[output truncated; full output saved to: ${ info . file } ] ` : ""
return {
output : ` ${ page . output || "(no output)" } ${ notice } ` ,
truncated ,
}
} )
2026-07-06 17:36:19 +00:00
const settleShell = Effect . fn ( "ShellTool.settleShell" ) ( function * ( ) {
const final = yield * shell . wait ( info . id )
2026-06-29 21:01:04 +00:00
2026-07-28 00:48:29 +00:00
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
2026-07-06 17:36:19 +00:00
if ( final . status === "timeout" ) {
return {
2026-07-28 00:48:29 +00:00
. . . ( final . exit !== undefined ? { exit : final.exit } : { } ) ,
2026-07-06 17:36:19 +00:00
output : ` Command exceeded timeout of ${ timeout } ms. Retry with a larger timeout if the command is expected to take longer. ` ,
truncated : false ,
timeout : true ,
status : "completed" as const ,
}
}
2026-06-29 21:01:04 +00:00
2026-07-15 01:29:02 +00:00
const capture = yield * captureShell ( )
2026-07-01 16:17:30 +00:00
return {
2026-07-28 00:48:29 +00:00
. . . ( final . exit !== undefined ? { exit : final.exit } : { } ) ,
2026-07-15 01:29:02 +00:00
output : capture.output ,
truncated : capture.truncated ,
2026-07-01 16:17:30 +00:00
status : "completed" as const ,
}
2026-07-06 17:36:19 +00:00
} )
2026-06-30 17:18:56 +00:00
2026-07-23 21:13:31 +00:00
const settled = yield * Deferred . make < Output > ( )
2026-07-06 17:36:19 +00:00
const run = settleShell ( ) . pipe (
2026-07-23 21:13:31 +00:00
Effect . tap ( ( output ) = > Deferred . succeed ( settled , output ) ) ,
2026-07-06 17:36:19 +00:00
Effect . map ( ( output ) = > output . output ) ,
Effect . onInterrupt ( ( ) = > shell . remove ( info . id ) . pipe ( Effect . ignore ) ) ,
)
const job = yield * runtime . job . start ( {
2026-07-14 19:57:16 +00:00
id : context.callID ,
2026-07-06 17:36:19 +00:00
type : name ,
title : input.command ,
metadata : { sessionID : context.sessionID , shellID : info.id } ,
run ,
} )
2026-06-29 21:01:04 +00:00
2026-07-06 17:36:19 +00:00
if ( input . background === true ) {
yield * runtime . job . background ( job . id )
2026-07-14 19:57:16 +00:00
yield * notifyWhenDone ( context . sessionID , context . callID , input . command )
2026-07-06 17:36:19 +00:00
return {
output : BACKGROUND_STARTED ,
shellID : info.id ,
truncated : false ,
status : "running" as const ,
}
}
2026-07-01 16:17:30 +00:00
2026-07-15 01:29:02 +00:00
const result = yield * runtime . job . block ( { id : job.id , sessionID : context.sessionID } ) . pipe (
Effect . onInterrupt ( ( ) = > runtime . job . cancel ( job . id ) . pipe ( Effect . ignore ) ) ,
)
2026-07-06 17:36:19 +00:00
if ( result ? . type === "backgrounded" ) {
2026-07-06 23:32:22 +00:00
yield * shell . timeout ( info . id , 0 )
2026-07-14 19:57:16 +00:00
yield * notifyWhenDone ( context . sessionID , context . callID , input . command )
2026-07-06 17:36:19 +00:00
return {
output : BACKGROUND_STARTED ,
shellID : info.id ,
truncated : false ,
status : "running" as const ,
}
2026-06-29 21:01:04 +00:00
}
2026-07-06 17:36:19 +00:00
if ( result ? . info . status === "error" )
return yield * Effect . fail ( new Error ( result . info . error ? ? "Command failed" ) )
if ( result ? . info . status === "cancelled" ) return yield * Effect . fail ( new Error ( "Command cancelled" ) )
2026-06-30 17:18:56 +00:00
2026-07-29 16:16:45 +00:00
return yield * Deferred . await ( settled )
2026-07-06 17:36:19 +00:00
} ) . pipe (
2026-07-23 21:13:31 +00:00
Effect . map ( ( output ) = > {
2026-07-27 00:08:55 +00:00
const content : Array < Content > = [ { type : "text" , text : output.output } ]
2026-07-23 21:13:31 +00:00
const model = modelOutput ( output )
if ( model ) content . push ( { type : "text" , text : model } )
return {
output ,
content ,
metadata : {
truncated : output.truncated ,
. . . ( "exit" in output && output . exit !== undefined ? { exit : output.exit } : { } ) ,
. . . ( "shellID" in output && output . shellID !== undefined ? { shellID : output.shellID } : { } ) ,
. . . ( "timeout" in output && output . timeout !== undefined ? { timeout : output.timeout } : { } ) ,
} ,
}
} ) ,
2026-07-06 22:14:06 +00:00
Effect . mapError (
( error ) = > new ToolFailure ( { message : ` Unable to execute command: ${ input . command } ` , error } ) ,
) ,
2026-07-06 17:36:19 +00:00
) ,
} ) ,
) ,
)
2026-06-07 00:49:12 +00:00
. pipe ( Effect . orDie )
2026-07-28 20:53:19 +00:00
yield * ctx . session . hook ( "context" , ( event ) = >
Effect . gen ( function * ( ) {
const tool = event . tools [ name ]
if ( ! tool ) return
tool . description = description ( yield * shell . name ( ) )
} ) ,
)
2026-06-04 03:02:17 +00:00
} ) ,
2026-06-30 17:18:56 +00:00
}