Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ import { LSP } from "@/lsp/lsp"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Bus } from "../bus"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
Expand Down Expand Up @@ -139,6 +142,14 @@ export const layer: Layer.Layer<
ask: (req) => toolCtx.ask(req),
directory: ctx.directory,
worktree: ctx.worktree,
notifyFileChanged: async ({ filePath, event = "change" }) =>
// Restore the instance ALS so the async bus publishes still see
// the active project context after the plugin awaits.
Instance.restore(ctx, async () => {
const file = path.isAbsolute(filePath) ? filePath : path.join(ctx.directory, filePath)
await Bus.publish(File.Event.Edited, { file })
await Bus.publish(FileWatcher.Event.Updated, { file, event })
}),
}
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
const output = typeof result === "string" ? result : result.output
Expand Down
71 changes: 71 additions & 0 deletions packages/opencode/test/tool/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Plugin } from "@/plugin"
import { Question } from "@/question"
import { Todo } from "@/session/todo"
Expand All @@ -23,6 +25,7 @@ import { Format } from "@/format"
import { Ripgrep } from "@/file/ripgrep"
import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
import { MessageID, SessionID } from "@/session/schema"

const node = CrossSpawnSpawner.defaultLayer
const configLayer = TestConfig.layer({
Expand Down Expand Up @@ -185,4 +188,72 @@ describe("tool.registry", () => {
expect(ids).toContain("cowsay")
}),
)

it.instance("plugin tools can publish file change notifications", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const toolDir = path.join(opencode, "tool")
const fileName = "refresh.txt"
const file = path.join(test.directory, fileName)
const toolFile = path.join(toolDir, "notify.ts")

yield* Effect.promise(() => fs.mkdir(toolDir, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
toolFile,
[
"import path from \"path\"",
"export default {",
" description: 'notify file changes',",
" args: {},",
" execute: async (_args, context) => {",
` const file = path.join(context.directory, ${JSON.stringify(fileName)})`,
' await Bun.write(file, "fresh")',
` await context.notifyFileChanged({ filePath: ${JSON.stringify(fileName)}, event: "change" })`,
' return "done"',
" },",
"}",
"",
].join("\n"),
),
)

const registry = yield* ToolRegistry.Service
const tool = (yield* registry.all()).find((tool) => tool.id === "notify")
if (!tool) throw new Error("notify tool not found")

const events: string[] = []
const offEdited = Bus.subscribe(File.Event.Edited, (evt) => {
if (evt.properties.file === file) events.push("edited")
})
const offUpdated = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
if (evt.properties.file === file) events.push(`watch:${evt.properties.event}`)
})

yield* Effect.addFinalizer(() =>
Effect.sync(() => {
offEdited()
offUpdated()
}),
)

const result = yield* tool.execute({}, {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
})

yield* Effect.promise(() => Bun.sleep(10))
const content = yield* Effect.promise(() => fs.readFile(file, "utf8"))

expect(result.output).toBe("done")
expect(content).toBe("fresh")
expect(events).toEqual(["edited", "watch:change"])
}),
)
})
6 changes: 6 additions & 0 deletions packages/plugin/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export type ToolContext = {
abort: AbortSignal
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void
ask(input: AskInput): Effect.Effect<void>
/**
* Tell OpenCode that a file changed after the plugin updated it on disk.
* OpenCode uses this to publish the same file and file-watcher events that
* native write/edit tools emit.
*/
notifyFileChanged(input: { filePath: string; event?: "add" | "change" }): Promise<void>
}

type AskInput = {
Expand Down
Loading