refactor(core): replace bash tool with shell tool

This commit is contained in:
Dax Raad 2026-06-29 00:43:04 -04:00
parent 595c6bd4a7
commit 5ae93092aa
37 changed files with 2260 additions and 901 deletions

View file

@ -614,99 +614,159 @@ const adaptGroup15 = (raw: RawClient["server.pty"]) => ({
remove: Endpoint15_4(raw),
})
type Endpoint16_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint16_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.question"]) => (input?: Endpoint16_0Input) =>
const Endpoint16_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint16_0Input) =>
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint16_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
type Endpoint16_1Input = {
readonly location?: Endpoint16_1Request["query"]["location"]
readonly command: Endpoint16_1Request["payload"]["command"]
readonly cwd?: Endpoint16_1Request["payload"]["cwd"]
readonly timeout?: Endpoint16_1Request["payload"]["timeout"]
readonly metadata?: Endpoint16_1Request["payload"]["metadata"]
}
const Endpoint16_1 = (raw: RawClient["server.shell"]) => (input: Endpoint16_1Input) =>
raw["shell.create"]({
query: { location: input["location"] },
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint16_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
type Endpoint16_2Input = {
readonly id: Endpoint16_2Request["params"]["id"]
readonly location?: Endpoint16_2Request["query"]["location"]
}
const Endpoint16_2 = (raw: RawClient["server.shell"]) => (input: Endpoint16_2Input) =>
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint16_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
type Endpoint16_3Input = {
readonly id: Endpoint16_3Request["params"]["id"]
readonly location?: Endpoint16_3Request["query"]["location"]
readonly cursor?: Endpoint16_3Request["query"]["cursor"]
readonly limit?: Endpoint16_3Request["query"]["limit"]
}
const Endpoint16_3 = (raw: RawClient["server.shell"]) => (input: Endpoint16_3Input) =>
raw["shell.output"]({
params: { id: input["id"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint16_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
type Endpoint16_4Input = {
readonly id: Endpoint16_4Request["params"]["id"]
readonly location?: Endpoint16_4Request["query"]["location"]
}
const Endpoint16_4 = (raw: RawClient["server.shell"]) => (input: Endpoint16_4Input) =>
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup16 = (raw: RawClient["server.shell"]) => ({
list: Endpoint16_0(raw),
create: Endpoint16_1(raw),
get: Endpoint16_2(raw),
output: Endpoint16_3(raw),
remove: Endpoint16_4(raw),
})
type Endpoint17_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
const Endpoint17_0 = (raw: RawClient["server.question"]) => (input?: Endpoint17_0Input) =>
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint16_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint16_1Input = { readonly sessionID: Endpoint16_1Request["params"]["sessionID"] }
const Endpoint16_1 = (raw: RawClient["server.question"]) => (input: Endpoint16_1Input) =>
type Endpoint17_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint17_1Input = { readonly sessionID: Endpoint17_1Request["params"]["sessionID"] }
const Endpoint17_1 = (raw: RawClient["server.question"]) => (input: Endpoint17_1Input) =>
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint16_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint16_2Input = {
readonly sessionID: Endpoint16_2Request["params"]["sessionID"]
readonly requestID: Endpoint16_2Request["params"]["requestID"]
readonly answers: Endpoint16_2Request["payload"]["answers"]
type Endpoint17_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint17_2Input = {
readonly sessionID: Endpoint17_2Request["params"]["sessionID"]
readonly requestID: Endpoint17_2Request["params"]["requestID"]
readonly answers: Endpoint17_2Request["payload"]["answers"]
}
const Endpoint16_2 = (raw: RawClient["server.question"]) => (input: Endpoint16_2Input) =>
const Endpoint17_2 = (raw: RawClient["server.question"]) => (input: Endpoint17_2Input) =>
raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint16_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint16_3Input = {
readonly sessionID: Endpoint16_3Request["params"]["sessionID"]
readonly requestID: Endpoint16_3Request["params"]["requestID"]
type Endpoint17_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint17_3Input = {
readonly sessionID: Endpoint17_3Request["params"]["sessionID"]
readonly requestID: Endpoint17_3Request["params"]["requestID"]
}
const Endpoint16_3 = (raw: RawClient["server.question"]) => (input: Endpoint16_3Input) =>
const Endpoint17_3 = (raw: RawClient["server.question"]) => (input: Endpoint17_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup16 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint16_0(raw),
list: Endpoint16_1(raw),
reply: Endpoint16_2(raw),
reject: Endpoint16_3(raw),
const adaptGroup17 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint17_0(raw),
list: Endpoint17_1(raw),
reply: Endpoint17_2(raw),
reject: Endpoint17_3(raw),
})
type Endpoint17_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
const Endpoint17_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint17_0Input) =>
type Endpoint18_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
const Endpoint18_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint18_0Input) =>
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup17 = (raw: RawClient["server.reference"]) => ({ list: Endpoint17_0(raw) })
const adaptGroup18 = (raw: RawClient["server.reference"]) => ({ list: Endpoint18_0(raw) })
type Endpoint18_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint18_0Input = {
readonly projectID: Endpoint18_0Request["params"]["projectID"]
readonly location?: Endpoint18_0Request["query"]["location"]
readonly strategy: Endpoint18_0Request["payload"]["strategy"]
readonly directory: Endpoint18_0Request["payload"]["directory"]
readonly name?: Endpoint18_0Request["payload"]["name"]
type Endpoint19_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint19_0Input = {
readonly projectID: Endpoint19_0Request["params"]["projectID"]
readonly location?: Endpoint19_0Request["query"]["location"]
readonly strategy: Endpoint19_0Request["payload"]["strategy"]
readonly directory: Endpoint19_0Request["payload"]["directory"]
readonly name?: Endpoint19_0Request["payload"]["name"]
}
const Endpoint18_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_0Input) =>
const Endpoint19_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_0Input) =>
raw["projectCopy.create"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint18_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint18_1Input = {
readonly projectID: Endpoint18_1Request["params"]["projectID"]
readonly location?: Endpoint18_1Request["query"]["location"]
readonly directory: Endpoint18_1Request["payload"]["directory"]
readonly force: Endpoint18_1Request["payload"]["force"]
type Endpoint19_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint19_1Input = {
readonly projectID: Endpoint19_1Request["params"]["projectID"]
readonly location?: Endpoint19_1Request["query"]["location"]
readonly directory: Endpoint19_1Request["payload"]["directory"]
readonly force: Endpoint19_1Request["payload"]["force"]
}
const Endpoint18_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_1Input) =>
const Endpoint19_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_1Input) =>
raw["projectCopy.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint18_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint18_2Input = {
readonly projectID: Endpoint18_2Request["params"]["projectID"]
readonly location?: Endpoint18_2Request["query"]["location"]
type Endpoint19_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint19_2Input = {
readonly projectID: Endpoint19_2Request["params"]["projectID"]
readonly location?: Endpoint19_2Request["query"]["location"]
}
const Endpoint18_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_2Input) =>
const Endpoint19_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_2Input) =>
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup18 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint18_0(raw),
remove: Endpoint18_1(raw),
refresh: Endpoint18_2(raw),
const adaptGroup19 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint19_0(raw),
remove: Endpoint19_1(raw),
refresh: Endpoint19_2(raw),
})
const adaptClient = (raw: RawClient) => ({
@ -726,9 +786,10 @@ const adaptClient = (raw: RawClient) => ({
skills: adaptGroup13(raw["server.skill"]),
events: adaptGroup14(raw["server.event"]),
ptys: adaptGroup15(raw["server.pty"]),
questions: adaptGroup16(raw["server.question"]),
references: adaptGroup17(raw["server.reference"]),
projectCopies: adaptGroup18(raw["server.projectCopy"]),
"server.shell": adaptGroup16(raw["server.shell"]),
questions: adaptGroup17(raw["server.question"]),
references: adaptGroup18(raw["server.reference"]),
projectCopies: adaptGroup19(raw["server.projectCopy"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>

View file

@ -100,6 +100,16 @@ import type {
PtysUpdateOutput,
PtysRemoveInput,
PtysRemoveOutput,
ServerShellListInput,
ServerShellListOutput,
ServerShellCreateInput,
ServerShellCreateOutput,
ServerShellGetInput,
ServerShellGetOutput,
ServerShellOutputInput,
ServerShellOutputOutput,
ServerShellRemoveInput,
ServerShellRemoveOutput,
QuestionsListRequestsInput,
QuestionsListRequestsOutput,
QuestionsListInput,
@ -916,6 +926,74 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
"server.shell": {
list: (input?: ServerShellListInput, requestOptions?: RequestOptions) =>
request<ServerShellListOutput>(
{
method: "GET",
path: `/api/shell`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
create: (input: ServerShellCreateInput, requestOptions?: RequestOptions) =>
request<ServerShellCreateOutput>(
{
method: "POST",
path: `/api/shell`,
query: { location: input["location"] },
body: {
command: input["command"],
cwd: input["cwd"],
timeout: input["timeout"],
metadata: input["metadata"],
},
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
get: (input: ServerShellGetInput, requestOptions?: RequestOptions) =>
request<ServerShellGetOutput>(
{
method: "GET",
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
output: (input: ServerShellOutputInput, requestOptions?: RequestOptions) =>
request<ServerShellOutputOutput>(
{
method: "GET",
path: `/api/shell/${encodeURIComponent(input.id)}/output`,
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
remove: (input: ServerShellRemoveInput, requestOptions?: RequestOptions) =>
request<ServerShellRemoveOutput>(
{
method: "DELETE",
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
},
questions: {
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionsListRequestsOutput>(

View file

@ -94,6 +94,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string }
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
export type QuestionNotFoundError = {
readonly _tag: "QuestionNotFoundError"
readonly requestID: string
@ -2734,6 +2738,160 @@ export type PtysRemoveInput = {
export type PtysRemoveOutput = void
export type ServerShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ServerShellListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: ReadonlyArray<{
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}>
}
export type ServerShellCreateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly command: {
readonly command: string
readonly cwd?: string
readonly timeout?: number
readonly metadata?: { readonly [x: string]: JsonValue }
}["command"]
readonly cwd?: {
readonly command: string
readonly cwd?: string
readonly timeout?: number
readonly metadata?: { readonly [x: string]: JsonValue }
}["cwd"]
readonly timeout?: {
readonly command: string
readonly cwd?: string
readonly timeout?: number
readonly metadata?: { readonly [x: string]: JsonValue }
}["timeout"]
readonly metadata?: {
readonly command: string
readonly cwd?: string
readonly timeout?: number
readonly metadata?: { readonly [x: string]: JsonValue }
}["metadata"]
}
export type ServerShellCreateOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
}
export type ServerShellGetInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ServerShellGetOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
}
export type ServerShellOutputInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["location"]
readonly cursor?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["cursor"]
readonly limit?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["limit"]
}
export type ServerShellOutputOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
export type ServerShellRemoveInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ServerShellRemoveOutput = void
export type QuestionsListRequestsInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined

View file

@ -23,6 +23,7 @@ import { Policy } from "./policy"
import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm"
@ -59,6 +60,7 @@ export const locationServices = LayerNode.group([
FileSystem.node,
Watcher.node,
Pty.node,
Shell.node,
SkillV2.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,

View file

@ -8,7 +8,12 @@ import { Global } from "../global"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your strengths:
@ -99,7 +104,7 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(

View file

@ -8,7 +8,7 @@ import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { PtyID } from "./pty/schema"
import { Shell } from "./shell"
import { ShellSelect } from "./shell/select"
import { lazy } from "./util/lazy"
const BUFFER_LIMIT = 1024 * 1024 * 2
@ -164,8 +164,8 @@ export const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"))
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
...process.env,

View file

@ -1,226 +1,300 @@
export * as Shell from "./shell"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout as sleep } from "node:timers/promises"
import { Flag } from "./flag/flag"
import { FSUtil } from "./fs-util"
import { which } from "./util/which"
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { makeLocationNode } from "./effect/app-node"
import { AppProcess } from "./process"
import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { Global } from "./global"
import { ShellSelect } from "./shell/select"
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
bash: { login: true, posix: true },
dash: { login: true, posix: true },
fish: { deny: true, login: true },
ksh: { login: true, posix: true },
nu: { deny: true },
powershell: { ps: true },
pwsh: { ps: true },
sh: { login: true, posix: true },
zsh: { login: true, posix: true },
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
}) {}
// Exited processes stay observable (status, exit code, retained output) until removed explicitly.
// Cap retention so abandoned commands do not accumulate unbounded state and output files.
const EXITED_LIMIT = 25
type Info = Shell.Info
type Active = {
// Immutable snapshot; lifecycle updates replace it via immer `produce`.
info: Info
file: string
size: number
// Resolves with the terminal Info once the command exits, times out, or is killed. A wait
// started after termination resolves immediately from the already-completed deferred.
done: Deferred.Deferred<Info, NotFoundError>
timeoutFiber?: Fiber.Fiber<void, never>
}
export type Item = {
path: string
name: string
acceptable: boolean
/**
* Location-owned non-interactive shell command process service.
*
* Each `create` spawns one shell command, captures combined stdout/stderr to a
* file, and returns an ID. Clients poll `get` for status and `output` for
* file-backed output by cursor. No session, message, or permission state lives
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
*/
export interface Interface {
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// Resolves once the command reaches a terminal status, returning its final Info. Fails with
// NotFoundError if the command is unknown or is removed before it terminates.
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError>
}
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Shell") {}
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
killer.once("exit", () => resolve())
killer.once("error", () => resolve())
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
return
}
try {
process.kill(-pid, "SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch {
proc.kill("SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
}
}
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* events.publish(Shell.Event.Deleted, { id })
})
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
function full(file: string) {
if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
}
if (name(shell) === "bash") return gitbash() || which(shell) || shell
return which(shell) || shell
}
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
function meta(file: string) {
return META[name(file)]
}
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
function ok(file: string) {
return meta(file)?.deny !== true
}
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
function resolve(file: string) {
const shell = full(file)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
}
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
const id = Shell.ID.ascending()
const cwd = input.cwd ?? location.directory
const configShell = Config.latest(yield* config.entries(), "shell")
const shell = ShellSelect.preferred(configShell)
const args = ShellSelect.args(shell, input.command, cwd)
const file = path.join(outputDir, `${id}.out`)
const env = {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
function win() {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map(full),
),
)
}
const info: Info = {
id,
status: "running",
command: input.command,
cwd,
shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
async function unix() {
const text = await readFile("/etc/shells", "utf8").catch(() => "")
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active, never>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(shell, args, {
cwd,
env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]
return fallback()
}
const stream = createWriteStream(file)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
export function gitbash() {
if (process.platform !== "win32") return
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (stat(file)?.size) return file
}
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(pump.pipe(Effect.catch(() => Effect.void)))
function fallback() {
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
if (bash) return bash
return "/bin/sh"
}
const finish = (status: Info["status"], exit?: number) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
stream.end()
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* events.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
export function name(file: string) {
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
return path.basename(file).toLowerCase()
}
if (input.timeout) {
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(input.timeout)).pipe(
Effect.flatMap(() =>
Effect.gen(function* () {
yield* finish("timeout")
yield* handle.kill().pipe(Effect.catch(() => Effect.void))
}),
),
Effect.catch(() => Effect.void),
),
)
}
export function login(file: string) {
return meta(file)?.login === true
}
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
export function posix(file: string) {
return meta(file)?.posix === true
}
yield* events.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
export function ps(file: string) {
return meta(file)?.ps === true
}
const session = yield* Deferred.await(ready)
return session.info
})
function info(file: string): Item {
const item = full(file)
const n = name(item)
return {
path: item,
name: resolve(n) ? n : item,
acceptable: ok(item),
}
}
return Service.of({ create, list, get, wait, output, remove })
}),
)
export function args(file: string, command: string, cwd: string) {
const n = name(file)
if (n === "nu" || n === "fish") return ["-c", command]
if (n === "zsh") {
return [
"-l",
"-c",
`
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(command)}
`,
"opencode",
cwd,
]
}
if (n === "bash") {
return [
"-l",
"-c",
`
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(command)}
`,
"opencode",
cwd,
]
}
if (n === "cmd") return ["/c", command]
if (ps(file)) return ["-NoProfile", "-Command", command]
return ["-c", command]
}
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
}
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, Location.node, Config.node, Global.node, AppProcess.node],
})

View file

@ -0,0 +1,226 @@
export * as ShellSelect from "./select"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout as sleep } from "node:timers/promises"
import { Flag } from "../flag/flag"
import { FSUtil } from "../fs-util"
import { which } from "../util/which"
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
bash: { login: true, posix: true },
dash: { login: true, posix: true },
fish: { deny: true, login: true },
ksh: { login: true, posix: true },
nu: { deny: true },
powershell: { ps: true },
pwsh: { ps: true },
sh: { login: true, posix: true },
zsh: { login: true, posix: true },
}
export type Item = {
path: string
name: string
acceptable: boolean
}
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
killer.once("exit", () => resolve())
killer.once("error", () => resolve())
})
return
}
try {
process.kill(-pid, "SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch {
proc.kill("SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
}
}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string) {
if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
}
if (name(shell) === "bash") return gitbash() || which(shell) || shell
return which(shell) || shell
}
function meta(file: string) {
return META[name(file)]
}
function ok(file: string) {
return meta(file)?.deny !== true
}
function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string) {
const shell = full(file)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
}
function win() {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map(full),
),
)
}
async function unix() {
const text = await readFile("/etc/shells", "utf8").catch(() => "")
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]
return fallback()
}
export function gitbash() {
if (process.platform !== "win32") return
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (stat(file)?.size) return file
}
function fallback() {
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
if (bash) return bash
return "/bin/sh"
}
export function name(file: string) {
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
return path.basename(file).toLowerCase()
}
export function login(file: string) {
return meta(file)?.login === true
}
export function posix(file: string) {
return meta(file)?.posix === true
}
export function ps(file: string) {
return meta(file)?.ps === true
}
function info(file: string): Item {
const item = full(file)
const n = name(item)
return {
path: item,
name: resolve(n) ? n : item,
acceptable: ok(item),
}
}
export function args(file: string, command: string, cwd: string) {
const n = name(file)
if (n === "nu" || n === "fish") return ["-c", command]
if (n === "zsh") {
return [
"-l",
"-c",
`
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(command)}
`,
"opencode",
cwd,
]
}
if (n === "bash") {
return [
"-l",
"-c",
`
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(command)}
`,
"opencode",
cwd,
]
}
if (n === "cmd") return ["/c", command]
if (ps(file)) return ["-NoProfile", "-Command", command]
return ["-c", command]
}
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
}

