From 5cd7b060ffb033bad5a66d72449ee61ce9b5ca2c Mon Sep 17 00:00:00 2001 From: Zireael <3856578+Zireael@users.noreply.github.com> Date: Thu, 7 May 2026 07:00:45 +0200 Subject: [PATCH] feat: expose file change notifications --- packages/opencode/src/tool/registry.ts | 11 +++ packages/opencode/test/tool/registry.test.ts | 71 ++++++++++++++++++++ packages/plugin/src/tool.ts | 6 ++ 3 files changed, 88 insertions(+) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a4eb31acc747..699778e4a6f5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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" @@ -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 diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c33981ddff5f..8f97dc269280 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -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" @@ -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({ @@ -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"]) + }), + ) }) diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 3105bf534bef..8ebe6b63038f 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -18,6 +18,12 @@ export type ToolContext = { abort: AbortSignal metadata(input: { title?: string; metadata?: { [key: string]: any } }): void ask(input: AskInput): Effect.Effect + /** + * 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 } type AskInput = {