Prechádzať zdrojové kódy

feat(plugin): add shell.create.before hook (#39547)

Aiden Cline 2 týždňov pred
rodič
commit
c8dca936b1

+ 2 - 0
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
 }
 

+ 3 - 0
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

+ 4 - 0
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)))

+ 34 - 18
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<NotFoundError>()("Shell.NotFoundError", {
   id: Shell.ID,
@@ -45,7 +47,10 @@ type Active = {
  */
 export interface Interface {
   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.
   readonly list: () => Effect.Effect<Shell.Info[]>
   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 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<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 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<string, string>
 
       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],
   })
 }
 

+ 37 - 31
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,

+ 21 - 0
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")
+    }),
+  )
 })

+ 3 - 0
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"),

+ 2 - 0
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<unknown>
   readonly reference: ReferenceDomain
   readonly session: SessionDomain
+  readonly shell: ShellDomain
   readonly skill: SkillDomain
   readonly tool: ToolDomain
   readonly websearch: WebSearchDomain

+ 17 - 0
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<string, string | undefined>
+}
+
+export interface ShellHooks {
+  readonly "create.before": ShellCreateBefore
+}
+
+export interface ShellDomain {
+  readonly hook: Hooks<ShellHooks>
+}

+ 2 - 0
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

+ 17 - 0
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<string, string | undefined>
+}
+
+export interface ShellHooks {
+  readonly "create.before": ShellCreateBefore
+}
+
+export interface ShellDomain {
+  readonly hook: Hooks<ShellHooks>
+}