feat(plugin): add shell.create.before hook (#39547)
This commit is contained in:
parent
4f871906bc
commit
c8dca936b1
11 changed files with 142 additions and 49 deletions
|
|
@ -2,6 +2,7 @@ export * as PluginHooks from "./hooks"
|
||||||
|
|
||||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||||
|
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
|
@ -10,6 +11,7 @@ import { State } from "../state"
|
||||||
export interface Domains {
|
export interface Domains {
|
||||||
readonly aisdk: AISDKHooks
|
readonly aisdk: AISDKHooks
|
||||||
readonly session: SessionHooks
|
readonly session: SessionHooks
|
||||||
|
readonly shell: ShellHooks
|
||||||
readonly tool: ToolHooks
|
readonly tool: ToolHooks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
shell: {
|
||||||
|
hook: (name, callback) => hooks.register("shell", name, callback),
|
||||||
|
},
|
||||||
tool: {
|
tool: {
|
||||||
transform: (callback) =>
|
transform: (callback) =>
|
||||||
tools
|
tools
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,10 @@ export function fromPromise(plugin: Plugin) {
|
||||||
),
|
),
|
||||||
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
|
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
|
||||||
},
|
},
|
||||||
|
shell: {
|
||||||
|
hook: (name, callback) =>
|
||||||
|
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import { Bus } from "./bus"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { ShellSelect } from "./shell/select"
|
import { ShellSelect } from "./shell/select"
|
||||||
|
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
|
||||||
|
import { PluginHooks } from "./plugin/hooks"
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
|
||||||
id: Shell.ID,
|
id: Shell.ID,
|
||||||
|
|
@ -45,7 +47,10 @@ type Active = {
|
||||||
*/
|
*/
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly name: () => Effect.Effect<string>
|
readonly name: () => Effect.Effect<string>
|
||||||
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
|
readonly create: <E = never, R = never>(
|
||||||
|
input: Shell.CreateInput,
|
||||||
|
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||||
|
) => Effect.Effect<Shell.Info, E, R>
|
||||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||||
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
||||||
|
|
@ -68,6 +73,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const appProcess = yield* AppProcess.Service
|
const appProcess = yield* AppProcess.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
const context = yield* Effect.context()
|
const context = yield* Effect.context()
|
||||||
const runFork = Effect.runForkWith(context)
|
const runFork = Effect.runForkWith(context)
|
||||||
const sessions = new Map<string, Active>()
|
const sessions = new Map<string, Active>()
|
||||||
|
|
@ -172,24 +178,34 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
|
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||||
|
input: Shell.CreateInput,
|
||||||
|
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||||
|
) {
|
||||||
|
const invocation: ShellCreateBefore = {
|
||||||
|
command: input.command,
|
||||||
|
cwd: input.cwd ?? location.directory,
|
||||||
|
timeout: input.timeout,
|
||||||
|
shell: yield* resolve(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: "xterm-256color",
|
||||||
|
OPENCODE_TERMINAL: "1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield* hooks.trigger("shell", "create.before", invocation)
|
||||||
|
if (before) yield* before(invocation)
|
||||||
|
|
||||||
const id = Shell.ID.ascending()
|
const id = Shell.ID.ascending()
|
||||||
const cwd = input.cwd ?? location.directory
|
const args = ShellSelect.args(invocation.shell, invocation.command)
|
||||||
const shell = yield* resolve()
|
|
||||||
const args = ShellSelect.args(shell, input.command)
|
|
||||||
const file = path.join(outputDir, `${id}.out`)
|
const file = path.join(outputDir, `${id}.out`)
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
TERM: "xterm-256color",
|
|
||||||
OPENCODE_TERMINAL: "1",
|
|
||||||
} as Record<string, string>
|
|
||||||
|
|
||||||
const info: Info = {
|
const info: Info = {
|
||||||
id,
|
id,
|
||||||
status: "running",
|
status: "running",
|
||||||
command: input.command,
|
command: invocation.command,
|
||||||
cwd,
|
cwd: invocation.cwd,
|
||||||
shell,
|
shell: invocation.shell,
|
||||||
file,
|
file,
|
||||||
metadata: input.metadata ?? {},
|
metadata: input.metadata ?? {},
|
||||||
time: { started: Date.now() },
|
time: { started: Date.now() },
|
||||||
|
|
@ -203,9 +219,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const handle = yield* appProcess.spawn(
|
const handle = yield* appProcess.spawn(
|
||||||
ChildProcess.make(shell, args, {
|
ChildProcess.make(invocation.shell, args, {
|
||||||
cwd,
|
cwd: invocation.cwd,
|
||||||
env,
|
env: invocation.env,
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
forceKillAfter: Duration.seconds(3),
|
forceKillAfter: Duration.seconds(3),
|
||||||
|
|
@ -297,7 +313,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* session.timeout(input.timeout)
|
yield* session.timeout(invocation.timeout)
|
||||||
|
|
||||||
runFork(
|
runFork(
|
||||||
handle.exitCode.pipe(
|
handle.exitCode.pipe(
|
||||||
|
|
@ -327,7 +343,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||||
return makeLocationNode({
|
return makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer(options),
|
layer: layer(options),
|
||||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node],
|
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,34 +146,40 @@ export const Plugin = {
|
||||||
messageID: context.messageID,
|
messageID: context.messageID,
|
||||||
callID: context.callID,
|
callID: context.callID,
|
||||||
}
|
}
|
||||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
|
||||||
const external = target.externalDirectory
|
|
||||||
if (external)
|
|
||||||
yield* permission.assert({
|
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
yield* permission.assert({
|
|
||||||
action: name,
|
|
||||||
resources: [input.command],
|
|
||||||
save: [input.command],
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
|
|
||||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
|
||||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
|
||||||
|
|
||||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||||
const info = yield* shell.create({
|
let finalTimeout = timeout
|
||||||
command: input.command,
|
const info = yield* shell.create(
|
||||||
cwd: target.canonical,
|
{
|
||||||
timeout,
|
command: input.command,
|
||||||
metadata: { sessionID: context.sessionID },
|
cwd: input.workdir,
|
||||||
})
|
timeout,
|
||||||
|
metadata: { sessionID: context.sessionID },
|
||||||
|
},
|
||||||
|
(invocation) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||||
|
invocation.cwd = target.canonical
|
||||||
|
finalTimeout = invocation.timeout
|
||||||
|
const external = target.externalDirectory
|
||||||
|
if (external)
|
||||||
|
yield* permission.assert({
|
||||||
|
...LocationMutation.externalDirectoryPermission(external),
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
yield* permission.assert({
|
||||||
|
action: name,
|
||||||
|
resources: [invocation.command],
|
||||||
|
save: [invocation.command],
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||||
|
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||||
|
}),
|
||||||
|
)
|
||||||
yield* context.progress({ shellID: info.id })
|
yield* context.progress({ shellID: info.id })
|
||||||
|
|
||||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||||
|
|
@ -198,7 +204,7 @@ export const Plugin = {
|
||||||
if (final.status === "timeout") {
|
if (final.status === "timeout") {
|
||||||
return {
|
return {
|
||||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||||
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 ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
timeout: true,
|
timeout: true,
|
||||||
status: "completed" as const,
|
status: "completed" as const,
|
||||||
|
|
@ -223,14 +229,14 @@ export const Plugin = {
|
||||||
const job = yield* runtime.job.start({
|
const job = yield* runtime.job.start({
|
||||||
id: context.callID,
|
id: context.callID,
|
||||||
type: name,
|
type: name,
|
||||||
title: input.command,
|
title: info.command,
|
||||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||||
run,
|
run,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (input.background === true) {
|
if (input.background === true) {
|
||||||
yield* runtime.job.background(job.id)
|
yield* runtime.job.background(job.id)
|
||||||
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
|
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
|
||||||
return {
|
return {
|
||||||
output: BACKGROUND_STARTED,
|
output: BACKGROUND_STARTED,
|
||||||
shellID: info.id,
|
shellID: info.id,
|
||||||
|
|
@ -244,7 +250,7 @@ export const Plugin = {
|
||||||
)
|
)
|
||||||
if (result?.type === "backgrounded") {
|
if (result?.type === "backgrounded") {
|
||||||
yield* shell.timeout(info.id, 0)
|
yield* shell.timeout(info.id, 0)
|
||||||
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
|
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
|
||||||
return {
|
return {
|
||||||
output: BACKGROUND_STARTED,
|
output: BACKGROUND_STARTED,
|
||||||
shellID: info.id,
|
shellID: info.id,
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,25 @@ describe("PluginHooks", () => {
|
||||||
expect(event.messages).toEqual([Message.user("changed")])
|
expect(event.messages).toEqual([Message.user("changed")])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("mutates shell creation input", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
yield* hooks.register("shell", "create.before", (event) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
event.command = "echo changed"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const event = {
|
||||||
|
command: "echo original",
|
||||||
|
cwd: "/tmp",
|
||||||
|
timeout: 0,
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(yield* hooks.trigger("shell", "create.before", event)).toBe(event)
|
||||||
|
expect(event.command).toBe("echo changed")
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||||
transform: () => Effect.die("unused skill.transform"),
|
transform: () => Effect.die("unused skill.transform"),
|
||||||
reload: () => Effect.die("unused skill.reload"),
|
reload: () => Effect.die("unused skill.reload"),
|
||||||
},
|
},
|
||||||
|
shell: overrides.shell ?? {
|
||||||
|
hook: () => Effect.die("unused shell.hook"),
|
||||||
|
},
|
||||||
tool: overrides.tool ?? {
|
tool: overrides.tool ?? {
|
||||||
transform: () => Effect.die("unused tool.transform"),
|
transform: () => Effect.die("unused tool.transform"),
|
||||||
hook: () => Effect.die("unused tool.hook"),
|
hook: () => Effect.die("unused tool.hook"),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { EventDomain } from "./event.js"
|
||||||
import type { IntegrationDomain } from "./integration.js"
|
import type { IntegrationDomain } from "./integration.js"
|
||||||
import type { ReferenceDomain } from "./reference.js"
|
import type { ReferenceDomain } from "./reference.js"
|
||||||
import type { SessionDomain } from "./session.js"
|
import type { SessionDomain } from "./session.js"
|
||||||
|
import type { ShellDomain } from "./shell.js"
|
||||||
import type { SkillDomain } from "./skill.js"
|
import type { SkillDomain } from "./skill.js"
|
||||||
import type { ToolDomain } from "./tool.js"
|
import type { ToolDomain } from "./tool.js"
|
||||||
import type { WebSearchDomain } from "./websearch.js"
|
import type { WebSearchDomain } from "./websearch.js"
|
||||||
|
|
@ -26,6 +27,7 @@ export interface Context {
|
||||||
readonly plugin: PluginApi<unknown>
|
readonly plugin: PluginApi<unknown>
|
||||||
readonly reference: ReferenceDomain
|
readonly reference: ReferenceDomain
|
||||||
readonly session: SessionDomain
|
readonly session: SessionDomain
|
||||||
|
readonly shell: ShellDomain
|
||||||
readonly skill: SkillDomain
|
readonly skill: SkillDomain
|
||||||
readonly tool: ToolDomain
|
readonly tool: ToolDomain
|
||||||
readonly websearch: WebSearchDomain
|
readonly websearch: WebSearchDomain
|
||||||
|
|
|
||||||
17
packages/plugin/src/effect/shell.ts
Normal file
17
packages/plugin/src/effect/shell.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
|
export interface ShellCreateBefore {
|
||||||
|
command: string
|
||||||
|
cwd: string
|
||||||
|
timeout: number
|
||||||
|
shell: string
|
||||||
|
env: Record<string, string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellHooks {
|
||||||
|
readonly "create.before": ShellCreateBefore
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellDomain {
|
||||||
|
readonly hook: Hooks<ShellHooks>
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import type { EventDomain } from "./event.js"
|
||||||
import type { IntegrationDomain } from "./integration.js"
|
import type { IntegrationDomain } from "./integration.js"
|
||||||
import type { ReferenceDomain } from "./reference.js"
|
import type { ReferenceDomain } from "./reference.js"
|
||||||
import type { SessionDomain } from "./session.js"
|
import type { SessionDomain } from "./session.js"
|
||||||
|
import type { ShellDomain } from "./shell.js"
|
||||||
import type { SkillDomain } from "./skill.js"
|
import type { SkillDomain } from "./skill.js"
|
||||||
import type { ToolDomain } from "./tool.js"
|
import type { ToolDomain } from "./tool.js"
|
||||||
import type { WebSearchDomain } from "./websearch.js"
|
import type { WebSearchDomain } from "./websearch.js"
|
||||||
|
|
@ -25,6 +26,7 @@ export interface Context {
|
||||||
readonly plugin: PluginApi
|
readonly plugin: PluginApi
|
||||||
readonly reference: ReferenceDomain
|
readonly reference: ReferenceDomain
|
||||||
readonly session: SessionDomain
|
readonly session: SessionDomain
|
||||||
|
readonly shell: ShellDomain
|
||||||
readonly skill: SkillDomain
|
readonly skill: SkillDomain
|
||||||
readonly tool: ToolDomain
|
readonly tool: ToolDomain
|
||||||
readonly websearch: WebSearchDomain
|
readonly websearch: WebSearchDomain
|
||||||
|
|
|
||||||
17
packages/plugin/src/promise/shell.ts
Normal file
17
packages/plugin/src/promise/shell.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
|
export interface ShellCreateBefore {
|
||||||
|
command: string
|
||||||
|
cwd: string
|
||||||
|
timeout: number
|
||||||
|
shell: string
|
||||||
|
env: Record<string, string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellHooks {
|
||||||
|
readonly "create.before": ShellCreateBefore
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellDomain {
|
||||||
|
readonly hook: Hooks<ShellHooks>
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue