From c8dca936b146921e68a4d2f98b2e8c8963856d9a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:11:52 -0500 Subject: [PATCH] feat(plugin): add shell.create.before hook (#39547) --- packages/core/src/plugin/hooks.ts | 2 + packages/core/src/plugin/host.ts | 3 ++ packages/core/src/plugin/promise.ts | 4 ++ packages/core/src/shell.ts | 52 ++++++++++++------- packages/core/src/tool/plugin/shell.ts | 68 ++++++++++++++----------- packages/core/test/plugin-hooks.test.ts | 21 ++++++++ packages/core/test/plugin/host.ts | 3 ++ packages/plugin/src/effect/plugin.ts | 2 + packages/plugin/src/effect/shell.ts | 17 +++++++ packages/plugin/src/promise/plugin.ts | 2 + packages/plugin/src/promise/shell.ts | 17 +++++++ 11 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 packages/plugin/src/effect/shell.ts create mode 100644 packages/plugin/src/promise/shell.ts diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 50c019e6fb4..1f02bbed97a 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -2,6 +2,7 @@ export * as PluginHooks from "./hooks" import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk" 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 { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" @@ -10,6 +11,7 @@ import { State } from "../state" export interface Domains { readonly aisdk: AISDKHooks readonly session: SessionHooks + readonly shell: ShellHooks readonly tool: ToolHooks } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index b0b11d2169b..6d2aaec6a29 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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: { transform: (callback) => tools diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 21380800af4..651bc7c8ba5 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -326,6 +326,10 @@ export function fromPromise(plugin: Plugin) { ), 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))) diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index f436683aa7d..16318b55a7b 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -12,6 +12,8 @@ import { Bus } from "./bus" import { Location } from "./location" import { Global } from "@opencode-ai/util/global" 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()("Shell.NotFoundError", { id: Shell.ID, @@ -45,7 +47,10 @@ type Active = { */ export interface Interface { readonly name: () => Effect.Effect - readonly create: (input: Shell.CreateInput) => Effect.Effect + readonly create: ( + input: Shell.CreateInput, + before?: (input: ShellCreateBefore) => Effect.Effect, + ) => Effect.Effect // Currently running commands only; exited shells are retained for get/output but excluded here. readonly list: () => Effect.Effect readonly get: (id: Shell.ID) => Effect.Effect @@ -68,6 +73,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( const config = yield* Config.Service const global = yield* Global.Service const appProcess = yield* AppProcess.Service + const hooks = yield* PluginHooks.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const sessions = new Map() @@ -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* ( + input: Shell.CreateInput, + before?: (input: ShellCreateBefore) => Effect.Effect, + ) { + 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 cwd = input.cwd ?? location.directory - const shell = yield* resolve() - const args = ShellSelect.args(shell, input.command) + const args = ShellSelect.args(invocation.shell, invocation.command) const file = path.join(outputDir, `${id}.out`) - const env = { - ...process.env, - TERM: "xterm-256color", - OPENCODE_TERMINAL: "1", - } as Record const info: Info = { id, status: "running", - command: input.command, - cwd, - shell, + command: invocation.command, + cwd: invocation.cwd, + shell: invocation.shell, file, metadata: input.metadata ?? {}, time: { started: Date.now() }, @@ -203,9 +219,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( Effect.scoped( Effect.gen(function* () { const handle = yield* appProcess.spawn( - ChildProcess.make(shell, args, { - cwd, - env, + ChildProcess.make(invocation.shell, args, { + cwd: invocation.cwd, + env: invocation.env, stdin: "ignore", detached: process.platform !== "win32", 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( handle.exitCode.pipe( @@ -327,7 +343,7 @@ export function configured(options?: ShellSelect.Options) { return makeLocationNode({ service: Service, 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], }) } diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 84723863acd..b663140688a 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -146,34 +146,40 @@ export const Plugin = { messageID: context.messageID, 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 info = yield* shell.create({ - command: input.command, - cwd: target.canonical, - timeout, - metadata: { sessionID: context.sessionID }, - }) + let finalTimeout = timeout + const info = yield* shell.create( + { + command: input.command, + 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 }) const captureShell = Effect.fn("ShellTool.captureShell")(function* () { @@ -198,7 +204,7 @@ export const Plugin = { if (final.status === "timeout") { return { ...(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, timeout: true, status: "completed" as const, @@ -223,14 +229,14 @@ export const Plugin = { const job = yield* runtime.job.start({ id: context.callID, type: name, - title: input.command, + title: info.command, metadata: { sessionID: context.sessionID, shellID: info.id }, run, }) if (input.background === true) { yield* runtime.job.background(job.id) - yield* notifyWhenDone(context.sessionID, context.callID, input.command) + yield* notifyWhenDone(context.sessionID, context.callID, info.command) return { output: BACKGROUND_STARTED, shellID: info.id, @@ -244,7 +250,7 @@ export const Plugin = { ) if (result?.type === "backgrounded") { yield* shell.timeout(info.id, 0) - yield* notifyWhenDone(context.sessionID, context.callID, input.command) + yield* notifyWhenDone(context.sessionID, context.callID, info.command) return { output: BACKGROUND_STARTED, shellID: info.id, diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts index 4fdf9a9c54c..3b489ecca83 100644 --- a/packages/core/test/plugin-hooks.test.ts +++ b/packages/core/test/plugin-hooks.test.ts @@ -42,4 +42,25 @@ describe("PluginHooks", () => { 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") + }), + ) }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 614ce2b323f..956c5353d24 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -86,6 +86,9 @@ export function host(overrides: Overrides = {}): Plugin.Context { transform: () => Effect.die("unused skill.transform"), reload: () => Effect.die("unused skill.reload"), }, + shell: overrides.shell ?? { + hook: () => Effect.die("unused shell.hook"), + }, tool: overrides.tool ?? { transform: () => Effect.die("unused tool.transform"), hook: () => Effect.die("unused tool.hook"), diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 127e6a70b54..4e9d2399e40 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -10,6 +10,7 @@ import type { EventDomain } from "./event.js" import type { IntegrationDomain } from "./integration.js" import type { ReferenceDomain } from "./reference.js" import type { SessionDomain } from "./session.js" +import type { ShellDomain } from "./shell.js" import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" import type { WebSearchDomain } from "./websearch.js" @@ -26,6 +27,7 @@ export interface Context { readonly plugin: PluginApi readonly reference: ReferenceDomain readonly session: SessionDomain + readonly shell: ShellDomain readonly skill: SkillDomain readonly tool: ToolDomain readonly websearch: WebSearchDomain diff --git a/packages/plugin/src/effect/shell.ts b/packages/plugin/src/effect/shell.ts new file mode 100644 index 00000000000..d0e6ae71ed7 --- /dev/null +++ b/packages/plugin/src/effect/shell.ts @@ -0,0 +1,17 @@ +import type { Hooks } from "./registration.js" + +export interface ShellCreateBefore { + command: string + cwd: string + timeout: number + shell: string + env: Record +} + +export interface ShellHooks { + readonly "create.before": ShellCreateBefore +} + +export interface ShellDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 35225b076c2..5fb97a9cb8c 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -9,6 +9,7 @@ import type { EventDomain } from "./event.js" import type { IntegrationDomain } from "./integration.js" import type { ReferenceDomain } from "./reference.js" import type { SessionDomain } from "./session.js" +import type { ShellDomain } from "./shell.js" import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" import type { WebSearchDomain } from "./websearch.js" @@ -25,6 +26,7 @@ export interface Context { readonly plugin: PluginApi readonly reference: ReferenceDomain readonly session: SessionDomain + readonly shell: ShellDomain readonly skill: SkillDomain readonly tool: ToolDomain readonly websearch: WebSearchDomain diff --git a/packages/plugin/src/promise/shell.ts b/packages/plugin/src/promise/shell.ts new file mode 100644 index 00000000000..d0e6ae71ed7 --- /dev/null +++ b/packages/plugin/src/promise/shell.ts @@ -0,0 +1,17 @@ +import type { Hooks } from "./registration.js" + +export interface ShellCreateBefore { + command: string + cwd: string + timeout: number + shell: string + env: Record +} + +export interface ShellHooks { + readonly "create.before": ShellCreateBefore +} + +export interface ShellDomain { + readonly hook: Hooks +}