refactor(core): replace bash tool with shell tool

This commit is contained in:
Dax Raad 2026-06-29 00:43:04 -04:00
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), 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"] } 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)) raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint16_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0] type Endpoint17_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint16_1Input = { readonly sessionID: Endpoint16_1Request["params"]["sessionID"] } type Endpoint17_1Input = { readonly sessionID: Endpoint17_1Request["params"]["sessionID"] }
const Endpoint16_1 = (raw: RawClient["server.question"]) => (input: Endpoint16_1Input) => const Endpoint17_1 = (raw: RawClient["server.question"]) => (input: Endpoint17_1Input) =>
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint16_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0] type Endpoint17_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint16_2Input = { type Endpoint17_2Input = {
readonly sessionID: Endpoint16_2Request["params"]["sessionID"] readonly sessionID: Endpoint17_2Request["params"]["sessionID"]
readonly requestID: Endpoint16_2Request["params"]["requestID"] readonly requestID: Endpoint17_2Request["params"]["requestID"]
readonly answers: Endpoint16_2Request["payload"]["answers"] 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"]({ raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] }, params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] }, payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint16_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0] type Endpoint17_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint16_3Input = { type Endpoint17_3Input = {
readonly sessionID: Endpoint16_3Request["params"]["sessionID"] readonly sessionID: Endpoint17_3Request["params"]["sessionID"]
readonly requestID: Endpoint16_3Request["params"]["requestID"] 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( raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
const adaptGroup16 = (raw: RawClient["server.question"]) => ({ const adaptGroup17 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint16_0(raw), listRequests: Endpoint17_0(raw),
list: Endpoint16_1(raw), list: Endpoint17_1(raw),
reply: Endpoint16_2(raw), reply: Endpoint17_2(raw),
reject: Endpoint16_3(raw), reject: Endpoint17_3(raw),
}) })
type Endpoint17_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0] type Endpoint18_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
const Endpoint17_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint17_0Input) => const Endpoint18_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint18_0Input) =>
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) 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 Endpoint19_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint18_0Input = { type Endpoint19_0Input = {
readonly projectID: Endpoint18_0Request["params"]["projectID"] readonly projectID: Endpoint19_0Request["params"]["projectID"]
readonly location?: Endpoint18_0Request["query"]["location"] readonly location?: Endpoint19_0Request["query"]["location"]
readonly strategy: Endpoint18_0Request["payload"]["strategy"] readonly strategy: Endpoint19_0Request["payload"]["strategy"]
readonly directory: Endpoint18_0Request["payload"]["directory"] readonly directory: Endpoint19_0Request["payload"]["directory"]
readonly name?: Endpoint18_0Request["payload"]["name"] 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"]({ raw["projectCopy.create"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
query: { location: input["location"] }, query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint18_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0] type Endpoint19_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint18_1Input = { type Endpoint19_1Input = {
readonly projectID: Endpoint18_1Request["params"]["projectID"] readonly projectID: Endpoint19_1Request["params"]["projectID"]
readonly location?: Endpoint18_1Request["query"]["location"] readonly location?: Endpoint19_1Request["query"]["location"]
readonly directory: Endpoint18_1Request["payload"]["directory"] readonly directory: Endpoint19_1Request["payload"]["directory"]
readonly force: Endpoint18_1Request["payload"]["force"] 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"]({ raw["projectCopy.remove"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
query: { location: input["location"] }, query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] }, payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint18_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0] type Endpoint19_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint18_2Input = { type Endpoint19_2Input = {
readonly projectID: Endpoint18_2Request["params"]["projectID"] readonly projectID: Endpoint19_2Request["params"]["projectID"]
readonly location?: Endpoint18_2Request["query"]["location"] 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"]({ raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] }, params: { projectID: input["projectID"] },
query: { location: input["location"] }, query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
const adaptGroup18 = (raw: RawClient["server.projectCopy"]) => ({ const adaptGroup19 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint18_0(raw), create: Endpoint19_0(raw),
remove: Endpoint18_1(raw), remove: Endpoint19_1(raw),
refresh: Endpoint18_2(raw), refresh: Endpoint19_2(raw),
}) })
const adaptClient = (raw: RawClient) => ({ const adaptClient = (raw: RawClient) => ({
@ -726,9 +786,10 @@ const adaptClient = (raw: RawClient) => ({
skills: adaptGroup13(raw["server.skill"]), skills: adaptGroup13(raw["server.skill"]),
events: adaptGroup14(raw["server.event"]), events: adaptGroup14(raw["server.event"]),
ptys: adaptGroup15(raw["server.pty"]), ptys: adaptGroup15(raw["server.pty"]),
questions: adaptGroup16(raw["server.question"]), "server.shell": adaptGroup16(raw["server.shell"]),
references: adaptGroup17(raw["server.reference"]), questions: adaptGroup17(raw["server.question"]),
projectCopies: adaptGroup18(raw["server.projectCopy"]), references: adaptGroup18(raw["server.reference"]),
projectCopies: adaptGroup19(raw["server.projectCopy"]),
}) })
export const make = (options?: { readonly baseUrl?: URL | string }) => export const make = (options?: { readonly baseUrl?: URL | string }) =>

View file

@ -100,6 +100,16 @@ import type {
PtysUpdateOutput, PtysUpdateOutput,
PtysRemoveInput, PtysRemoveInput,
PtysRemoveOutput, PtysRemoveOutput,
ServerShellListInput,
ServerShellListOutput,
ServerShellCreateInput,
ServerShellCreateOutput,
ServerShellGetInput,
ServerShellGetOutput,
ServerShellOutputInput,
ServerShellOutputOutput,
ServerShellRemoveInput,
ServerShellRemoveOutput,
QuestionsListRequestsInput, QuestionsListRequestsInput,
QuestionsListRequestsOutput, QuestionsListRequestsOutput,
QuestionsListInput, QuestionsListInput,
@ -916,6 +926,74 @@ export function make(options: ClientOptions) {
requestOptions, 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: { questions: {
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) => listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionsListRequestsOutput>( request<QuestionsListRequestsOutput>(

View file

@ -94,6 +94,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError => export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "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 = { export type QuestionNotFoundError = {
readonly _tag: "QuestionNotFoundError" readonly _tag: "QuestionNotFoundError"
readonly requestID: string readonly requestID: string
@ -2734,6 +2738,160 @@ export type PtysRemoveInput = {
export type PtysRemoveOutput = void 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 = { export type QuestionsListRequestsInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined 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 { ProjectCopy } from "./project/copy"
import { Pty } from "./pty" import { Pty } from "./pty"
import { QuestionV2 } from "./question" import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference" import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance" import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm" import * as SessionRunnerLLM from "./session/runner/llm"
@ -59,6 +60,7 @@ export const locationServices = LayerNode.group([
FileSystem.node, FileSystem.node,
Watcher.node, Watcher.node,
Pty.node, Pty.node,
Shell.node,
SkillV2.node, SkillV2.node,
SystemContextRegistry.node, SystemContextRegistry.node,
SystemContextBuiltIns.node, SystemContextBuiltIns.node,

View file

@ -8,7 +8,12 @@ import { Global } from "../global"
import { Location } from "../location" import { Location } from "../location"
import { PermissionV2 } from "../permission" 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. const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your strengths: Your strengths:
@ -99,7 +104,7 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service const location = yield* Location.Service
const worktree = location.directory 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 = [ const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" }, { action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map( ...whitelistedDirs.map(

View file

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

View file

@ -1,226 +1,300 @@
export * as Shell from "./shell" export * as Shell from "./shell"
import path from "path" import path from "path"
import { spawn, type ChildProcess } from "child_process" import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { readFile } from "fs/promises" import { ChildProcess } from "effect/unstable/process"
import { statSync } from "fs" import { produce } from "immer"
import { setTimeout as sleep } from "node:timers/promises" import { Shell } from "@opencode-ai/schema/shell"
import { Flag } from "./flag/flag" import { makeLocationNode } from "./effect/app-node"
import { FSUtil } from "./fs-util" import { AppProcess } from "./process"
import { which } from "./util/which" 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 export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = { id: Shell.ID,
bash: { login: true, posix: true }, }) {}
dash: { login: true, posix: true },
fish: { deny: true, login: true }, // Exited processes stay observable (status, exit code, retained output) until removed explicitly.
ksh: { login: true, posix: true }, // Cap retention so abandoned commands do not accumulate unbounded state and output files.
nu: { deny: true }, const EXITED_LIMIT = 25
powershell: { ps: true },
pwsh: { ps: true }, type Info = Shell.Info
sh: { login: true, posix: true },
zsh: { login: true, posix: true }, 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 * Location-owned non-interactive shell command process service.
name: string *
acceptable: boolean * 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> { export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Shell") {}
const pid = proc.pid
if (!pid || opts?.exited?.()) return
if (process.platform === "win32") { export const layer = Layer.effect(
await new Promise<void>((resolve) => { Service,
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { Effect.gen(function* () {
stdio: "ignore", const events = yield* EventV2.Service
windowsHide: true, const location = yield* Location.Service
}) const config = yield* Config.Service
killer.once("exit", () => resolve()) const global = yield* Global.Service
killer.once("error", () => resolve()) 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 { const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
process.kill(-pid, "SIGTERM") const session = sessions.get(id)
await sleep(SIGKILL_TIMEOUT_MS) if (!session) return
if (!opts?.exited?.()) { sessions.delete(id)
process.kill(-pid, "SIGKILL") const index = exitOrder.indexOf(id)
} if (index !== -1) exitOrder.splice(index, 1)
} catch { if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
proc.kill("SIGTERM") // Unblock any wait still pending when the command is removed before it terminated.
await sleep(SIGKILL_TIMEOUT_MS) yield* Deferred.fail(session.done, new NotFoundError({ id }))
if (!opts?.exited?.()) { yield* Effect.promise(() => unlink(session.file).catch(() => {}))
proc.kill("SIGKILL") yield* events.publish(Shell.Event.Deleted, { id })
} })
}
}
function stat(file: string) { const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined yield* require(id)
} yield* removeSession(id)
})
function full(file: string) { const list = Effect.fn("Shell.list")(function* () {
if (process.platform !== "win32") return file return Array.from(sessions.values())
const shell = FSUtil.windowsPath(file) .filter((session) => session.info.status === "running")
if (path.win32.dirname(shell) !== ".") { .map((session) => session.info)
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) { const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return META[name(file)] return (yield* require(id)).info
} })
function ok(file: string) { const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return meta(file)?.deny !== true return yield* Deferred.await((yield* require(id)).done)
} })
function rooted(file: string) { const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
return path.isAbsolute(FSUtil.windowsPath(file)) 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 create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
const shell = full(file) const id = Shell.ID.ascending()
if (rooted(shell)) { const cwd = input.cwd ?? location.directory
if (stat(shell)?.isFile()) return shell const configShell = Config.latest(yield* config.entries(), "shell")
return const shell = ShellSelect.preferred(configShell)
} const args = ShellSelect.args(shell, input.command, cwd)
return which(shell) ?? undefined const file = path.join(outputDir, `${id}.out`)
} const env = {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
function win() { const info: Info = {
return Array.from( id,
new Set( status: "running",
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] command: input.command,
.filter((item): item is string => Boolean(item)) cwd,
.map(full), shell,
), file,
) metadata: input.metadata ?? {},
} time: { started: Date.now() },
}
async function unix() { // Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
const text = await readFile("/etc/shells", "utf8").catch(() => "") // the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#")))) // end). `create` returns once `ready` resolves with the registered session.
return ["/bin/bash", "/bin/zsh", "/bin/sh"] 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 }) { const stream = createWriteStream(file)
if (file && (!opts?.acceptable || ok(file))) { yield* Effect.promise(
const shell = resolve(file) () =>
if (shell) return shell new Promise<void>((resolve) => {
} stream.once("open", () => resolve())
if (process.platform === "win32") return win()[0] stream.once("error", () => resolve())
return fallback() }),
} )
export function gitbash() { const pump = handle.all.pipe(
if (process.platform !== "win32") return Stream.runForEach((chunk: Uint8Array) =>
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH Effect.sync(() => {
const git = which("git") stream.write(chunk)
if (!git) return session.size += chunk.length
const file = path.join(git, "..", "..", "bin", "bash.exe") }),
if (stat(file)?.size) return file ),
} )
runFork(pump.pipe(Effect.catch(() => Effect.void)))
function fallback() { const finish = (status: Info["status"], exit?: number) =>
if (process.platform === "darwin") return "/bin/zsh" Effect.gen(function* () {
const bash = which("bash") if (session.info.status !== "running") return
if (bash) return bash session.info = produce(session.info, (draft) => {
return "/bin/sh" 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 (input.timeout) {
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase() session.timeoutFiber = runFork(
return path.basename(file).toLowerCase() 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) { runFork(
return meta(file)?.login === true handle.exitCode.pipe(
} Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
export function posix(file: string) { yield* events.publish(Shell.Event.Created, { info })
return meta(file)?.posix === true 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) { const session = yield* Deferred.await(ready)
return meta(file)?.ps === true return session.info
} })
function info(file: string): Item { return Service.of({ create, list, get, wait, output, remove })
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) { export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
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 export const node = makeLocationNode({
let defaultAcceptable: string | undefined service: Service,
layer,
export function preferred(configShell?: string) { deps: [EventV2.node, Location.node, Config.node, Global.node, AppProcess.node],
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

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

View file

@ -1,19 +1,17 @@
export * as BashTool from "./bash" export * as ShellTool from "./shell"
import path from "path" import path from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema" import { PositiveInt } from "../schema"
import { Shell } from "../shell"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
export const name = "bash" export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024 export const MAX_CAPTURE_BYTES = 1024 * 1024
@ -44,8 +42,6 @@ const Output = Schema.Struct({
type Output = typeof Output.Type type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
const modelOutput = (output: Output) => { const modelOutput = (output: Output) => {
const warnings = output.warnings?.length const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` ? `\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}.` 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 * Minimal V2 core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core. * legacy shell runtime into core.
@ -93,8 +86,7 @@ export const layer = Layer.effectDiscard(
const tools = yield* Tools.Service const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const appProcess = yield* AppProcess.Service const shell = yield* Shell.Service
const config = yield* Config.Service
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* tools yield* tools
@ -131,7 +123,7 @@ export const layer = Layer.effectDiscard(
}) })
const warnings = externalCommandDirectories(input.command, target.canonical).map( const warnings = externalCommandDirectories(input.command, target.canonical).map(
(directory) => (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({ yield* permission.assert({
action: name, action: name,
@ -145,30 +137,20 @@ export const layer = Layer.effectDiscard(
if ((yield* fs.stat(target.canonical)).type !== "Directory") if ((yield* fs.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const entries = yield* config.entries() // Delegate spawning, combined-output capture, timeout, and exit tracking to the Shell
const shell = // service. The full output is captured to a file; we read a bounded page for the model
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))) // and point the agent at the file when it overflows the model cap.
.shell ?? defaultShell()
const command = ChildProcess.make(input.command, [], {
cwd: target.canonical,
shell,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
})
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess const info = yield* shell.create({
.run(command, { command: input.command,
combineOutput: true, cwd: target.canonical,
timeout: Duration.millis(timeout), timeout,
maxOutputBytes: MAX_CAPTURE_BYTES, metadata: { sessionID: context.sessionID },
}) })
.pipe( const final = yield* shell.wait(info.id)
Effect.catchTag("AppProcessError", (error) => const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
), if (final.status === "timeout") {
)
if (!result) {
return { return {
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false, truncated: false,
@ -177,14 +159,13 @@ export const layer = Layer.effectDiscard(
} }
} }
const output = result.output?.toString("utf8") || "(no output)" const truncated = page.size > page.cursor
const notice = result.outputTruncated const body = page.output || "(no output)"
? "[output capture truncated at the in-memory safety limit]" const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
: undefined
return { return {
exit: result.exitCode, exit: final.exit,
output: notice ? `${output}\n\n${notice}` : output, output: `${body}${notice}`,
truncated: result.outputTruncated === true, truncated,
...(warnings.length ? { warnings } : {}), ...(warnings.length ? { warnings } : {}),
} }
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }).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: "*", resource: "*",
effect: enabled ? ("allow" as const) : ("deny" as const), 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 if (!rule) continue
const action = normalizeAction(key)
if (typeof rule === "string") { if (typeof rule === "string") {
rules.push({ action, resource: "*", effect: rule }) rules.push({ action, resource: "*", effect: rule })
continue continue
@ -90,8 +91,12 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
return rules.length ? rules : undefined 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) { 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) { 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", () => it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
@ -566,7 +587,7 @@ describe("Config", () => {
expect(documents[0]?.info.snapshots).toBe(false) expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.share).toBe("auto") expect(documents[0]?.info.share).toBe("auto")
expect(documents[0]?.info.permissions).toEqual([ 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: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" }, { action: "edit", resource: "*", effect: "deny" },
{ action: "question", resource: "*", effect: "deny" }, { action: "question", resource: "*", effect: "deny" },

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" 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 { FSUtil } from "@opencode-ai/core/fs-util"
import { which } from "@opencode-ai/core/util/which" 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 const prev = process.env.SHELL
if (shell === undefined) delete process.env.SHELL if (shell === undefined) delete process.env.SHELL
else process.env.SHELL = shell else process.env.SHELL = shell
Shell.acceptable.reset() ShellSelect.acceptable.reset()
Shell.preferred.reset() ShellSelect.preferred.reset()
try { try {
await fn() await fn()
} finally { } finally {
if (prev === undefined) delete process.env.SHELL if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev else process.env.SHELL = prev
Shell.acceptable.reset() ShellSelect.acceptable.reset()
Shell.preferred.reset() ShellSelect.preferred.reset()
} }
} }
describe("shell", () => { describe("shell", () => {
test("normalizes shell names", () => { test("normalizes shell names", () => {
expect(Shell.name("/bin/bash")).toBe("bash") expect(ShellSelect.name("/bin/bash")).toBe("bash")
if (process.platform === "win32") { if (process.platform === "win32") {
expect(Shell.name("C:/tools/NU.EXE")).toBe("nu") expect(ShellSelect.name("C:/tools/NU.EXE")).toBe("nu")
expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh") expect(ShellSelect.name("C:/tools/PWSH.EXE")).toBe("pwsh")
} }
}) })
test("detects login shells", () => { test("detects login shells", () => {
expect(Shell.login("/bin/bash")).toBe(true) expect(ShellSelect.login("/bin/bash")).toBe(true)
expect(Shell.login("C:/tools/pwsh.exe")).toBe(false) expect(ShellSelect.login("C:/tools/pwsh.exe")).toBe(false)
}) })
test("detects posix shells", () => { test("detects posix shells", () => {
expect(Shell.posix("/bin/bash")).toBe(true) expect(ShellSelect.posix("/bin/bash")).toBe(true)
expect(Shell.posix("/bin/fish")).toBe(false) expect(ShellSelect.posix("/bin/fish")).toBe(false)
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false) expect(ShellSelect.posix("C:/tools/pwsh.exe")).toBe(false)
}) })
test("falls back when configured shell cannot be resolved", async () => { test("falls back when configured shell cannot be resolved", async () => {
await withShell(undefined, async () => { await withShell(undefined, async () => {
const preferred = Shell.preferred() const preferred = ShellSelect.preferred()
const acceptable = Shell.acceptable() const acceptable = ShellSelect.acceptable()
expect(Shell.preferred("opencode-missing-shell")).toBe(preferred) expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred)
expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable) expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable)
}) })
}) })
test("falls back for terminal-only acceptable shells", () => { test("falls back for terminal-only acceptable shells", () => {
expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish") expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish")
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu") expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu")
}) })
test("builds command args per shell family", () => { test("builds command args per shell family", () => {
expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) expect(ShellSelect.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) expect(ShellSelect.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp") const zsh = ShellSelect.args("/bin/zsh", "echo hi", "/tmp")
expect(zsh[0]).toBe("-l") expect(zsh[0]).toBe("-l")
expect(zsh[1]).toBe("-c") expect(zsh[1]).toBe("-c")
expect(zsh.at(-1)).toBe("/tmp") expect(zsh.at(-1)).toBe("/tmp")
@ -66,34 +66,34 @@ describe("shell", () => {
if (process.platform === "win32") { if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => { test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", 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 () => { test("normalizes Git Bash shell paths from env", async () => {
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe" const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
await withShell(shell, async () => { 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 () => { test("resolves /usr/bin/bash from env to Git Bash", async () => {
const bash = Shell.gitbash() const bash = ShellSelect.gitbash()
if (!bash) return if (!bash) return
await withShell("/usr/bin/bash", async () => { await withShell("/usr/bin/bash", async () => {
expect(Shell.acceptable()).toBe(bash) expect(ShellSelect.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash) expect(ShellSelect.preferred()).toBe(bash)
}) })
}) })
test("resolves bare bash to Git Bash before PATH", async () => { test("resolves bare bash to Git Bash before PATH", async () => {
const bash = Shell.gitbash() const bash = ShellSelect.gitbash()
if (!bash) return if (!bash) return
expect(Shell.acceptable("bash")).toBe(bash) expect(ShellSelect.acceptable("bash")).toBe(bash)
expect(Shell.preferred("bash")).toBe(bash) expect(ShellSelect.preferred("bash")).toBe(bash)
await withShell("bash", async () => { await withShell("bash", async () => {
expect(Shell.acceptable()).toBe(bash) expect(ShellSelect.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash) expect(ShellSelect.preferred()).toBe(bash)
}) })
}) })
@ -101,7 +101,7 @@ describe("shell", () => {
const shell = which("pwsh") || which("powershell") const shell = which("pwsh") || which("powershell")
if (!shell) return if (!shell) return
await withShell(path.win32.basename(shell), async () => { 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 { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" 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 { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@opencode-ai/server/cors"
import { import {
PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TICKET_QUERY,
@ -58,7 +58,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
}) })
const shells = Effect.fn("PtyHttpApi.shells")(function* () { 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* () { const list = Effect.fn("PtyHttpApi.list")(function* () {

View file

@ -35,7 +35,7 @@ import { Tool } from "@/tool/tool"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
import { LLM } from "./llm" 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 { ShellID } from "@/tool/shell/id"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
@ -520,8 +520,8 @@ export const layer = Layer.effect(
}).pipe(Effect.ensuring(markReady)) }).pipe(Effect.ensuring(markReady))
const cfg = yield* config.get() const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell) const sh = ShellSelect.preferred(cfg.shell)
const args = Shell.args(sh, input.command, cwd) const args = ShellSelect.args(sh, input.command, cwd)
let output = "" let output = ""
let aborted = false let aborted = false
@ -1396,7 +1396,7 @@ export const layer = Layer.effect(
const shellMatches = ConfigMarkdown.shell(template) const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) { if (shellMatches.length > 0) {
const cfg = yield* config.get() const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell) const sh = ShellSelect.preferred(cfg.shell)
const results = yield* Effect.promise(() => const results = yield* Effect.promise(() =>
Promise.all( Promise.all(
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text), 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 { fileURLToPath } from "url"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags" 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 { ShellID } from "./shell/id"
import * as Truncate from "./truncate" 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) { 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], { return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd, cwd,
env, env,
@ -357,7 +357,7 @@ export const ShellTool = Tool.define(
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (process.platform === "win32") { 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) const file = yield* cygpath(shell, text)
if (file) return file if (file) return file
} }
@ -387,7 +387,7 @@ export const ShellTool = Tool.define(
patterns: new Set<string>(), patterns: new Set<string>(),
always: 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)) { for (const node of commands(root)) {
const command = parts(node) const command = parts(node)
@ -597,8 +597,8 @@ export const ShellTool = Tool.define(
return () => return () =>
Effect.gen(function* () { Effect.gen(function* () {
const cfg = yield* config.get() const cfg = yield* config.get()
const shell = Shell.acceptable(cfg.shell) const shell = ShellSelect.acceptable(cfg.shell)
const name = Shell.name(shell) const name = ShellSelect.name(shell)
const limits = yield* trunc.limits() const limits = yield* trunc.limits()
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs) const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
yield* Effect.logInfo("shell tool using shell", { shell }) 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.`) throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
} }
const timeout = params.timeout ?? defaultTimeoutMs const timeout = params.timeout ?? defaultTimeoutMs
const ps = Shell.ps(shell) const ps = ShellSelect.ps(shell)
yield* Effect.scoped( yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => 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 { SessionExecution } from "@opencode-ai/core/session/execution"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system" 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 { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
@ -77,7 +77,7 @@ function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
Effect.sync(() => { Effect.sync(() => {
const prev = process.env.SHELL const prev = process.env.SHELL
process.env.SHELL = "/bin/sh" process.env.SHELL = "/bin/sh"
Shell.preferred.reset() ShellSelect.preferred.reset()
return prev return prev
}), }),
() => fx(), () => fx(),
@ -85,7 +85,7 @@ function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
Effect.sync(() => { Effect.sync(() => {
if (prev === undefined) delete process.env.SHELL if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev 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 os from "os"
import path from "path" import path from "path"
import { Config } from "@/config/config" 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 { ShellTool } from "../../src/tool/shell"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
@ -77,25 +77,25 @@ const ctx = {
ask: () => Effect.void, ask: () => Effect.void,
} }
Shell.acceptable.reset() ShellSelect.acceptable.reset()
const quote = (text: string) => `"${text}"` const quote = (text: string) => `"${text}"`
const squote = (text: string) => `'${text}'` const squote = (text: string) => `'${text}'`
const projectRoot = path.join(__dirname, "../..") const projectRoot = path.join(__dirname, "../..")
const bin = quote(process.execPath.replaceAll("\\", "/")) const bin = quote(process.execPath.replaceAll("\\", "/"))
const bash = (() => { const bash = (() => {
const shell = Shell.acceptable() const shell = ShellSelect.acceptable()
if (Shell.name(shell) === "bash") return shell if (ShellSelect.name(shell) === "bash") return shell
return Shell.gitbash() return ShellSelect.gitbash()
})() })()
const shells = (() => { const shells = (() => {
if (process.platform !== "win32") { if (process.platform !== "win32") {
const shell = Shell.acceptable() const shell = ShellSelect.acceptable()
return [{ label: Shell.name(shell), shell }] return [{ label: ShellSelect.name(shell), shell }]
} }
const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")] const list = [bash, Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || Bun.which("cmd.exe")]
.filter((shell): shell is string => Boolean(shell)) .filter((shell): shell is string => Boolean(shell))
.map((shell) => ({ label: Shell.name(shell), shell })) .map((shell) => ({ label: ShellSelect.name(shell), shell }))
return list.filter( return list.filter(
(item, i) => list.findIndex((other) => other.shell.toLowerCase() === item.shell.toLowerCase()) === i, (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 ps = shells.filter((item) => PS.has(item.label))
const cmdShell = shells.find((item) => item.label === "cmd") 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 evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text))
const fill = (mode: "lines" | "bytes", n: number) => { 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(() => { Effect.sync(() => {
const prev = process.env.SHELL const prev = process.env.SHELL
process.env.SHELL = item.shell process.env.SHELL = item.shell
Shell.acceptable.reset() ShellSelect.acceptable.reset()
Shell.preferred.reset() ShellSelect.preferred.reset()
return prev return prev
}), }),
() => self, () => self,
@ -142,8 +142,8 @@ const withShell = <A, E, R>(item: { label: string; shell: string }, self: Effect
Effect.sync(() => { Effect.sync(() => {
if (prev === undefined) delete process.env.SHELL if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev else process.env.SHELL = prev
Shell.acceptable.reset() ShellSelect.acceptable.reset()
Shell.preferred.reset() ShellSelect.preferred.reset()
}), }),
) )
@ -196,7 +196,7 @@ describe("tool.shell", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const bash = yield* initBash() const bash = yield* initBash()
const fallback = Shell.name(Shell.acceptable("fish")) const fallback = ShellSelect.name(ShellSelect.acceptable("fish"))
expect(fallback).not.toBe("fish") expect(fallback).not.toBe("fish")
expect(bash.description).toContain(fallback) 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 { AgentGroup } from "./groups/agent"
import { HealthGroup } from "./groups/health" import { HealthGroup } from "./groups/health"
import { PtyGroup } from "./groups/pty" import { PtyGroup } from "./groups/pty"
import { ShellGroup } from "./groups/shell"
import { makeQuestionGroup } from "./groups/question" import { makeQuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference" import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization" import { Authorization } from "./middleware/authorization"
@ -52,6 +53,7 @@ const makeApiFromGroup = <
.add(SkillGroup.middleware(locationMiddleware)) .add(SkillGroup.middleware(locationMiddleware))
.add(eventGroup) .add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware)) .add(PtyGroup.middleware(locationMiddleware))
.add(ShellGroup.middleware(locationMiddleware))
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware)) .add(ReferenceGroup.middleware(locationMiddleware))
.add(ProjectCopyGroup.middleware(locationMiddleware)) .add(ProjectCopyGroup.middleware(locationMiddleware))

View file

@ -118,3 +118,12 @@ export class PtyNotFoundError extends Schema.TaggedErrorClass<PtyNotFoundError>(
}, },
{ httpApiStatus: 404 }, { 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 { QuestionV1 } from "./question-v1"
import { Reference } from "./reference" import { Reference } from "./reference"
import { ServerEvent } from "./server-event" import { ServerEvent } from "./server-event"
import { Shell } from "./shell"
import { SessionCompactionEvent } from "./session-compaction-event" import { SessionCompactionEvent } from "./session-compaction-event"
import { SessionEvent } from "./session-event" import { SessionEvent } from "./session-event"
import { SessionStatusEvent } from "./session-status-event" import { SessionStatusEvent } from "./session-status-event"
@ -51,6 +52,7 @@ const featureDefinitions = Event.inventory(
...ProjectDirectories.Event.Definitions, ...ProjectDirectories.Event.Definitions,
...FileSystemWatcher.Event.Definitions, ...FileSystemWatcher.Event.Definitions,
...Pty.Event.Definitions, ...Pty.Event.Definitions,
...Shell.Event.Definitions,
...Question.Event.Definitions, ...Question.Event.Definitions,
) )

View file

@ -18,6 +18,7 @@ export { Revert } from "./revert"
export { Session } from "./session" export { Session } from "./session"
export { SessionInput } from "./session-input" export { SessionInput } from "./session-input"
export { SessionMessage } from "./session-message" export { SessionMessage } from "./session-message"
export { Shell } from "./shell"
export { Skill } from "./skill" export { Skill } from "./skill"
export { Pty } from "./pty" export { Pty } from "./pty"
export { PtyTicket } from "./pty-ticket" 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, V2SessionSwitchModelResponses,
V2SessionWaitErrors, V2SessionWaitErrors,
V2SessionWaitResponses, V2SessionWaitResponses,
V2ShellCreateErrors,
V2ShellCreateResponses,
V2ShellGetErrors,
V2ShellGetResponses,
V2ShellListErrors,
V2ShellListResponses,
V2ShellOutputErrors,
V2ShellOutputResponses,
V2ShellRemoveErrors,
V2ShellRemoveResponses,
V2SkillListErrors, V2SkillListErrors,
V2SkillListResponses, V2SkillListResponses,
VcsApplyErrors, 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 { export class Request2 extends HeyApiClient {
/** /**
* List pending question requests * List pending question requests
@ -7095,6 +7278,11 @@ export class V2 extends HeyApiClient {
return (this._pty ??= new Pty2({ client: this.client })) 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 private _question?: Question3
get question(): Question3 { get question(): Question3 {
return (this._question ??= new Question3({ client: this.client })) return (this._question ??= new Question3({ client: this.client }))

View file

@ -65,6 +65,9 @@ export type Event =
| EventPtyUpdated | EventPtyUpdated
| EventPtyExited | EventPtyExited
| EventPtyDeleted | EventPtyDeleted
| EventShellCreated
| EventShellExited
| EventShellDeleted
| EventQuestionV2Asked | EventQuestionV2Asked
| EventQuestionV2Replied | EventQuestionV2Replied
| EventQuestionV2Rejected | EventQuestionV2Rejected
@ -643,6 +646,7 @@ export type Prompt = {
text: string text: string
files?: Array<PromptFileAttachment> files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment> agents?: Array<PromptAgentAttachment>
system?: string
} }
export type Pty = { export type Pty = {
@ -656,6 +660,24 @@ export type Pty = {
exitCode?: number 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 = { export type Todo = {
/** /**
* Brief description of the task * Brief description of the task
@ -1338,6 +1360,29 @@ export type GlobalEvent = {
id: string 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 id: string
type: "question.v2.asked" type: "question.v2.asked"
@ -2715,6 +2760,7 @@ export type PromptInput = {
text: string text: string
files?: Array<PromptInputFileAttachment> files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment> agents?: Array<PromptAgentAttachment>
system?: string
} }
export type ConflictError = { export type ConflictError = {
@ -2804,6 +2850,24 @@ export type OutputFormat1 =
retryCount?: number 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 = { export type SessionStatus2 = {
id: string id: string
metadata?: { metadata?: {
@ -2920,6 +2984,9 @@ export type V2Event =
| PtyUpdated | PtyUpdated
| PtyExited | PtyExited
| PtyDeleted | PtyDeleted
| ShellCreated
| ShellExited
| ShellDeleted
| QuestionV2Asked | QuestionV2Asked
| QuestionV2Replied | QuestionV2Replied
| QuestionV2Rejected | QuestionV2Rejected
@ -2957,6 +3024,12 @@ export type ForbiddenError = {
message: string message: string
} }
export type ShellNotFoundError = {
_tag: "ShellNotFoundError"
id: string
message: string
}
export type ProjectCopyError = { export type ProjectCopyError = {
name: "ProjectCopyError" name: "ProjectCopyError"
data: { data: {
@ -2969,6 +3042,24 @@ export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden" _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 = { export type EventTuiPromptAppend2 = {
id: string id: string
type: "tui.prompt.append" type: "tui.prompt.append"
@ -4005,6 +4096,7 @@ export type SessionMessageUser = {
text: string text: string
files?: Array<PromptFileAttachment> files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment> agents?: Array<PromptAgentAttachment>
system?: string
type: "user" 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 = { export type QuestionV2Asked = {
id: string id: string
metadata?: { 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 = { export type EventQuestionV2Asked = {
id: string id: string
type: "question.v2.asked" type: "question.v2.asked"
@ -13409,6 +13580,220 @@ export type V2PtyConnectResponses = {
export type V2PtyConnectResponse = V2PtyConnectResponses[keyof 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 = { export type V2QuestionRequestListData = {
body?: never body?: never
path?: never path?: never

View file

@ -12,6 +12,7 @@ import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent" import { AgentHandler } from "./handlers/agent"
import { HealthHandler } from "./handlers/health" import { HealthHandler } from "./handlers/health"
import { PtyHandler } from "./handlers/pty" import { PtyHandler } from "./handlers/pty"
import { ShellHandler } from "./handlers/shell"
import { QuestionHandler } from "./handlers/question" import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference" import { ReferenceHandler } from "./handlers/reference"
import { LocationHandler } from "./handlers/location" import { LocationHandler } from "./handlers/location"
@ -36,6 +37,7 @@ export const handlers = Layer.mergeAll(
SkillHandler, SkillHandler,
EventHandler, EventHandler,
PtyHandler, PtyHandler,
ShellHandler,
QuestionHandler, QuestionHandler,
ReferenceHandler, ReferenceHandler,
ProjectCopyHandler, 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") .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
.length, .length,
) )
const runningShells = createMemo(
() => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
)
const history = usePromptHistory() const history = usePromptHistory()
const stash = usePromptStash() const stash = usePromptStash()
const keymap = useOpencodeKeymap() 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<{ const [store, setStore] = createStore<{
prompt: PromptInfo prompt: PromptInfo
mode: "normal" | "shell" mode: "normal" | "shell"
@ -1548,13 +1565,6 @@ export function Prompt(props: PromptProps) {
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} /> <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show> </Show>
</box> </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}> <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "} esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}> <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
@ -1624,22 +1634,20 @@ export function Prompt(props: PromptProps) {
<Switch> <Switch>
<Match when={store.mode === "normal"}> <Match when={store.mode === "normal"}>
<Switch> <Switch>
<Match when={usage()}> <Match when={statusItems().length > 0}>
{(item) => ( <text fg={theme.textMuted} wrapMode="none">
<text fg={theme.textMuted} wrapMode="none"> {statusItems().join(" · ")}
{[item().context, item().cost].filter(Boolean).join(" · ")} </text>
</text>
)}
</Match> </Match>
<Match when={true}> <Match when={true}>
<text fg={theme.text}> <text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span> {agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text> </text>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match> </Match>
</Switch> </Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match> </Match>
<Match when={store.mode === "shell"}> <Match when={store.mode === "shell"}>
<text fg={theme.text}> <text fg={theme.text}>

View file

@ -15,6 +15,7 @@ import type {
SessionMessageAssistantText, SessionMessageAssistantText,
SessionMessageAssistantTool, SessionMessageAssistantTool,
SessionV2Info, SessionV2Info,
Shell,
SkillV2Info, SkillV2Info,
V2Event, V2Event,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
@ -47,6 +48,9 @@ type Data = {
permission: Record<string, PermissionSavedInfo[]> permission: Record<string, PermissionSavedInfo[]>
} }
location: Record<string, LocationData> 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) { function locationKey(location: LocationRef) {
@ -72,6 +76,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
permission: {}, permission: {},
}, },
location: {}, location: {},
shell: {},
}) })
const sdk = useSDK() const sdk = useSDK()
@ -467,6 +472,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
), ),
) )
break 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": case "reference.updated":
void result.location.reference.refresh() void result.location.reference.refresh()
break 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: { location: {
default() { default() {
return defaultLocation() return defaultLocation()
@ -682,6 +716,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.reference.refresh(), result.location.reference.refresh(),
result.location.command.refresh(), result.location.command.refresh(),
result.location.skill.refresh(), result.location.skill.refresh(),
result.shell.refresh(),
]) ])
for (const failure of settled.filter((item) => item.status === "rejected")) for (const failure of settled.filter((item) => item.status === "rejected"))
console.error("Failed to refresh default location data", failure.reason) 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 {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})", "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", "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", "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 bash permissions', 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular shell permissions',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands', 'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing', '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', '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", "Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)", "Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}steps{/highlight} to limit agentic iterations per request", "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', 'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration", "Override global tool settings per agent configuration",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions', 'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',

View file

@ -1622,7 +1622,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
return ( return (
<Show when={!shouldHide()}> <Show when={!shouldHide()}>
<Switch> <Switch>
<Match when={display() === "bash"}> <Match when={display() === "shell"}>
<Shell {...toolprops} /> <Shell {...toolprops} />
</Match> </Match>
<Match when={display() === "glob"}> <Match when={display() === "glob"}>
@ -1646,7 +1646,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
<Match when={display() === "edit"}> <Match when={display() === "edit"}>
<Edit {...toolprops} /> <Edit {...toolprops} />
</Match> </Match>
<Match when={display() === "task"}> <Match when={display() === "subagent"}>
<Task {...toolprops} /> <Task {...toolprops} />
</Match> </Match>
<Match when={display() === "apply_patch"}> <Match when={display() === "apply_patch"}>
@ -2111,8 +2111,8 @@ function Task(props: ToolProps) {
}} }}
> >
{formatSubagentTitle( {formatSubagentTitle(
Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"), Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Task", description() ?? "Subagent",
props.metadata.background === true, props.metadata.background === true,
)} )}
</InlineTool> </InlineTool>
@ -2124,7 +2124,7 @@ export function formatSubagentToolcalls(count: number) {
} }
export function formatSubagentTitle(agent: string, description: string, background: boolean) { 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) { export function formatSubagentRetry(attempt: number, message: string) {
@ -2402,7 +2402,7 @@ function numberValue(value: unknown) {
} }
const toolDisplays = new Set([ const toolDisplays = new Set([
"bash", "shell",
"glob", "glob",
"read", "read",
"grep", "grep",
@ -2410,7 +2410,7 @@ const toolDisplays = new Set([
"websearch", "websearch",
"write", "write",
"edit", "edit",
"task", "subagent",
"apply_patch", "apply_patch",
"todowrite", "todowrite",
"question", "question",
@ -2418,7 +2418,10 @@ const toolDisplays = new Set([
]) ])
export function toolDisplay(tool: string) { 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 { 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 : "" const command = typeof data.command === "string" ? data.command : ""
return { return {
body: ( body: (
@ -300,12 +300,17 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
} }
} }
if (permission === "task") { if (permission === "subagent" || permission === "task") {
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" 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 : "" const desc = typeof data.description === "string" ? data.description : ""
return { return {
icon: "#", icon: "#",
title: `${Locale.titlecase(type)} Task`, title: `${Locale.titlecase(agent)} Subagent`,
body: ( body: (
<Show when={desc}> <Show when={desc}>
<box paddingLeft={1}> <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\\. ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)" 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", () => { describe("TUI inline tool wrapping", () => {
test("falls back for unknown tool names", () => { 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") expect(toolDisplay("plugin_tool")).toBe("generic")
}) })
@ -169,9 +173,9 @@ describe("TUI inline tool wrapping", () => {
}) })
test("keeps background state attached to the subagent identity", () => { 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( expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
"Explore Task (background) — Inspect renderer", "Explore Subagent (background) — Inspect renderer",
) )
}) })