View file

@ -2,7 +2,7 @@ export * as BuiltInTools from "./builtins"
import { makeLocationNode } from "../effect/app-node"
import { Layer } from "effect"
import { BashTool } from "./bash"
import { ShellTool } from "./shell"
import { ApplyPatchTool } from "./apply-patch"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@ -16,8 +16,7 @@ import { WebFetchTool } from "./webfetch"
import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
import { FSUtil } from "../fs-util"
import { AppProcess } from "../process"
import { Config } from "../config"
import { Shell } from "../shell"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { FileMutation } from "../file-mutation"
@ -45,7 +44,7 @@ import { httpClient } from "../effect/app-node-platform"
*/
export const locationLayer = Layer.mergeAll(
ApplyPatchTool.layer,
BashTool.layer,
ShellTool.layer,
EditTool.layer,
GlobTool.layer,
GrepTool.layer,
@ -64,8 +63,7 @@ export const node = makeLocationNode({
deps: [
ToolRegistry.toolsNode,
FSUtil.node,
AppProcess.node,
Config.node,
Shell.node,
Location.node,
LocationMutation.node,
FileMutation.node,

View file

@ -1,19 +1,17 @@
export * as BashTool from "./bash"
export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
import { Effect, Layer, Schema } from "effect"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process"
import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema"
import { Shell } from "../shell"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "bash"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
@ -44,8 +42,6 @@ const Output = Schema.Struct({
type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
const modelOutput = (output: Output) => {
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
@ -54,9 +50,6 @@ const modelOutput = (output: Output) => {
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
}
const isTimeout = (error: AppProcess.AppProcessError) =>
error.cause instanceof Error && error.cause.message === "Timed out"
/**
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core.
@ -93,8 +86,7 @@ export const layer = Layer.effectDiscard(
const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service
const fs = yield* FSUtil.Service
const appProcess = yield* AppProcess.Service
const config = yield* Config.Service
const shell = yield* Shell.Service
const permission = yield* PermissionV2.Service
yield* tools
@ -131,7 +123,7 @@ export const layer = Layer.effectDiscard(
})
const warnings = externalCommandDirectories(input.command, target.canonical).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* permission.assert({
action: name,
@ -145,30 +137,20 @@ export const layer = Layer.effectDiscard(
if ((yield* fs.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const entries = yield* config.entries()
const shell =
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : [])))
.shell ?? defaultShell()
const command = ChildProcess.make(input.command, [], {
cwd: target.canonical,
shell,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
})
// Delegate spawning, combined-output capture, timeout, and exit tracking to the Shell
// service. The full output is captured to a file; we read a bounded page for the model
// and point the agent at the file when it overflows the model cap.
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess
.run(command, {
combineOutput: true,
timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES,
})
.pipe(
Effect.catchTag("AppProcessError", (error) =>
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
),
)
if (!result) {
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") {
return {
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
@ -177,14 +159,13 @@ export const layer = Layer.effectDiscard(
}
}
const output = result.output?.toString("utf8") || "(no output)"
const notice = result.outputTruncated
? "[output capture truncated at the in-memory safety limit]"
: undefined
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return {
exit: result.exitCode,
output: notice ? `${output}\n\n${notice}` : output,
truncated: result.outputTruncated === true,
exit: final.exit,
output: `${body}${notice}`,
truncated,
...(warnings.length ? { warnings } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),

View file

@ -79,8 +79,9 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
resource: "*",
effect: enabled ? ("allow" as const) : ("deny" as const),
}))
for (const [action, rule] of Object.entries(info ?? {})) {
for (const [key, rule] of Object.entries(info ?? {})) {
if (!rule) continue
const action = normalizeAction(key)
if (typeof rule === "string") {
rules.push({ action, resource: "*", effect: rule })
continue
@ -90,8 +91,12 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
return rules.length ? rules : undefined
}
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
function normalizeAction(action: string) {
return action === "write" || action === "patch" ? "edit" : action
if (action === "write" || action === "patch") return "edit"
if (action === "task") return "subagent"
if (action === "bash") return "shell"
return action
}
function agents(info: typeof ConfigV1.Info.Type) {

View file

@ -143,6 +143,27 @@ describe("Config", () => {
}),
)
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
}),
)
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@ -566,7 +587,7 @@ describe("Config", () => {
expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.share).toBe("auto")
expect(documents[0]?.info.permissions).toEqual([
{ action: "bash", resource: "*", effect: "ask" },
{ action: "shell", resource: "*", effect: "ask" },
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "question", resource: "*", effect: "deny" },

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { which } from "@opencode-ai/core/util/which"
@ -8,56 +8,56 @@ const withShell = async (shell: string | undefined, fn: () => void | Promise<voi
const prev = process.env.SHELL
if (shell === undefined) delete process.env.SHELL
else process.env.SHELL = shell
Shell.acceptable.reset()
Shell.preferred.reset()
ShellSelect.acceptable.reset()
ShellSelect.preferred.reset()
try {
await fn()
} finally {
if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev
Shell.acceptable.reset()
Shell.preferred.reset()
ShellSelect.acceptable.reset()
ShellSelect.preferred.reset()
}
}
describe("shell", () => {
test("normalizes shell names", () => {
expect(Shell.name("/bin/bash")).toBe("bash")
expect(ShellSelect.name("/bin/bash")).toBe("bash")
if (process.platform === "win32") {
expect(Shell.name("C:/tools/NU.EXE")).toBe("nu")
expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh")
expect(ShellSelect.name("C:/tools/NU.EXE")).toBe("nu")
expect(ShellSelect.name("C:/tools/PWSH.EXE")).toBe("pwsh")
}
})
test("detects login shells", () => {
expect(Shell.login("/bin/bash")).toBe(true)
expect(Shell.login("C:/tools/pwsh.exe")).toBe(false)
expect(ShellSelect.login("/bin/bash")).toBe(true)
expect(ShellSelect.login("C:/tools/pwsh.exe")).toBe(false)
})
test("detects posix shells", () => {
expect(Shell.posix("/bin/bash")).toBe(true)
expect(Shell.posix("/bin/fish")).toBe(false)
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
expect(ShellSelect.posix("/bin/bash")).toBe(true)
expect(ShellSelect.posix("/bin/fish")).toBe(false)
expect(ShellSelect.posix("C:/tools/pwsh.exe")).toBe(false)
})
test("falls back when configured shell cannot be resolved", async () => {
await withShell(undefined, async () => {
const preferred = Shell.preferred()
const acceptable = Shell.acceptable()
expect(Shell.preferred("opencode-missing-shell")).toBe(preferred)
expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable)
const preferred = ShellSelect.preferred()
const acceptable = ShellSelect.acceptable()
expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred)
expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable)
})
})
test("falls back for terminal-only acceptable shells", () => {
expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish")
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish")
expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu")
})
test("builds command args per shell family", () => {
expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp")
expect(ShellSelect.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
expect(ShellSelect.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
const zsh = ShellSelect.args("/bin/zsh", "echo hi", "/tmp")
expect(zsh[0]).toBe("-l")
expect(zsh[1]).toBe("-c")
expect(zsh.at(-1)).toBe("/tmp")
@ -66,34 +66,34 @@ describe("shell", () => {
if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {
expect(Shell.name(Shell.acceptable())).not.toBe("nu")
expect(ShellSelect.name(ShellSelect.acceptable())).not.toBe("nu")
})
})
test("normalizes Git Bash shell paths from env", async () => {
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
await withShell(shell, async () => {
expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell))
expect(ShellSelect.preferred()).toBe(FSUtil.windowsPath(shell))
})
})
test("resolves /usr/bin/bash from env to Git Bash", async () => {
const bash = Shell.gitbash()
const bash = ShellSelect.gitbash()
if (!bash) return
await withShell("/usr/bin/bash", async () => {
expect(Shell.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash)
expect(ShellSelect.acceptable()).toBe(bash)
expect(ShellSelect.preferred()).toBe(bash)
})
})
test("resolves bare bash to Git Bash before PATH", async () => {
const bash = Shell.gitbash()
const bash = ShellSelect.gitbash()
if (!bash) return
expect(Shell.acceptable("bash")).toBe(bash)
expect(Shell.preferred("bash")).toBe(bash)
expect(ShellSelect.acceptable("bash")).toBe(bash)
expect(ShellSelect.preferred("bash")).toBe(bash)
await withShell("bash", async () => {
expect(Shell.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash)
expect(ShellSelect.acceptable()).toBe(bash)
expect(ShellSelect.preferred()).toBe(bash)
})
})
@ -101,7 +101,7 @@ describe("shell", () => {
const shell = which("pwsh") || which("powershell")
if (!shell) return
await withShell(path.win32.basename(shell), async () => {
expect(Shell.preferred()).toBe(shell)
expect(ShellSelect.preferred()).toBe(shell)
})
})
}

View file

@ -1,432 +0,0 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_bash_tool_test")
const assertions: PermissionV2.AssertInput[] = []
const runs: Array<{
readonly command: string
readonly cwd?: string
readonly shell?: string | boolean
readonly options?: AppProcess.RunOptions
}> = []
let denyAction: string | undefined
let result: AppProcess.RunResult = {
command: "mock",
exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
}
let runFailure: AppProcess.AppProcessError | undefined
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const appProcess = Layer.succeed(
AppProcess.Service,
AppProcess.Service.of({
run: (command: ChildProcess.Command, options?: AppProcess.RunOptions) =>
Effect.suspend(() => {
if (command._tag !== "StandardCommand") throw new Error("expected standard command")
runs.push({ command: command.command, cwd: command.options.cwd, shell: command.options.shell, options })
return runFailure ? Effect.fail(runFailure) : Effect.succeed(result)
}),
} as unknown as AppProcess.Interface),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([]),
}),
)
const reset = () => {
assertions.length = 0
runs.length = 0
denyAction = undefined
runFailure = undefined
afterPermission = () => Effect.void
result = {
command: "mock",
exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
}
}
const withTool = <A, E, R>(
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
processLayer: Layer.Layer<AppProcess.Service> = appProcess,
) => {
const filesystem = FSUtil.defaultLayer
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const bash = BashTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(processLayer),
Layer.provide(config),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
}
const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input },
})
const it = testEffect(Layer.empty)
describe("BashTool", () => {
it.live("registers and returns structured successful output from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd")
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
result: {
type: "content",
value: [
{ type: "text", text: "hello\n" },
{ type: "text", text: "Command exited with code 0." },
],
},
output: {
structured: {
exit: 0,
truncated: false,
},
content: [
{ type: "text", text: "hello\n" },
{ type: "text", text: "Command exited with code 0." },
],
},
})
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
expect(runs[0]?.options).toMatchObject({
combineOutput: true,
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects a workdir that stops being a directory during approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const workdir = path.join(tmp.path, "src")
afterPermission = (input) =>
input.action === "bash"
? Effect.promise(async () => {
await fs.rm(workdir, { recursive: true })
await fs.writeFile(workdir, "not a directory")
}).pipe(Effect.orDie)
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => {
expect(runs).toEqual([])
expect(assertions.map((input) => input.action)).toEqual(["bash"])
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
if (process.platform !== "win32") {
it.live("executes a real shell command through AppProcess", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(
tmp.path,
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
AppProcess.defaultLayer,
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "core-bash" },
{ type: "text", text: "Command exited with code 0." },
],
})
expect(settled.output?.structured).toMatchObject({
exit: 0,
})
expect(settled.output?.structured).not.toHaveProperty("output")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
}
it.live("approves an explicit external workdir before bash execution", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
return withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "bash"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
expect(runs).toHaveLength(1)
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("does not execute after external-directory or bash denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
expect(runs).toEqual([])
reset()
denyAction = "bash"
yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toEqual([])
}),
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("reports external command arguments as advisory warnings without enforcing approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toHaveLength(1)
expect(settled.output?.structured).toMatchObject({
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 7"),
})
expect(settled.output?.structured).toMatchObject({
exit: 7,
truncated: false,
})
expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("surfaces bounded process-capture truncation", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, outputTruncated: true }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output capture truncated"),
})
expect(settled.output?.structured).not.toHaveProperty("resource")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
expect(settled.output?.structured).toMatchObject({
timeout: true,
truncated: false,
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {
const source = await fs.readFile(new URL("../src/tool/bash.ts", import.meta.url), "utf8")
for (const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.",
"Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
"Persist background job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.",
"Stream full shell output into managed storage while retaining only a bounded in-memory preview.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})

View file

@ -0,0 +1,366 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Shell } from "@opencode-ai/core/shell"
import { ShellTool } from "@opencode-ai/core/tool/shell"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const reset = () => {
assertions.length = 0
denyAction = undefined
afterPermission = () => Effect.void
}
const withTool = <A, E, R>(
data: string,
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
) => {
const filesystem = FSUtil.defaultLayer
const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
Layer.provide(Project.defaultLayer),
)
const global = Global.layerWith({ data, config: path.join(data, "config") })
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(location))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const shellService = Shell.layer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(location),
Layer.provide(Config.locationLayer.pipe(Layer.provide(location), Layer.provide(filesystem), Layer.provide(global))),
Layer.provide(global),
Layer.provide(filesystem),
Layer.provide(AppProcess.defaultLayer),
)
const shell = ShellTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(shellService),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, shell, filesystem)))
}
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "shell", input },
})
const it = testEffect(Layer.empty)
describe("ShellTool", () => {
it.live("registers and returns real successful output from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
reset()
return withTool(data.path, tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["shell"])
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
expect(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).toEqual([])
const settled = yield* settleTool(registry, call({ command: "printf hello" }))
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" })
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: ["printf hello"] }])
}),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withTool(data.path, tmp.path, (registry) => settleTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen((settled) =>
Effect.sync(() =>
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))),
}),
),
),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("rejects a workdir that stops being a directory during approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
reset()
const workdir = path.join(tmp.path, "src")
afterPermission = (input) =>
input.action === "shell"
? Effect.promise(async () => {
await fs.rm(workdir, { recursive: true })
await fs.writeFile(workdir, "not a directory")
}).pipe(Effect.orDie)
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(
withTool(data.path, tmp.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: "src" })),
),
),
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves an explicit external workdir before shell execution", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
([data, active, outside]) => {
reset()
return withTool(data.path, active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([data, active, outside]) =>
Effect.promise(() =>
Promise.all([
data[Symbol.asyncDispose](),
active[Symbol.asyncDispose](),
outside[Symbol.asyncDispose](),
]).then(() => undefined),
),
),
)
it.live("does not execute after external-directory or shell denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
([data, active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(data.path, active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
reset()
denyAction = "shell"
yield* withTool(data.path, active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["shell"])
}),
([data, active, outside]) =>
Effect.promise(() =>
Promise.all([
data[Symbol.asyncDispose](),
active[Symbol.asyncDispose](),
outside[Symbol.asyncDispose](),
]).then(() => undefined),
),
),
)
it.live("reports external command arguments as advisory warnings without enforcing approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
([data, active, outside]) => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(data.path, active.path, (registry) =>
settleTool(registry, call({ command: `cat ${target}` })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["shell"])
expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
})
}),
),
)
},
([data, active, outside]) =>
Effect.promise(() =>
Promise.all([
data[Symbol.asyncDispose](),
active[Symbol.asyncDispose](),
outside[Symbol.asyncDispose](),
]).then(() => undefined),
),
),
)
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
reset()
return withTool(data.path, tmp.path, (registry) =>
settleTool(registry, call({ command: "printf body && exit 7" }, "call-nonzero")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 7, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "body" })
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 7"),
})
}),
),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("truncates the model view and points at the saved output file when output overflows", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withTool(data.path, tmp.path, (registry) =>
settleTool(registry, call({ command: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` }, "call-overflow")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
}),
),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
reset()
return withTool(data.path, tmp.path, (registry) =>
settleTool(registry, call({ command: "sleep 60", timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false })
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
}),
),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {
const source = await fs.readFile(new URL("../src/tool/shell.ts", import.meta.url), "utf8")
for (const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.",
"Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
"Persist background job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.",
"Stream full shell output into managed storage while retaining only a bounded in-memory preview.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})

View file

@ -9,7 +9,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@opencode-ai/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
@ -58,7 +58,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
})
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
return yield* Effect.promise(() => ShellSelect.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {

View file

@ -35,7 +35,7 @@ import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { ShellID } from "@/tool/shell/id"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Truncate } from "@/tool/truncate"
@ -520,8 +520,8 @@ export const layer = Layer.effect(
}).pipe(Effect.ensuring(markReady))
const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell)
const args = Shell.args(sh, input.command, cwd)
const sh = ShellSelect.preferred(cfg.shell)
const args = ShellSelect.args(sh, input.command, cwd)
let output = ""
let aborted = false
@ -1396,7 +1396,7 @@ export const layer = Layer.effect(
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell)
const sh = ShellSelect.preferred(cfg.shell)
const results = yield* Effect.promise(() =>
Promise.all(
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),

View file

@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { ShellID } from "./shell/id"
import * as Truncate from "./truncate"
@ -291,7 +291,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan,
})
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && Shell.ps(shell)) {
if (process.platform === "win32" && ShellSelect.ps(shell)) {
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd,
env,
@ -357,7 +357,7 @@ export const ShellTool = Tool.define(
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) {
if (ShellSelect.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) {
const file = yield* cygpath(shell, text)
if (file) return file
}
@ -387,7 +387,7 @@ export const ShellTool = Tool.define(
patterns: new Set<string>(),
always: new Set<string>(),
}
const shellKind = ShellID.toKind(Shell.name(shell))
const shellKind = ShellID.toKind(ShellSelect.name(shell))
for (const node of commands(root)) {
const command = parts(node)
@ -597,8 +597,8 @@ export const ShellTool = Tool.define(
return () =>
Effect.gen(function* () {
const cfg = yield* config.get()
const shell = Shell.acceptable(cfg.shell)
const name = Shell.name(shell)
const shell = ShellSelect.acceptable(cfg.shell)
const name = ShellSelect.name(shell)
const limits = yield* trunc.limits()
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
yield* Effect.logInfo("shell tool using shell", { shell })
@ -616,7 +616,7 @@ export const ShellTool = Tool.define(
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? defaultTimeoutMs
const ps = Shell.ps(shell)
const ps = ShellSelect.ps(shell)
yield* Effect.scoped(
Effect.gen(function* () {
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>

View file

@ -43,7 +43,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
@ -77,7 +77,7 @@ function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
Effect.sync(() => {
const prev = process.env.SHELL
process.env.SHELL = "/bin/sh"
Shell.preferred.reset()
ShellSelect.preferred.reset()
return prev
}),
() => fx(),
@ -85,7 +85,7 @@ function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
Effect.sync(() => {
if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev
Shell.preferred.reset()
ShellSelect.preferred.reset()
}),
)
}

View file

@ -5,7 +5,7 @@ import type * as Scope from "effect/Scope"
import os from "os"
import path from "path"
import { Config } from "@/config/config"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { ShellTool } from "../../src/tool/shell"
import { Filesystem } from "@/util/filesystem"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
@ -77,25 +77,25 @@ const ctx = {
ask: () => Effect.void,
}
Shell.acceptable.reset()
ShellSelect.acceptable.reset()
const quote = (text: string) => `"${text}"`
const squote = (text: string) => `'${text}'`
const projectRoot = path.join(__dirname, "../..")
const bin = quote(process.execPath.replaceAll("\\", "/"))
const bash = (() => {
const shell = Shell.acceptable()
if (Shell.name(shell) === "bash") return shell
return Shell.gitbash()
const shell = ShellSelect.acceptable()
if (ShellSelect.name(shell) === "bash") return shell
return ShellSelect.gitbash()
})()
const shells = (() => {
if (process.platform !== "win32") {
const shell = Shell.acceptable()
return [{ label: Shell.name(shell), shell }]
const shell = ShellSelect.acceptable()
return [{ label: ShellSelect.name(shell), shell }]
}
const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")]
.filter((shell): shell is string => Boolean(shell))
.map((shell) => ({ label: Shell.name(shell), shell }))
.map((shell) => ({ label: ShellSelect.name(shell), shell }))
return list.filter(
(item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i,
@ -105,7 +105,7 @@ const PS = new Set(["pwsh", "powershell"])
const ps = shells.filter((item) => PS.has(item.label))
const cmdShell = shells.find((item) => item.label === "cmd")
const sh = () => Shell.name(Shell.acceptable())
const sh = () => ShellSelect.name(ShellSelect.acceptable())
const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text))
const fill = (mode: "lines" | "bytes", n: number) => {
@ -133,8 +133,8 @@ const withShell = <A, E, R>(item: { label: string; shell: string }, self: Effect
Effect.sync(() => {
const prev = process.env.SHELL
process.env.SHELL = item.shell
Shell.acceptable.reset()
Shell.preferred.reset()
ShellSelect.acceptable.reset()
ShellSelect.preferred.reset()
return prev
}),
() => self,
@ -142,8 +142,8 @@ const withShell = <A, E, R>(item: { label: string; shell: string }, self: Effect
Effect.sync(() => {
if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev
Shell.acceptable.reset()
Shell.preferred.reset()
ShellSelect.acceptable.reset()
ShellSelect.preferred.reset()
}),
)
@ -196,7 +196,7 @@ describe("tool.shell", () => {
tmp,
Effect.gen(function* () {
const bash = yield* initBash()
const fallback = Shell.name(Shell.acceptable("fish"))
const fallback = ShellSelect.name(ShellSelect.acceptable("fish"))
expect(fallback).not.toBe("fish")
expect(bash.description).toContain(fallback)

View file

@ -15,6 +15,7 @@ import type { Definition } from "@opencode-ai/schema/event"
import { AgentGroup } from "./groups/agent"
import { HealthGroup } from "./groups/health"
import { PtyGroup } from "./groups/pty"
import { ShellGroup } from "./groups/shell"
import { makeQuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
@ -52,6 +53,7 @@ const makeApiFromGroup = <
.add(SkillGroup.middleware(locationMiddleware))
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(ShellGroup.middleware(locationMiddleware))
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(ProjectCopyGroup.middleware(locationMiddleware))

View file

@ -118,3 +118,12 @@ export class PtyNotFoundError extends Schema.TaggedErrorClass<PtyNotFoundError>(
},
{ httpApiStatus: 404 },
) {}
export class ShellNotFoundError extends Schema.TaggedErrorClass<ShellNotFoundError>()(
"ShellNotFoundError",
{
id: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}

View file

@ -0,0 +1,89 @@
import { Shell } from "@opencode-ai/schema/shell"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "../errors"
import { LocationQuery, locationQueryOpenApi } from "./location"
export const ShellGroup = HttpApiGroup.make("server.shell")
.add(
HttpApiEndpoint.get("shell.list", "/api/shell", {
query: LocationQuery,
success: Location.response(Schema.Array(Shell.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.list",
summary: "List running shell commands",
description: "List currently running shell commands for a location. Exited commands are not included.",
}),
),
)
.add(
HttpApiEndpoint.post("shell.create", "/api/shell", {
query: LocationQuery,
payload: Shell.CreateInput,
success: Location.response(Shell.Info),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.create",
summary: "Run shell command",
description:
"Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output.",
}),
),
)
.add(
HttpApiEndpoint.get("shell.get", "/api/shell/:id", {
params: { id: Shell.ID },
query: LocationQuery,
success: Location.response(Shell.Info),
error: ShellNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.get",
summary: "Get shell command",
description: "Get one shell command, including its status and exit code once exited.",
}),
),
)
.add(
HttpApiEndpoint.get("shell.output", "/api/shell/:id/output", {
params: { id: Shell.ID },
query: Schema.Struct({ ...LocationQuery.fields, ...Shell.OutputInput.fields }),
success: Location.response(Shell.Output),
error: ShellNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.output",
summary: "Read shell output",
description: "Page through captured combined output by absolute byte cursor.",
}),
),
)
.add(
HttpApiEndpoint.delete("shell.remove", "/api/shell/:id", {
params: { id: Shell.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: ShellNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.remove",
summary: "Remove shell command",
description: "Terminate and remove one shell command and its retained output.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "shell", description: "Experimental location-scoped shell command routes." }),
)

View file

@ -21,6 +21,7 @@ import { Question } from "./question"
import { QuestionV1 } from "./question-v1"
import { Reference } from "./reference"
import { ServerEvent } from "./server-event"
import { Shell } from "./shell"
import { SessionCompactionEvent } from "./session-compaction-event"
import { SessionEvent } from "./session-event"
import { SessionStatusEvent } from "./session-status-event"
@ -51,6 +52,7 @@ const featureDefinitions = Event.inventory(
...ProjectDirectories.Event.Definitions,
...FileSystemWatcher.Event.Definitions,
...Pty.Event.Definitions,
...Shell.Event.Definitions,
...Question.Event.Definitions,
)

View file

@ -18,6 +18,7 @@ export { Revert } from "./revert"
export { Session } from "./session"
export { SessionInput } from "./session-input"
export { SessionMessage } from "./session-message"
export { Shell } from "./shell"
export { Skill } from "./skill"
export { Pty } from "./pty"
export { PtyTicket } from "./pty-ticket"

View file

@ -0,0 +1,79 @@
export * as Shell from "./shell"
import { Schema } from "effect"
import { optional } from "./schema"
import { define, inventory } from "./event"
import { ascending } from "./identifier"
import { NonNegativeInt, statics } from "./schema"
const IDSchema = Schema.String.check(Schema.isStartsWith("sh_")).pipe(Schema.brand("ShellID"))
export const ID = IDSchema.pipe(
statics((schema: typeof IDSchema) => {
const create = () => schema.make("sh_" + ascending())
return {
create,
ascending: (id?: string) => (id === undefined ? create() : schema.make(id)),
}
}),
)
export type ID = typeof ID.Type
export const Status = Schema.Literals(["running", "exited", "timeout", "killed"])
export type Status = typeof Status.Type
export const Time = Schema.Struct({
started: Schema.Number,
completed: optional(Schema.Number),
})
export interface Time extends Schema.Schema.Type<typeof Time> {}
// Opaque caller-supplied tags echoed back on Info and events. The Shell service never interprets
// these; callers (e.g. ShellTool stores the originating session ID) use them to filter or correlate.
export const Metadata = Schema.Record(Schema.String, Schema.Unknown)
export type Metadata = typeof Metadata.Type
export const Info = Schema.Struct({
id: ID,
status: Status,
command: Schema.String,
cwd: Schema.String,
shell: Schema.String,
// Absolute path of the file capturing combined stdout/stderr. Page through it via `output`.
file: Schema.String,
pid: optional(NonNegativeInt),
exit: optional(Schema.Number),
// Always present; defaults to an empty object when the creator supplies no metadata.
metadata: Metadata,
time: Time,
}).annotate({ identifier: "Shell" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
const Created = define({ type: "shell.created", schema: { info: Info } })
const Exited = define({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } })
const Deleted = define({ type: "shell.deleted", schema: { id: ID } })
export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) }
export const CreateInput = Schema.Struct({
command: Schema.String,
cwd: optional(Schema.String),
timeout: optional(NonNegativeInt),
metadata: optional(Metadata),
})
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const OutputInput = Schema.Struct({
cursor: optional(NonNegativeInt),
limit: optional(NonNegativeInt),
})
export interface OutputInput extends Schema.Schema.Type<typeof OutputInput> {}
export const Output = Schema.Struct({
output: Schema.String,
// Absolute cursor after this page. Equals `size` once fully caught up.
cursor: NonNegativeInt,
// Total bytes captured so far. A consumer has more to page while `cursor < size`.
size: NonNegativeInt,
truncated: Schema.Boolean,
})
export interface Output extends Schema.Schema.Type<typeof Output> {}

View file

@ -385,6 +385,16 @@ import type {
V2SessionSwitchModelResponses,
V2SessionWaitErrors,
V2SessionWaitResponses,
V2ShellCreateErrors,
V2ShellCreateResponses,
V2ShellGetErrors,
V2ShellGetResponses,
V2ShellListErrors,
V2ShellListResponses,
V2ShellOutputErrors,
V2ShellOutputResponses,
V2ShellRemoveErrors,
V2ShellRemoveResponses,
V2SkillListErrors,
V2SkillListResponses,
VcsApplyErrors,
@ -6849,6 +6859,179 @@ export class Pty2 extends HeyApiClient {
}
}
export class Shell extends HeyApiClient {
/**
* List running shell commands
*
* List currently running shell commands for a location. Exited commands are not included.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2ShellListResponses, V2ShellListErrors, ThrowOnError>({
url: "/api/shell",
...options,
...params,
})
}
/**
* Run shell command
*
* Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output.
*/
public create<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
command?: string
cwd?: string
timeout?: number
metadata?: {
[key: string]: unknown
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "location" },
{ in: "body", key: "command" },
{ in: "body", key: "cwd" },
{ in: "body", key: "timeout" },
{ in: "body", key: "metadata" },
],
},
],
)
return (options?.client ?? this.client).post<V2ShellCreateResponses, V2ShellCreateErrors, ThrowOnError>({
url: "/api/shell",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Remove shell command
*
* Terminate and remove one shell command and its retained output.
*/
public remove<ThrowOnError extends boolean = false>(
parameters: {
id: string
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "id" },
{ in: "query", key: "location" },
],
},
],
)
return (options?.client ?? this.client).delete<V2ShellRemoveResponses, V2ShellRemoveErrors, ThrowOnError>({
url: "/api/shell/{id}",
...options,
...params,
})
}
/**
* Get shell command
*
* Get one shell command, including its status and exit code once exited.
*/
public get<ThrowOnError extends boolean = false>(
parameters: {
id: string
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "id" },
{ in: "query", key: "location" },
],
},
],
)
return (options?.client ?? this.client).get<V2ShellGetResponses, V2ShellGetErrors, ThrowOnError>({
url: "/api/shell/{id}",
...options,
...params,
})
}
/**
* Read shell output
*
* Page through captured combined output by absolute byte cursor.
*/
public output<ThrowOnError extends boolean = false>(
parameters: {
id: string
location?: {
directory?: string
workspace?: string
}
cursor?: string
limit?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "id" },
{ in: "query", key: "location" },
{ in: "query", key: "cursor" },
{ in: "query", key: "limit" },
],
},
],
)
return (options?.client ?? this.client).get<V2ShellOutputResponses, V2ShellOutputErrors, ThrowOnError>({
url: "/api/shell/{id}/output",
...options,
...params,
})
}
}
export class Request2 extends HeyApiClient {
/**
* List pending question requests
@ -7095,6 +7278,11 @@ export class V2 extends HeyApiClient {
return (this._pty ??= new Pty2({ client: this.client }))
}
private _shell?: Shell
get shell(): Shell {
return (this._shell ??= new Shell({ client: this.client }))
}
private _question?: Question3
get question(): Question3 {
return (this._question ??= new Question3({ client: this.client }))

View file

@ -65,6 +65,9 @@ export type Event =
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventShellCreated
| EventShellExited
| EventShellDeleted
| EventQuestionV2Asked
| EventQuestionV2Replied
| EventQuestionV2Rejected
@ -643,6 +646,7 @@ export type Prompt = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
system?: string
}
export type Pty = {
@ -656,6 +660,24 @@ export type Pty = {
exitCode?: number
}
export type Shell = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
export type Todo = {
/**
* Brief description of the task
@ -1338,6 +1360,29 @@ export type GlobalEvent = {
id: string
}
}
| {
id: string
type: "shell.created"
properties: {
info: Shell
}
}
| {
id: string
type: "shell.exited"
properties: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
status: "running" | "exited" | "timeout" | "killed"
}
}
| {
id: string
type: "shell.deleted"
properties: {
id: string
}
}
| {
id: string
type: "question.v2.asked"
@ -2715,6 +2760,7 @@ export type PromptInput = {
text: string
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
system?: string
}
export type ConflictError = {
@ -2804,6 +2850,24 @@ export type OutputFormat1 =
retryCount?: number
}
export type Shell1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type SessionStatus2 = {
id: string
metadata?: {
@ -2920,6 +2984,9 @@ export type V2Event =
| PtyUpdated
| PtyExited
| PtyDeleted
| ShellCreated
| ShellExited
| ShellDeleted
| QuestionV2Asked
| QuestionV2Replied
| QuestionV2Rejected
@ -2957,6 +3024,12 @@ export type ForbiddenError = {
message: string
}
export type ShellNotFoundError = {
_tag: "ShellNotFoundError"
id: string
message: string
}
export type ProjectCopyError = {
name: "ProjectCopyError"
data: {
@ -2969,6 +3042,24 @@ export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type Shell2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type EventTuiPromptAppend2 = {
id: string
type: "tui.prompt.append"
@ -4005,6 +4096,7 @@ export type SessionMessageUser = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
system?: string
type: "user"
}
@ -5641,6 +5733,59 @@ export type PtyDeleted = {
}
}
export type ShellCreated = {
id: string
metadata?: {
[key: string]: unknown
}
type: "shell.created"
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
info: Shell1
}
}
export type ShellExited = {
id: string
metadata?: {
[key: string]: unknown
}
type: "shell.exited"
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
status: "running" | "exited" | "timeout" | "killed"
}
}
export type ShellDeleted = {
id: string
metadata?: {
[key: string]: unknown
}
type: "shell.deleted"
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
id: string
}
}
export type QuestionV2Asked = {
id: string
metadata?: {
@ -6859,6 +7004,32 @@ export type EventPtyDeleted = {
}
}
export type EventShellCreated = {
id: string
type: "shell.created"
properties: {
info: Shell2
}
}
export type EventShellExited = {
id: string
type: "shell.exited"
properties: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
status: "running" | "exited" | "timeout" | "killed"
}
}
export type EventShellDeleted = {
id: string
type: "shell.deleted"
properties: {
id: string
}
}
export type EventQuestionV2Asked = {
id: string
type: "question.v2.asked"
@ -13409,6 +13580,220 @@ export type V2PtyConnectResponses = {
export type V2PtyConnectResponse = V2PtyConnectResponses[keyof V2PtyConnectResponses]
export type V2ShellListData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/shell"
}
export type V2ShellListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ShellListError = V2ShellListErrors[keyof V2ShellListErrors]
export type V2ShellListResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: Array<Shell>
}
}
export type V2ShellListResponse = V2ShellListResponses[keyof V2ShellListResponses]
export type V2ShellCreateData = {
body: {
command: string
cwd?: string
timeout?: number
metadata?: {
[key: string]: unknown
}
}
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/shell"
}
export type V2ShellCreateErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ShellCreateError = V2ShellCreateErrors[keyof V2ShellCreateErrors]
export type V2ShellCreateResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: Shell
}
}
export type V2ShellCreateResponse = V2ShellCreateResponses[keyof V2ShellCreateResponses]
export type V2ShellRemoveData = {
body?: never
path: {
id: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/shell/{id}"
}
export type V2ShellRemoveErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ShellNotFoundError
*/
404: ShellNotFoundError
}
export type V2ShellRemoveError = V2ShellRemoveErrors[keyof V2ShellRemoveErrors]
export type V2ShellRemoveResponses = {
/**
* <No Content>
*/
204: void
}
export type V2ShellRemoveResponse = V2ShellRemoveResponses[keyof V2ShellRemoveResponses]
export type V2ShellGetData = {
body?: never
path: {
id: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/shell/{id}"
}
export type V2ShellGetErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ShellNotFoundError
*/
404: ShellNotFoundError
}
export type V2ShellGetError = V2ShellGetErrors[keyof V2ShellGetErrors]
export type V2ShellGetResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: Shell
}
}
export type V2ShellGetResponse = V2ShellGetResponses[keyof V2ShellGetResponses]
export type V2ShellOutputData = {
body?: never
path: {
id: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
cursor?: string
limit?: string
}
url: "/api/shell/{id}/output"
}
export type V2ShellOutputErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ShellNotFoundError
*/
404: ShellNotFoundError
}
export type V2ShellOutputError = V2ShellOutputErrors[keyof V2ShellOutputErrors]
export type V2ShellOutputResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: {
output: string
cursor: number
size: number
truncated: boolean
}
}
}
export type V2ShellOutputResponse = V2ShellOutputResponses[keyof V2ShellOutputResponses]
export type V2QuestionRequestListData = {
body?: never
path?: never

View file

@ -12,6 +12,7 @@ import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { HealthHandler } from "./handlers/health"
import { PtyHandler } from "./handlers/pty"
import { ShellHandler } from "./handlers/shell"
import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference"
import { LocationHandler } from "./handlers/location"
@ -36,6 +37,7 @@ export const handlers = Layer.mergeAll(
SkillHandler,
EventHandler,
PtyHandler,
ShellHandler,
QuestionHandler,
ReferenceHandler,
ProjectCopyHandler,

View file

@ -0,0 +1,69 @@
import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
import { Api } from "../api"
import { response } from "../location"
export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(
"shell.list",
Effect.fn(function* () {
const shell = yield* Shell.Service
return yield* response(shell.list())
}),
)
.handle(
"shell.create",
Effect.fn(function* (ctx) {
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }))
}),
)
.handle(
"shell.get",
Effect.fn(function* (ctx) {
const shell = yield* Shell.Service
return yield* response(
shell.get(ctx.params.id).pipe(
Effect.catchTag(
"Shell.NotFoundError",
() => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }),
),
),
)
}),
)
.handle(
"shell.output",
Effect.fn(function* (ctx) {
const shell = yield* Shell.Service
return yield* response(
shell.output(ctx.params.id, { cursor: ctx.query.cursor, limit: ctx.query.limit }).pipe(
Effect.catchTag(
"Shell.NotFoundError",
() => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }),
),
),
)
}),
)
.handle(
"shell.remove",
Effect.fn(function* (ctx) {
const shell = yield* Shell.Service
yield* shell.remove(ctx.params.id).pipe(
Effect.catchTag(
"Shell.NotFoundError",
() => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }),
),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View file

@ -164,6 +164,9 @@ export function Prompt(props: PromptProps) {
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
.length,
)
const runningShells = createMemo(
() => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
)
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
@ -284,6 +287,20 @@ export function Prompt(props: PromptProps) {
}
})
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
// When empty, the cluster falls back to the hotkey hints.
const statusItems = createMemo(() => {
const agents = activeSubagents()
const shells = runningShells()
const stats = usage()
return [
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
stats?.context,
stats?.cost,
].filter(Boolean)
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
@ -1548,13 +1565,6 @@ export function Prompt(props: PromptProps) {
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<Show when={activeSubagents()}>
{(count) => (
<Spinner color={theme.textMuted}>
{count()} active subagent{count() === 1 ? "" : "s"}
</Spinner>
)}
</Show>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
@ -1624,22 +1634,20 @@ export function Prompt(props: PromptProps) {
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
<Match when={statusItems().length > 0}>
<text fg={theme.textMuted} wrapMode="none">
{statusItems().join(" · ")}
</text>
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>

View file

@ -15,6 +15,7 @@ import type {
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionV2Info,
Shell,
SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2"
@ -47,6 +48,9 @@ type Data = {
permission: Record<string, PermissionSavedInfo[]>
}
location: Record<string, LocationData>
// Currently running shell commands, keyed by shell id. Entries are removed once the command
// exits or is deleted, so this only ever holds in-flight shells.
shell: Record<string, Shell>
}
function locationKey(location: LocationRef) {
@ -72,6 +76,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
permission: {},
},
location: {},
shell: {},
})
const sdk = useSDK()
@ -467,6 +472,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
),
)
break
case "shell.created":
setStore("shell", event.data.info.id, event.data.info)
break
case "shell.exited":
case "shell.deleted":
setStore(
"shell",
produce((draft) => {
delete draft[event.data.id]
}),
)
break
case "reference.updated":
void result.location.reference.refresh()
break
@ -567,6 +584,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
},
shell: {
list() {
return Object.values(store.shell)
},
get(id: string) {
return store.shell[id]
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.shell.list({ location: locationQuery(ref) }, { throwOnError: true })
setStore(
"shell",
produce((draft) => {
for (const info of result.data.data) draft[info.id] = info
}),
)
},
},
location: {
default() {
return defaultLocation()
@ -682,6 +716,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.reference.refresh(),
result.location.command.refresh(),
result.location.skill.refresh(),
result.shell.refresh(),
])
for (const failure of settled.filter((item) => item.status === "rejected"))
console.error("Failed to refresh default location data", failure.reason)

View file

@ -221,8 +221,8 @@ const TIPS: Tip[] = [
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}shell{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
'Set {highlight}"formatter": true{/highlight} in config to enable built-in formatters like prettier, gofmt, and ruff',
@ -256,7 +256,7 @@ const TIPS: Tip[] = [
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}steps{/highlight} to limit agentic iterations per request",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
'Set {highlight}"tools": {"shell": false}{/highlight} to disable specific tools',
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',

View file

@ -1622,7 +1622,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
return (
<Show when={!shouldHide()}>
<Switch>
<Match when={display() === "bash"}>
<Match when={display() === "shell"}>
<Shell {...toolprops} />
</Match>
<Match when={display() === "glob"}>
@ -1646,7 +1646,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
<Match when={display() === "edit"}>
<Edit {...toolprops} />
</Match>
<Match when={display() === "task"}>
<Match when={display() === "subagent"}>
<Task {...toolprops} />
</Match>
<Match when={display() === "apply_patch"}>
@ -2111,8 +2111,8 @@ function Task(props: ToolProps) {
}}
>
{formatSubagentTitle(
Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Task",
Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Subagent",
props.metadata.background === true,
)}
</InlineTool>
@ -2124,7 +2124,7 @@ export function formatSubagentToolcalls(count: number) {
}
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
return `${agent} Task${background ? " (background)" : ""}${description}`
return `${agent} Subagent${background ? " (background)" : ""}${description}`
}
export function formatSubagentRetry(attempt: number, message: string) {
@ -2402,7 +2402,7 @@ function numberValue(value: unknown) {
}
const toolDisplays = new Set([
"bash",
"shell",
"glob",
"read",
"grep",
@ -2410,7 +2410,7 @@ const toolDisplays = new Set([
"websearch",
"write",
"edit",
"task",
"subagent",
"apply_patch",
"todowrite",
"question",
@ -2418,7 +2418,10 @@ const toolDisplays = new Set([
])
export function toolDisplay(tool: string) {
return toolDisplays.has(tool) ? tool : "generic"
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
// them with the renamed views.
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool
return toolDisplays.has(normalized) ? normalized : "generic"
}
function recordValue(value: unknown): Record<string, unknown> | undefined {

View file

@ -287,7 +287,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
}
}
if (permission === "bash") {
if (permission === "shell") {
const command = typeof data.command === "string" ? data.command : ""
return {
body: (
@ -300,12 +300,17 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
}
}
if (permission === "task") {
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown"
if (permission === "subagent" || permission === "task") {
const agent =
typeof data.agent === "string"
? data.agent
: typeof data.subagent_type === "string"
? data.subagent_type
: "Unknown"
const desc = typeof data.description === "string" ? data.description : ""
return {
icon: "#",
title: `${Locale.titlecase(type)} Task`,
title: `${Locale.titlecase(agent)} Subagent`,
body: (
<Show when={desc}>
<box paddingLeft={1}>

View file

@ -20,68 +20,3 @@ exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
"
$ ls
file.ts
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping keeps separation after a padded user message 1`] = `
"
Check whether the next tool remains separated.
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping separates after a multi-line task row 1`] = `
" ✱ Grep "Task" (2 matches)
⠙ Explore Task — Inspect active task spacing
✓ General Task — Confirm completed task spacing
↳ 1 toolcall · 501ms
→ Read src/cli/cmd/tui/routes/session/index.tsx"
`;
exports[`TUI inline tool wrapping separates a task row from a preceding inline detail 1`] = `
" → Read src/cli/cmd/tui/routes/session/index.tsx
↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx
✓ Explore Task — Inspect active task spacing
↳ 1 toolcall · 501ms"
`;
exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = `
" Build · Little Frank · 53.1s
✓ Build Task — Review changes
↳ 48 toolcalls · 1m 40s"
`;
exports[`TUI inline tool wrapping separates an inline row from the previous assistant error 1`] = `
"│
│ Managed inference requires an active Member plan
✓ Build Task — Review changes
↳ 48 toolcalls · 1m 40s"
`;

View file

@ -104,7 +104,11 @@ async function renderFrame(component: () => JSX.Element, options: { width: numbe
describe("TUI inline tool wrapping", () => {
test("falls back for unknown tool names", () => {
expect(toolDisplay("bash")).toBe("bash")
expect(toolDisplay("shell")).toBe("shell")
expect(toolDisplay("subagent")).toBe("subagent")
// Legacy tool names normalize to their renamed views.
expect(toolDisplay("bash")).toBe("shell")
expect(toolDisplay("task")).toBe("subagent")
expect(toolDisplay("plugin_tool")).toBe("generic")
})
@ -169,9 +173,9 @@ describe("TUI inline tool wrapping", () => {
})
test("keeps background state attached to the subagent identity", () => {
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer")
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer")
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
"Explore Task (background) — Inspect renderer",
"Explore Subagent (background) — Inspect renderer",
)
})