diff --git a/package.json b/package.json index b866c9bdf087..65c8b5a81fa4 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.3", + "packageManager": "bun@1.3.4", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index ae4798d963de..41ef72395eb2 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -5,6 +5,7 @@ import { type AuthenticateRequest, type AuthMethod, type CancelNotification, + type ClientCapabilities, type InitializeRequest, type InitializeResponse, type LoadSessionRequest, @@ -12,11 +13,15 @@ import { type PermissionOption, type PlanEntry, type PromptRequest, + type ReadTextFileRequest, + type ReadTextFileResponse, type SetSessionModelRequest, type SetSessionModeRequest, type SetSessionModeResponse, type ToolCallContent, type ToolKind, + type WriteTextFileRequest, + type WriteTextFileResponse, } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" @@ -29,6 +34,7 @@ import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import { FileSystemDelegate } from "../tool/delegate" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -50,6 +56,7 @@ export namespace ACP { private config: ACPConfig private sdk: OpencodeClient private sessionManager + private clientCapabilities?: ClientCapabilities constructor(connection: AgentSideConnection, config: ACPConfig) { this.connection = connection @@ -58,6 +65,33 @@ export namespace ACP { this.sessionManager = new ACPSessionManager(this.sdk) } + private registerACPTools(sessionID: string) { + const { readTextFile, writeTextFile } = this.clientCapabilities?.fs ?? {} + const delegate: FileSystemDelegate = { + ...(readTextFile && { + read: async (path, options) => { + const res = await this.readTextFile({ + sessionId: sessionID, + path, + line: options?.offset, + limit: options?.limit, + }) + return res.content + }, + }), + ...(writeTextFile && { + write: async (path, content) => { + await this.writeTextFile({ + sessionId: sessionID, + path, + content, + }) + }, + }), + } + FileSystemDelegate.register(sessionID, delegate) + } + private setupEventSubscriptions(session: ACPSessionState) { const sessionId = session.id const directory = session.cwd @@ -332,6 +366,7 @@ export namespace ACP { async initialize(params: InitializeRequest): Promise { log.info("initialize", { protocolVersion: params.protocolVersion }) + this.clientCapabilities = params.clientCapabilities const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", @@ -393,6 +428,7 @@ export namespace ACP { }) this.setupEventSubscriptions(state) + this.registerACPTools(sessionId) return { sessionId, @@ -697,6 +733,16 @@ export namespace ACP { { throwOnError: true }, ) } + + async readTextFile(params: ReadTextFileRequest): Promise { + log.debug("readTextFile", { path: params.path, sessionId: params.sessionId }) + return await this.connection.readTextFile(params) + } + + async writeTextFile(params: WriteTextFileRequest): Promise { + log.debug("writeTextFile", { path: params.path, sessionId: params.sessionId }) + return await this.connection.writeTextFile(params) + } } function toToolKind(toolName: string): ToolKind { diff --git a/packages/opencode/src/tool/delegate.ts b/packages/opencode/src/tool/delegate.ts new file mode 100644 index 000000000000..722b512437c7 --- /dev/null +++ b/packages/opencode/src/tool/delegate.ts @@ -0,0 +1,20 @@ +export interface FileSystemDelegate { + read?(path: string, options?: { offset?: number; limit?: number }): Promise + write?(path: string, content: string): Promise +} + +export namespace FileSystemDelegate { + const registry = new Map() + + export function register(sessionID: string, delegate: FileSystemDelegate) { + registry.set(sessionID, delegate) + } + + export function get(sessionID: string): FileSystemDelegate | undefined { + return registry.get(sessionID) + } + + export function unregister(sessionID: string) { + registry.delete(sessionID) + } +} diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a5d34c949ff1..bc2e4b0bf9f5 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -17,6 +17,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Snapshot } from "@/snapshot" +import { FileSystemDelegate } from "./delegate" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") @@ -40,6 +41,7 @@ export const EditTool = Tool.define("edit", { } const agent = await Agent.get(ctx.agent) + const delegate = FileSystemDelegate.get(ctx.sessionID) const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) if (!Filesystem.contains(Instance.directory, filePath)) { @@ -91,7 +93,7 @@ export const EditTool = Tool.define("edit", { }, }) } - await Bun.write(filePath, params.newString) + delegate?.write ? await delegate.write(filePath, params.newString) : await Bun.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, }) @@ -103,7 +105,7 @@ export const EditTool = Tool.define("edit", { if (!stats) throw new Error(`File ${filePath} not found`) if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) await FileTime.assert(ctx.sessionID, filePath) - contentOld = await file.text() + contentOld = delegate?.read ? await delegate.read(filePath) : await file.text() contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) diff = trimDiff( @@ -123,11 +125,11 @@ export const EditTool = Tool.define("edit", { }) } - await file.write(contentNew) + delegate?.write ? await delegate.write(filePath, contentNew) : await Bun.write(filePath, contentNew) await Bus.publish(File.Event.Edited, { file: filePath, }) - contentNew = await file.text() + contentNew = delegate?.read ? await delegate.read(filePath) : await file.text() diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 27426ad24129..b10603e569c5 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -9,6 +9,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Identifier } from "../id/id" import { Permission } from "../permission" +import { FileSystemDelegate } from "./delegate" import { Agent } from "@/agent/agent" import { iife } from "@/util/iife" @@ -29,6 +30,7 @@ export const ReadTool = Tool.define("read", { } const title = path.relative(Instance.worktree, filepath) const agent = await Agent.get(ctx.agent) + const delegate = FileSystemDelegate.get(ctx.sessionID) if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) @@ -122,8 +124,9 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - const lines = await file.text().then((text) => text.split("\n")) - const raw = lines.slice(offset, offset + limit).map((line) => { + let lines = (delegate?.read ? await delegate.read(filepath, { offset, limit }) : await file.text()).split("\n") + if (!delegate?.read) lines = lines.slice(offset, offset + limit) + const raw = lines.map((line) => { return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line }) const content = raw.map((line, index) => { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 7b109261eb1f..5c8e4d832a8e 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,6 +10,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" +import { FileSystemDelegate } from "./delegate" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -19,6 +20,7 @@ export const WriteTool = Tool.define("write", { }), async execute(params, ctx) { const agent = await Agent.get(ctx.agent) + const delegate = FileSystemDelegate.get(ctx.sessionID) const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) if (!Filesystem.contains(Instance.directory, filepath)) { @@ -68,7 +70,7 @@ export const WriteTool = Tool.define("write", { }, }) - await Bun.write(filepath, params.content) + delegate?.write ? await delegate.write(filepath, params.content) : await Bun.write(filepath, params.content) await Bus.publish(File.Event.Edited, { file: filepath, })