refactor(core): replace bash tool with shell tool
This commit is contained in:
parent
595c6bd4a7
commit
5ae93092aa
37 changed files with 2260 additions and 901 deletions
|
|
@ -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 }) =>
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
226
packages/core/src/shell/select.ts
Normal file
226
packages/core/src/shell/select.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}` }))),
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
366
packages/core/test/tool-shell.test.ts
Normal file
366
packages/core/test/tool-shell.test.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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* () {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
) {}
|
||||||
|
|
|
||||||
89
packages/protocol/src/groups/shell.ts
Normal file
89
packages/protocol/src/groups/shell.ts
Normal 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." }),
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
79
packages/schema/src/shell.ts
Normal file
79
packages/schema/src/shell.ts
Normal 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> {}
|
||||||
|
|
@ -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 }))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
69
packages/server/src/handlers/shell.ts
Normal file
69
packages/server/src/handlers/shell.ts
Normal 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()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
`;
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue