From eed4bfa66e271bea82ce83b7f19c466a8a1f6eb7 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 5 Nov 2025 17:10:39 -0800 Subject: [PATCH 01/25] init --- packages/opencode/src/acp/README.md | 7 + packages/opencode/src/acp/agent.ts | 50 ++++- packages/opencode/src/acp/session.ts | 2 + packages/opencode/src/acp/tools.ts | 294 +++++++++++++++++++++++++++ packages/opencode/src/acp/types.ts | 1 + 5 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/acp/tools.ts diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md index d998cb22da8d..4d2f227dec8a 100644 --- a/packages/opencode/src/acp/README.md +++ b/packages/opencode/src/acp/README.md @@ -29,6 +29,13 @@ The implementation follows a clean separation of concerns: - Sets up JSON-RPC over stdio using the official library - Manages graceful shutdown on SIGTERM/SIGINT - Provides Instance context for the agent + +- **`tools.ts`** - ACP-aware tools for client file operation delegation + - Provides `acp_read`, `acp_edit`, and `acp_write` tools + - These tools delegate file operations to the ACP client (e.g., Zed) + - Registered automatically when client has `writeTextFile` capability + - Enables proper edit accumulation and undo/redo in supporting clients + - **`types.ts`** - Type definitions for internal use diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 4eaf76cfe51c..1e12638b1cd1 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" @@ -37,6 +42,8 @@ import { MCP } from "@/mcp" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" +import { createACPTools } from "./tools" +import { ToolRegistry } from "../tool/registry" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -45,6 +52,7 @@ export namespace ACP { private sessionManager = new ACPSessionManager() private connection: AgentSideConnection private config: ACPConfig + private clientCapabilities?: ClientCapabilities constructor(connection: AgentSideConnection, config: ACPConfig = {}) { this.connection = connection @@ -315,6 +323,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", @@ -361,9 +370,18 @@ export namespace ACP { async newSession(params: NewSessionRequest) { try { const model = await defaultModel(this.config) - const session = await this.sessionManager.create(params.cwd, params.mcpServers, model) - - log.info("creating_session", { mcpServers: params.mcpServers.length }) + const hasACPTools = !!this.clientCapabilities?.fs?.writeTextFile + const session = await this.sessionManager.create( + params.cwd, + params.mcpServers, + model, + hasACPTools, + ) + + log.info("creating_session", { + mcpServers: params.mcpServers.length, + hasACPTools, + }) const load = await this.loadSession({ cwd: params.cwd, mcpServers: params.mcpServers, @@ -452,6 +470,14 @@ export namespace ACP { } } + if (this.clientCapabilities?.fs?.writeTextFile) { + log.info("Client has writeTextFile capability, registering ACP tools") + const acpTools = createACPTools(this, sessionId) + for (const tool of acpTools) { + await ToolRegistry.register(tool) + } + } + await Promise.all( Object.entries(mcpServers).map(async ([key, mcp]) => { await MCP.add(key, mcp) @@ -593,6 +619,13 @@ export namespace ACP { } if (!cmd) { + const tools = acpSession.hasACPTools + ? { + edit: false, + write: false, + read: false, + } + : undefined await SessionPrompt.prompt({ sessionID, model: { @@ -601,6 +634,7 @@ export namespace ACP { }, parts, agent, + tools, }) return done } @@ -633,6 +667,16 @@ export namespace ACP { async cancel(params: CancelNotification) { SessionLock.abort(params.sessionId) } + + 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/acp/session.ts b/packages/opencode/src/acp/session.ts index d3ab73d215e3..ce06568e9232 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -10,6 +10,7 @@ export class ACPSessionManager { cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"], + hasACPTools?: boolean, ): Promise { const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` }) const sessionId = session.id @@ -22,6 +23,7 @@ export class ACPSessionManager { mcpServers, createdAt: new Date(), model: resolvedModel, + hasACPTools, } this.sessions.set(sessionId, state) diff --git a/packages/opencode/src/acp/tools.ts b/packages/opencode/src/acp/tools.ts new file mode 100644 index 000000000000..dcca90d4a2d9 --- /dev/null +++ b/packages/opencode/src/acp/tools.ts @@ -0,0 +1,294 @@ +/** + * ACP-aware tools for ACP-client file operation delegation + * + * These tools delegate file operations to the ACP client (e.g., Zed) instead of + * performing them directly. This allows the client to: + * - Track file changes for undo/redo + * - Accumulate edits across multiple changes + * - Manage file state in its own UI + * + * These tools are registered when clientCapabilities.fs.writeTextFile is present. + */ + +import z from "zod" +import { Tool } from "../tool/tool" +import type { ACP } from "./agent" +import { Log } from "../util/log" +import { createTwoFilesPatch, diffLines } from "diff" +import { LSP } from "../lsp" + +const log = Log.create({ service: "acp-tools" }) + +/** + * Creates ACP-aware tools that delegate to the agent's readTextFile/writeTextFile methods. + * These tools are used instead of the native read/write/edit tools when the client has file capabilities. + */ +export function createACPTools(agent: ACP.Agent, sessionId: string): Tool.Info[] { + // ACP Read Tool - delegates to agent.readTextFile + const acpRead = Tool.define("acp_read", { + description: `**CRITICAL: You MUST use acp_read instead of 'read' in this session.** + +This tool reads files by delegating to the ACP client, which is REQUIRED for proper edit accumulation and undo functionality. + +Reads a file from the filesystem by calling the ACP client's readTextFile method. + +Usage: +- The filePath parameter must be an absolute path +- By default, reads the entire file +- You can optionally specify offset and limit for large files +- This tool supports reading images which will be presented visually + +DO NOT use the native 'read' tool in this session - always use acp_read.`, + parameters: z.object({ + filePath: z.string().describe("The absolute path to the file to read"), + offset: z.number().optional().describe("Line number to start reading from (0-based)"), + limit: z.number().optional().describe("Number of lines to read"), + }), + async execute(params) { + try { + log.debug("acp_read called", { filePath: params.filePath, sessionId }) + + const result = await agent.readTextFile({ + sessionId, + path: params.filePath, + line: params.offset, + limit: params.limit, + }) + + return { + title: `Read ${params.filePath}`, + output: result.content, + metadata: {}, + } + } catch (error: any) { + log.error("acp_read failed", { error: error.message, filePath: params.filePath }) + throw new Error(`Failed to read file via ACP: ${error.message}`) + } + }, + }) + + // ACP Edit Tool - reads file, applies edit, writes via agent.writeTextFile + const acpEdit = Tool.define("acp_edit", { + description: `**CRITICAL: You MUST use acp_edit instead of 'edit' in this session.** + +This tool edits files by delegating to the ACP client, which is REQUIRED for: +- Edit accumulation across multiple changes +- Undo/redo functionality +- File change tracking in the client's UI + +Performs exact string replacements in files. + +Usage: +- You must use acp_read at least once before editing +- Preserve exact indentation (tabs/spaces) from the read output +- The edit will FAIL if oldString is not unique - provide more context to make it unique +- Use replaceAll to change every instance of oldString + +DO NOT use the native 'edit' tool in this session - always use acp_edit.`, + parameters: z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + oldString: z.string().describe("The exact text to replace"), + newString: z.string().describe("The text to replace it with (must be different)"), + replaceAll: z + .boolean() + .optional() + .default(false) + .describe("Replace all occurrences of oldString (default false)"), + }), + async execute(params) { + try { + log.debug("acp_edit called", { filePath: params.filePath, sessionId }) + + // Read current file content via ACP + const { content: oldContent } = await agent.readTextFile({ + sessionId, + path: params.filePath, + }) + + // Apply the edit + const newContent = applyEdit( + oldContent, + params.oldString, + params.newString, + params.replaceAll, + ) + + // Generate diff for display + const patch = createTwoFilesPatch( + params.filePath, + params.filePath, + oldContent, + newContent, + undefined, + undefined, + ) + + // Calculate additions and deletions + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + // Write via ACP - this calls back to client to apply the change + await agent.writeTextFile({ + sessionId, + path: params.filePath, + content: newContent, + }) + + // Check for LSP diagnostics after the edit + let output = patch + await LSP.touchFile(params.filePath, true) + const diagnostics = await LSP.diagnostics() + + for (const [file, issues] of Object.entries(diagnostics)) { + if (issues.length === 0) continue + if (file === params.filePath) { + // Only show errors (severity === 1), matching the native edit tool + const errors = issues.filter((item) => item.severity === 1) + if (errors.length > 0) { + output += `\n\nThis file has errors, please fix\n\n${errors + .map(LSP.Diagnostic.pretty) + .join("\n")}\n\n` + } + break + } + } + + return { + title: `Edit ${params.filePath}`, + output, + metadata: { + diff: patch, + diagnostics, + filediff: { + file: params.filePath, + before: oldContent, + after: newContent, + additions, + deletions, + }, + }, + } + } catch (error: any) { + log.error("acp_edit failed", { error: error.message, filePath: params.filePath }) + throw new Error(`Failed to edit file via ACP: ${error.message}`) + } + }, + }) + + // ACP Write Tool - creates or overwrites files via agent.writeTextFile + const acpWrite = Tool.define("acp_write", { + description: `**CRITICAL: You MUST use acp_write instead of 'write' in this session.** + +This tool writes files by delegating to the ACP client, which is REQUIRED for proper edit tracking and undo functionality. + +Creates a new file or overwrites an existing file. + +Usage: +- The filePath parameter must be an absolute path +- ALWAYS prefer editing existing files with acp_edit +- Only write new files when explicitly required + +DO NOT use the native 'write' tool in this session - always use acp_write.`, + parameters: z.object({ + filePath: z.string().describe("The absolute path to the file to write"), + content: z.string().describe("The content to write to the file"), + }), + async execute(params) { + try { + log.debug("acp_write called", { filePath: params.filePath, sessionId }) + + // Write via ACP - this calls back to client to apply the change + await agent.writeTextFile({ + sessionId, + path: params.filePath, + content: params.content, + }) + + // Check for LSP diagnostics after writing + let output = `Successfully wrote to ${params.filePath}` + await LSP.touchFile(params.filePath, true) + const diagnostics = await LSP.diagnostics() + + for (const [file, issues] of Object.entries(diagnostics)) { + if (issues.length === 0) continue + if (file === params.filePath) { + // Show all diagnostics for the written file + output += `\n\nThis file has errors, please fix\n\n${issues + .map(LSP.Diagnostic.pretty) + .join("\n")}\n\n` + } else { + // Show project-level diagnostics from other files + output += `\n\n${file}\n${issues + .map(LSP.Diagnostic.pretty) + .join("\n")}\n\n` + } + } + + return { + title: `Write ${params.filePath}`, + output, + metadata: { + diagnostics, + }, + } + } catch (error: any) { + log.error("acp_write failed", { error: error.message, filePath: params.filePath }) + throw new Error(`Failed to write file via ACP: ${error.message}`) + } + }, + }) + + return [acpRead, acpEdit, acpWrite] +} + +/** + * Applies an edit to content by replacing oldString with newString. + * Throws if oldString is not found or is ambiguous. + */ +function applyEdit( + content: string, + oldString: string, + newString: string, + replaceAll: boolean, +): string { + // Count occurrences + const occurrences = countOccurrences(content, oldString) + + if (occurrences === 0) { + throw new Error( + `oldString not found in file. The text "${oldString.slice(0, 50)}${oldString.length > 50 ? "..." : ""}" does not exist in the file.`, + ) + } + + if (occurrences > 1 && !replaceAll) { + throw new Error( + `oldString found ${occurrences} times in file. Either provide more context to make it unique, or set replaceAll to true.`, + ) + } + + // Perform replacement + if (replaceAll) { + return content.split(oldString).join(newString) + } else { + const index = content.indexOf(oldString) + return content.slice(0, index) + newString + content.slice(index + oldString.length) + } +} + +/** + * Counts how many times a substring appears in a string. + */ +function countOccurrences(str: string, substring: string): number { + if (substring.length === 0) return 0 + let count = 0 + let position = 0 + while ((position = str.indexOf(substring, position)) !== -1) { + count++ + position += substring.length + } + return count +} diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 119b335ceb34..5a8184e131a6 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -11,6 +11,7 @@ export interface ACPSessionState { modelID: string } modeId?: string + hasACPTools?: boolean } export interface ACPConfig { From e4f4bbe21ca74ed0a6b3097c887dc3c8fe587ddb Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 5 Nov 2025 19:36:09 -0800 Subject: [PATCH 02/25] refactor ACP tools take PR feedback into account: - Import existing tools and only modify `execute()` - No prompt change needed, reads, edits, and writes are executed as-is with a different interface - Keep permissions refactor ACP tools --- packages/opencode/src/acp/agent.ts | 19 +- packages/opencode/src/acp/tools.ts | 294 ------------------------ packages/opencode/src/session/prompt.ts | 7 + packages/opencode/src/tool/edit.ts | 56 ++++- packages/opencode/src/tool/read.ts | 15 +- packages/opencode/src/tool/write.ts | 31 ++- 6 files changed, 98 insertions(+), 324 deletions(-) delete mode 100644 packages/opencode/src/acp/tools.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 1e12638b1cd1..0aefd5a34366 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -42,8 +42,6 @@ import { MCP } from "@/mcp" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" -import { createACPTools } from "./tools" -import { ToolRegistry } from "../tool/registry" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -470,14 +468,6 @@ export namespace ACP { } } - if (this.clientCapabilities?.fs?.writeTextFile) { - log.info("Client has writeTextFile capability, registering ACP tools") - const acpTools = createACPTools(this, sessionId) - for (const tool of acpTools) { - await ToolRegistry.register(tool) - } - } - await Promise.all( Object.entries(mcpServers).map(async ([key, mcp]) => { await MCP.add(key, mcp) @@ -619,13 +609,6 @@ export namespace ACP { } if (!cmd) { - const tools = acpSession.hasACPTools - ? { - edit: false, - write: false, - read: false, - } - : undefined await SessionPrompt.prompt({ sessionID, model: { @@ -634,7 +617,7 @@ export namespace ACP { }, parts, agent, - tools, + acpAgent: acpSession.hasACPTools ? this : undefined, }) return done } diff --git a/packages/opencode/src/acp/tools.ts b/packages/opencode/src/acp/tools.ts deleted file mode 100644 index dcca90d4a2d9..000000000000 --- a/packages/opencode/src/acp/tools.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * ACP-aware tools for ACP-client file operation delegation - * - * These tools delegate file operations to the ACP client (e.g., Zed) instead of - * performing them directly. This allows the client to: - * - Track file changes for undo/redo - * - Accumulate edits across multiple changes - * - Manage file state in its own UI - * - * These tools are registered when clientCapabilities.fs.writeTextFile is present. - */ - -import z from "zod" -import { Tool } from "../tool/tool" -import type { ACP } from "./agent" -import { Log } from "../util/log" -import { createTwoFilesPatch, diffLines } from "diff" -import { LSP } from "../lsp" - -const log = Log.create({ service: "acp-tools" }) - -/** - * Creates ACP-aware tools that delegate to the agent's readTextFile/writeTextFile methods. - * These tools are used instead of the native read/write/edit tools when the client has file capabilities. - */ -export function createACPTools(agent: ACP.Agent, sessionId: string): Tool.Info[] { - // ACP Read Tool - delegates to agent.readTextFile - const acpRead = Tool.define("acp_read", { - description: `**CRITICAL: You MUST use acp_read instead of 'read' in this session.** - -This tool reads files by delegating to the ACP client, which is REQUIRED for proper edit accumulation and undo functionality. - -Reads a file from the filesystem by calling the ACP client's readTextFile method. - -Usage: -- The filePath parameter must be an absolute path -- By default, reads the entire file -- You can optionally specify offset and limit for large files -- This tool supports reading images which will be presented visually - -DO NOT use the native 'read' tool in this session - always use acp_read.`, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to read"), - offset: z.number().optional().describe("Line number to start reading from (0-based)"), - limit: z.number().optional().describe("Number of lines to read"), - }), - async execute(params) { - try { - log.debug("acp_read called", { filePath: params.filePath, sessionId }) - - const result = await agent.readTextFile({ - sessionId, - path: params.filePath, - line: params.offset, - limit: params.limit, - }) - - return { - title: `Read ${params.filePath}`, - output: result.content, - metadata: {}, - } - } catch (error: any) { - log.error("acp_read failed", { error: error.message, filePath: params.filePath }) - throw new Error(`Failed to read file via ACP: ${error.message}`) - } - }, - }) - - // ACP Edit Tool - reads file, applies edit, writes via agent.writeTextFile - const acpEdit = Tool.define("acp_edit", { - description: `**CRITICAL: You MUST use acp_edit instead of 'edit' in this session.** - -This tool edits files by delegating to the ACP client, which is REQUIRED for: -- Edit accumulation across multiple changes -- Undo/redo functionality -- File change tracking in the client's UI - -Performs exact string replacements in files. - -Usage: -- You must use acp_read at least once before editing -- Preserve exact indentation (tabs/spaces) from the read output -- The edit will FAIL if oldString is not unique - provide more context to make it unique -- Use replaceAll to change every instance of oldString - -DO NOT use the native 'edit' tool in this session - always use acp_edit.`, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The exact text to replace"), - newString: z.string().describe("The text to replace it with (must be different)"), - replaceAll: z - .boolean() - .optional() - .default(false) - .describe("Replace all occurrences of oldString (default false)"), - }), - async execute(params) { - try { - log.debug("acp_edit called", { filePath: params.filePath, sessionId }) - - // Read current file content via ACP - const { content: oldContent } = await agent.readTextFile({ - sessionId, - path: params.filePath, - }) - - // Apply the edit - const newContent = applyEdit( - oldContent, - params.oldString, - params.newString, - params.replaceAll, - ) - - // Generate diff for display - const patch = createTwoFilesPatch( - params.filePath, - params.filePath, - oldContent, - newContent, - undefined, - undefined, - ) - - // Calculate additions and deletions - let additions = 0 - let deletions = 0 - for (const change of diffLines(oldContent, newContent)) { - if (change.added) additions += change.count || 0 - if (change.removed) deletions += change.count || 0 - } - - // Write via ACP - this calls back to client to apply the change - await agent.writeTextFile({ - sessionId, - path: params.filePath, - content: newContent, - }) - - // Check for LSP diagnostics after the edit - let output = patch - await LSP.touchFile(params.filePath, true) - const diagnostics = await LSP.diagnostics() - - for (const [file, issues] of Object.entries(diagnostics)) { - if (issues.length === 0) continue - if (file === params.filePath) { - // Only show errors (severity === 1), matching the native edit tool - const errors = issues.filter((item) => item.severity === 1) - if (errors.length > 0) { - output += `\n\nThis file has errors, please fix\n\n${errors - .map(LSP.Diagnostic.pretty) - .join("\n")}\n\n` - } - break - } - } - - return { - title: `Edit ${params.filePath}`, - output, - metadata: { - diff: patch, - diagnostics, - filediff: { - file: params.filePath, - before: oldContent, - after: newContent, - additions, - deletions, - }, - }, - } - } catch (error: any) { - log.error("acp_edit failed", { error: error.message, filePath: params.filePath }) - throw new Error(`Failed to edit file via ACP: ${error.message}`) - } - }, - }) - - // ACP Write Tool - creates or overwrites files via agent.writeTextFile - const acpWrite = Tool.define("acp_write", { - description: `**CRITICAL: You MUST use acp_write instead of 'write' in this session.** - -This tool writes files by delegating to the ACP client, which is REQUIRED for proper edit tracking and undo functionality. - -Creates a new file or overwrites an existing file. - -Usage: -- The filePath parameter must be an absolute path -- ALWAYS prefer editing existing files with acp_edit -- Only write new files when explicitly required - -DO NOT use the native 'write' tool in this session - always use acp_write.`, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to write"), - content: z.string().describe("The content to write to the file"), - }), - async execute(params) { - try { - log.debug("acp_write called", { filePath: params.filePath, sessionId }) - - // Write via ACP - this calls back to client to apply the change - await agent.writeTextFile({ - sessionId, - path: params.filePath, - content: params.content, - }) - - // Check for LSP diagnostics after writing - let output = `Successfully wrote to ${params.filePath}` - await LSP.touchFile(params.filePath, true) - const diagnostics = await LSP.diagnostics() - - for (const [file, issues] of Object.entries(diagnostics)) { - if (issues.length === 0) continue - if (file === params.filePath) { - // Show all diagnostics for the written file - output += `\n\nThis file has errors, please fix\n\n${issues - .map(LSP.Diagnostic.pretty) - .join("\n")}\n\n` - } else { - // Show project-level diagnostics from other files - output += `\n\n${file}\n${issues - .map(LSP.Diagnostic.pretty) - .join("\n")}\n\n` - } - } - - return { - title: `Write ${params.filePath}`, - output, - metadata: { - diagnostics, - }, - } - } catch (error: any) { - log.error("acp_write failed", { error: error.message, filePath: params.filePath }) - throw new Error(`Failed to write file via ACP: ${error.message}`) - } - }, - }) - - return [acpRead, acpEdit, acpWrite] -} - -/** - * Applies an edit to content by replacing oldString with newString. - * Throws if oldString is not found or is ambiguous. - */ -function applyEdit( - content: string, - oldString: string, - newString: string, - replaceAll: boolean, -): string { - // Count occurrences - const occurrences = countOccurrences(content, oldString) - - if (occurrences === 0) { - throw new Error( - `oldString not found in file. The text "${oldString.slice(0, 50)}${oldString.length > 50 ? "..." : ""}" does not exist in the file.`, - ) - } - - if (occurrences > 1 && !replaceAll) { - throw new Error( - `oldString found ${occurrences} times in file. Either provide more context to make it unique, or set replaceAll to true.`, - ) - } - - // Perform replacement - if (replaceAll) { - return content.split(oldString).join(newString) - } else { - const index = content.indexOf(oldString) - return content.slice(0, index) + newString + content.slice(index + oldString.length) - } -} - -/** - * Counts how many times a substring appears in a string. - */ -function countOccurrences(str: string, substring: string): number { - if (substring.length === 0) return 0 - let count = 0 - let position = 0 - while ((position = str.indexOf(substring, position)) !== -1) { - count++ - position += substring.length - } - return count -} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a700453498cf..a051ab1877a7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -9,6 +9,7 @@ import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" +import type { ACP } from "../acp/agent" import { generateText, streamText, @@ -109,6 +110,7 @@ export namespace SessionPrompt { noReply: z.boolean().optional(), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + acpAgent: z.custom().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -192,6 +194,7 @@ export namespace SessionPrompt { agent: agent.name, system, abort: abort.signal, + acpAgent: input.acpAgent, }) const tools = await resolveTools({ @@ -201,6 +204,7 @@ export namespace SessionPrompt { providerID: model.providerID, tools: input.tools, processor, + acpAgent: input.acpAgent, }) const params = await Plugin.trigger( @@ -518,6 +522,7 @@ export namespace SessionPrompt { providerID: string tools?: Record processor: Processor + acpAgent?: ACP.Agent }) { const tools: Record = {} const enabledTools = pipe( @@ -556,6 +561,7 @@ export namespace SessionPrompt { extra: { modelID: input.modelID, providerID: input.providerID, + acpAgent: input.acpAgent, }, agent: input.agent.name, metadata: async (val) => { @@ -951,6 +957,7 @@ export namespace SessionPrompt { system: string[] agent: string abort: AbortSignal + acpAgent?: ACP.Agent }) { const toolcalls: Record = {} let snapshot: string | undefined diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index ba3d2c0bf6c0..09bebc1827ef 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 type { ACP } from "../acp/agent" export const EditTool = Tool.define("edit", { description: DESCRIPTION, @@ -53,6 +54,7 @@ export const EditTool = Tool.define("edit", { } const agent = await Agent.get(ctx.agent) + const acpAgent = ctx.extra?.["acpAgent"] as ACP.Agent | undefined let diff = "" let contentOld = "" let contentNew = "" @@ -73,19 +75,40 @@ export const EditTool = Tool.define("edit", { }, }) } - await Bun.write(filePath, params.newString) + if (acpAgent) { + await acpAgent.writeTextFile({ + sessionId: ctx.sessionID, + path: filePath, + content: params.newString, + }) + } else { + await Bun.write(filePath, params.newString) + } await Bus.publish(File.Event.Edited, { file: filePath, }) return } - const file = Bun.file(filePath) - const stats = await file.stat().catch(() => {}) - if (!stats) throw new Error(`File ${filePath} not found`) - if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) + if (acpAgent) { + try { + const result = await acpAgent.readTextFile({ + sessionId: ctx.sessionID, + path: filePath, + }) + contentOld = result.content + } catch { + throw new Error(`File ${filePath} not found`) + } + } else { + const file = Bun.file(filePath) + const stats = await file.stat().catch(() => {}) + if (!stats) throw new Error(`File ${filePath} not found`) + if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) + contentOld = await file.text() + } + await FileTime.assert(ctx.sessionID, filePath) - contentOld = await file.text() contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) @@ -103,11 +126,28 @@ export const EditTool = Tool.define("edit", { }) } - await file.write(contentNew) + if (acpAgent) { + await acpAgent.writeTextFile({ + sessionId: ctx.sessionID, + path: filePath, + content: contentNew, + }) + } else { + await Bun.write(filePath, contentNew) + } await Bus.publish(File.Event.Edited, { file: filePath, }) - contentNew = await file.text() + if (acpAgent) { + const result = await acpAgent.readTextFile({ + sessionId: ctx.sessionID, + path: filePath, + }) + contentNew = result.content + } else { + const file = Bun.file(filePath) + contentNew = await file.text() + } diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) })() diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 963636fd10dc..d7c9868b0392 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,6 +10,7 @@ import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { Identifier } from "../id/id" import { Permission } from "../permission" +import type { ACP } from "../acp/agent" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -102,9 +103,21 @@ export const ReadTool = Tool.define("read", { const isBinary = await isBinaryFile(filepath, file) if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) + const acpAgent = ctx.extra?.["acpAgent"] as ACP.Agent | undefined const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - const lines = await file.text().then((text) => text.split("\n")) + const text = acpAgent + ? ( + await acpAgent.readTextFile({ + sessionId: ctx.sessionID, + path: filepath, + line: offset, + limit, + }) + ).content + : await file.text() + + const lines = text.split("\n") const raw = lines.slice(offset, offset + limit).map((line) => { return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line }) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index acaa123921c1..a9bd6c1363b2 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 type { ACP } from "../acp/agent" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -35,8 +36,23 @@ export const WriteTool = Tool.define("write", { }) } - const file = Bun.file(filepath) - const exists = await file.exists() + const acpAgent = ctx.extra?.["acpAgent"] as ACP.Agent | undefined + let exists = false + if (acpAgent) { + try { + await acpAgent.readTextFile({ + sessionId: ctx.sessionID, + path: filepath, + }) + exists = true + } catch { + exists = false + } + } else { + const file = Bun.file(filepath) + exists = await file.exists() + } + if (exists) await FileTime.assert(ctx.sessionID, filepath) const agent = await Agent.get(ctx.agent) @@ -54,7 +70,16 @@ export const WriteTool = Tool.define("write", { }, }) - await Bun.write(filepath, params.content) + if (acpAgent) { + await acpAgent.writeTextFile({ + sessionId: ctx.sessionID, + path: filepath, + content: params.content, + }) + } else { + await Bun.write(filepath, params.content) + } + await Bus.publish(File.Event.Edited, { file: filepath, }) From e0e9072b38eaa09563a05d03340e5bd5ab2ce43f Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 5 Nov 2025 20:46:52 -0800 Subject: [PATCH 03/25] revert changes to readme --- packages/opencode/src/acp/README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md index 4d2f227dec8a..d998cb22da8d 100644 --- a/packages/opencode/src/acp/README.md +++ b/packages/opencode/src/acp/README.md @@ -29,13 +29,6 @@ The implementation follows a clean separation of concerns: - Sets up JSON-RPC over stdio using the official library - Manages graceful shutdown on SIGTERM/SIGINT - Provides Instance context for the agent - -- **`tools.ts`** - ACP-aware tools for client file operation delegation - - Provides `acp_read`, `acp_edit`, and `acp_write` tools - - These tools delegate file operations to the ACP client (e.g., Zed) - - Registered automatically when client has `writeTextFile` capability - - Enables proper edit accumulation and undo/redo in supporting clients - - **`types.ts`** - Type definitions for internal use From 8408d94d619205841aa63b238a2712334e6829cb Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 5 Nov 2025 21:31:19 -0800 Subject: [PATCH 04/25] refactor ACP tools --- packages/opencode/src/acp/agent.ts | 4 ++-- packages/opencode/src/acp/session.ts | 12 ++++++++++-- packages/opencode/src/acp/types.ts | 3 ++- packages/opencode/src/session/prompt.ts | 6 ------ packages/opencode/src/tool/edit.ts | 5 +++-- packages/opencode/src/tool/read.ts | 5 +++-- packages/opencode/src/tool/write.ts | 5 +++-- 7 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 0aefd5a34366..9af1176fc81d 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -369,11 +369,12 @@ export namespace ACP { try { const model = await defaultModel(this.config) const hasACPTools = !!this.clientCapabilities?.fs?.writeTextFile + const acpAgent = hasACPTools ? this : undefined const session = await this.sessionManager.create( params.cwd, params.mcpServers, model, - hasACPTools, + acpAgent, ) log.info("creating_session", { @@ -617,7 +618,6 @@ export namespace ACP { }, parts, agent, - acpAgent: acpSession.hasACPTools ? this : undefined, }) return done } diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index ce06568e9232..4f62335ae72e 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -2,15 +2,21 @@ import type { McpServer } from "@agentclientprotocol/sdk" import { Session } from "../session" import { Provider } from "../provider/provider" import type { ACPSessionState } from "./types" +import type { ACP } from "./agent" export class ACPSessionManager { + private static registry = new Map() private sessions = new Map() + static getManagerForSession(sessionId: string): ACPSessionManager | undefined { + return ACPSessionManager.registry.get(sessionId) + } + async create( cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"], - hasACPTools?: boolean, + acpAgent?: ACP.Agent, ): Promise { const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` }) const sessionId = session.id @@ -23,10 +29,11 @@ export class ACPSessionManager { mcpServers, createdAt: new Date(), model: resolvedModel, - hasACPTools, + acpAgent, } this.sessions.set(sessionId, state) + ACPSessionManager.registry.set(sessionId, this) return state } @@ -40,6 +47,7 @@ export class ACPSessionManager { await Session.remove(sessionId).catch(() => {}) this.sessions.delete(sessionId) + ACPSessionManager.registry.delete(sessionId) } has(sessionId: string) { diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 5a8184e131a6..f7bc423a1ad9 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,4 +1,5 @@ import type { McpServer } from "@agentclientprotocol/sdk" +import type { ACP } from "./agent" export interface ACPSessionState { id: string @@ -11,7 +12,7 @@ export interface ACPSessionState { modelID: string } modeId?: string - hasACPTools?: boolean + acpAgent?: ACP.Agent } export interface ACPConfig { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a051ab1877a7..600091a4dffb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -110,7 +110,6 @@ export namespace SessionPrompt { noReply: z.boolean().optional(), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), - acpAgent: z.custom().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -194,7 +193,6 @@ export namespace SessionPrompt { agent: agent.name, system, abort: abort.signal, - acpAgent: input.acpAgent, }) const tools = await resolveTools({ @@ -204,7 +202,6 @@ export namespace SessionPrompt { providerID: model.providerID, tools: input.tools, processor, - acpAgent: input.acpAgent, }) const params = await Plugin.trigger( @@ -522,7 +519,6 @@ export namespace SessionPrompt { providerID: string tools?: Record processor: Processor - acpAgent?: ACP.Agent }) { const tools: Record = {} const enabledTools = pipe( @@ -561,7 +557,6 @@ export namespace SessionPrompt { extra: { modelID: input.modelID, providerID: input.providerID, - acpAgent: input.acpAgent, }, agent: input.agent.name, metadata: async (val) => { @@ -957,7 +952,6 @@ export namespace SessionPrompt { system: string[] agent: string abort: AbortSignal - acpAgent?: ACP.Agent }) { const toolcalls: Record = {} let snapshot: string | undefined diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 09bebc1827ef..cbfcb7f7039f 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -17,7 +17,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Snapshot } from "@/snapshot" -import type { ACP } from "../acp/agent" +import { ACPSessionManager } from "../acp/session" export const EditTool = Tool.define("edit", { description: DESCRIPTION, @@ -54,7 +54,8 @@ export const EditTool = Tool.define("edit", { } const agent = await Agent.get(ctx.agent) - const acpAgent = ctx.extra?.["acpAgent"] as ACP.Agent | undefined + const sessionManager = ACPSessionManager.getManagerForSession(ctx.sessionID) + const acpAgent = sessionManager?.get(ctx.sessionID)?.acpAgent let diff = "" let contentOld = "" let contentNew = "" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index d7c9868b0392..ed407daac829 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,7 +10,7 @@ import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { Identifier } from "../id/id" import { Permission } from "../permission" -import type { ACP } from "../acp/agent" +import { ACPSessionManager } from "../acp/session" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -103,7 +103,8 @@ export const ReadTool = Tool.define("read", { const isBinary = await isBinaryFile(filepath, file) if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) - const acpAgent = ctx.extra?.["acpAgent"] as ACP.Agent | undefined + const sessionManager = ACPSessionManager.getManagerForSession(ctx.sessionID) + const acpAgent = sessionManager?.get(ctx.sessionID)?.acpAgent const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 const text = acpAgent diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index a9bd6c1363b2..8264541ba4eb 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,7 +10,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" -import type { ACP } from "../acp/agent" +import { ACPSessionManager } from "../acp/session" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -36,7 +36,8 @@ export const WriteTool = Tool.define("write", { }) } - const acpAgent = ctx.extra?.["acpAgent"] as ACP.Agent | undefined + const sessionManager = ACPSessionManager.getManagerForSession(ctx.sessionID) + const acpAgent = sessionManager?.get(ctx.sessionID)?.acpAgent let exists = false if (acpAgent) { try { From 949e6b09c3ad324ea7974caf55e299073af3f6f1 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 5 Nov 2025 21:39:46 -0800 Subject: [PATCH 05/25] we don't need ACP for "does a file exist" we don't need ACP for "does a file exist" --- packages/opencode/src/tool/edit.ts | 22 +++++++++------------- packages/opencode/src/tool/write.ts | 21 ++++----------------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index cbfcb7f7039f..e4a1ae85c26d 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -91,21 +91,17 @@ export const EditTool = Tool.define("edit", { return } + const file = Bun.file(filePath) + const stats = await file.stat().catch(() => {}) + if (!stats) throw new Error(`File ${filePath} not found`) + if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) if (acpAgent) { - try { - const result = await acpAgent.readTextFile({ - sessionId: ctx.sessionID, - path: filePath, - }) - contentOld = result.content - } catch { - throw new Error(`File ${filePath} not found`) - } + const result = await acpAgent.readTextFile({ + sessionId: ctx.sessionID, + path: filePath, + }) + contentOld = result.content } else { - const file = Bun.file(filePath) - const stats = await file.stat().catch(() => {}) - if (!stats) throw new Error(`File ${filePath} not found`) - if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) contentOld = await file.text() } diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 8264541ba4eb..e4d45a812120 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -36,23 +36,8 @@ export const WriteTool = Tool.define("write", { }) } - const sessionManager = ACPSessionManager.getManagerForSession(ctx.sessionID) - const acpAgent = sessionManager?.get(ctx.sessionID)?.acpAgent - let exists = false - if (acpAgent) { - try { - await acpAgent.readTextFile({ - sessionId: ctx.sessionID, - path: filepath, - }) - exists = true - } catch { - exists = false - } - } else { - const file = Bun.file(filepath) - exists = await file.exists() - } + const file = Bun.file(filepath) + const exists = await file.exists() if (exists) await FileTime.assert(ctx.sessionID, filepath) @@ -71,6 +56,8 @@ export const WriteTool = Tool.define("write", { }, }) + const sessionManager = ACPSessionManager.getManagerForSession(ctx.sessionID) + const acpAgent = sessionManager?.get(ctx.sessionID)?.acpAgent if (acpAgent) { await acpAgent.writeTextFile({ sessionId: ctx.sessionID, From e1b0ab84487d293e6c4da989b244263db993a6c3 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 5 Nov 2025 21:46:27 -0800 Subject: [PATCH 06/25] remove unused import remove unused import remove unused import --- packages/opencode/src/session/prompt.ts | 1 - packages/opencode/src/tool/write.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 600091a4dffb..a700453498cf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -9,7 +9,6 @@ import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" -import type { ACP } from "../acp/agent" import { generateText, streamText, diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index e4d45a812120..c83f3402972a 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -38,7 +38,6 @@ export const WriteTool = Tool.define("write", { const file = Bun.file(filepath) const exists = await file.exists() - if (exists) await FileTime.assert(ctx.sessionID, filepath) const agent = await Agent.get(ctx.agent) From 8e86d9c0a50857d512819ad7170b00674f1ccc9e Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 5 Nov 2025 21:51:54 -0800 Subject: [PATCH 07/25] assure both capabilities are there --- packages/opencode/src/acp/agent.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 9af1176fc81d..dcce893caee0 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -368,7 +368,9 @@ export namespace ACP { async newSession(params: NewSessionRequest) { try { const model = await defaultModel(this.config) - const hasACPTools = !!this.clientCapabilities?.fs?.writeTextFile + const hasACPTools = + !!this.clientCapabilities?.fs?.readTextFile && + !!this.clientCapabilities?.fs?.writeTextFile const acpAgent = hasACPTools ? this : undefined const session = await this.sessionManager.create( params.cwd, From 399b0cf06e88950df9008024fcf89fd750f65b68 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Thu, 6 Nov 2025 12:06:01 -0800 Subject: [PATCH 08/25] address PR --- packages/opencode/src/acp/agent.ts | 28 +-- packages/opencode/src/acp/session.ts | 10 - packages/opencode/src/acp/types.ts | 2 - packages/opencode/src/tool/acp-edit.ts | 182 ++++++++++++++++++ packages/opencode/src/tool/acp-read.ts | 233 ++++++++++++++++++++++++ packages/opencode/src/tool/acp-write.ts | 97 ++++++++++ packages/opencode/src/tool/edit.ts | 45 +---- packages/opencode/src/tool/read.ts | 16 +- packages/opencode/src/tool/registry.ts | 7 +- packages/opencode/src/tool/write.ts | 14 +- 10 files changed, 539 insertions(+), 95 deletions(-) create mode 100644 packages/opencode/src/tool/acp-edit.ts create mode 100644 packages/opencode/src/tool/acp-read.ts create mode 100644 packages/opencode/src/tool/acp-write.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index dcce893caee0..7c2c91f867fa 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1,3 +1,4 @@ +// ACP Agent implementation for handling Agent Communication Protocol sessions and interactions import { RequestError, type Agent as ACPAgent, @@ -42,6 +43,10 @@ import { MCP } from "@/mcp" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" +import { ToolRegistry } from "@/tool/registry" +import { createACPReadTool } from "@/tool/acp-read" +import { createACPWriteTool } from "@/tool/acp-write" +import { createACPEditTool } from "@/tool/acp-edit" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -368,20 +373,21 @@ export namespace ACP { async newSession(params: NewSessionRequest) { try { const model = await defaultModel(this.config) - const hasACPTools = - !!this.clientCapabilities?.fs?.readTextFile && - !!this.clientCapabilities?.fs?.writeTextFile - const acpAgent = hasACPTools ? this : undefined - const session = await this.sessionManager.create( - params.cwd, - params.mcpServers, - model, - acpAgent, - ) + const clientSupportsRead = !!this.clientCapabilities?.fs?.readTextFile + const clientSupportsWrite = !!this.clientCapabilities?.fs?.writeTextFile + const session = await this.sessionManager.create(params.cwd, params.mcpServers, model) + if (clientSupportsRead) { + await ToolRegistry.register(createACPReadTool(this)) + } + if (clientSupportsWrite) { + await ToolRegistry.register(createACPWriteTool(this)) + await ToolRegistry.register(createACPEditTool(this)) + } log.info("creating_session", { mcpServers: params.mcpServers.length, - hasACPTools, + clientSupportsRead, + clientSupportsWrite, }) const load = await this.loadSession({ cwd: params.cwd, diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 4f62335ae72e..d3ab73d215e3 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -2,21 +2,14 @@ import type { McpServer } from "@agentclientprotocol/sdk" import { Session } from "../session" import { Provider } from "../provider/provider" import type { ACPSessionState } from "./types" -import type { ACP } from "./agent" export class ACPSessionManager { - private static registry = new Map() private sessions = new Map() - static getManagerForSession(sessionId: string): ACPSessionManager | undefined { - return ACPSessionManager.registry.get(sessionId) - } - async create( cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"], - acpAgent?: ACP.Agent, ): Promise { const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` }) const sessionId = session.id @@ -29,11 +22,9 @@ export class ACPSessionManager { mcpServers, createdAt: new Date(), model: resolvedModel, - acpAgent, } this.sessions.set(sessionId, state) - ACPSessionManager.registry.set(sessionId, this) return state } @@ -47,7 +38,6 @@ export class ACPSessionManager { await Session.remove(sessionId).catch(() => {}) this.sessions.delete(sessionId) - ACPSessionManager.registry.delete(sessionId) } has(sessionId: string) { diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index f7bc423a1ad9..119b335ceb34 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,5 +1,4 @@ import type { McpServer } from "@agentclientprotocol/sdk" -import type { ACP } from "./agent" export interface ACPSessionState { id: string @@ -12,7 +11,6 @@ export interface ACPSessionState { modelID: string } modeId?: string - acpAgent?: ACP.Agent } export interface ACPConfig { diff --git a/packages/opencode/src/tool/acp-edit.ts b/packages/opencode/src/tool/acp-edit.ts new file mode 100644 index 000000000000..0e48bdf128fb --- /dev/null +++ b/packages/opencode/src/tool/acp-edit.ts @@ -0,0 +1,182 @@ +// the approaches in this edit tool are sourced from +// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts +// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts +// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts + +import z from "zod" +import * as path from "path" +import { Tool } from "./tool" +import { LSP } from "../lsp" +import { createTwoFilesPatch, diffLines } from "diff" +import { Permission } from "../permission" +import DESCRIPTION from "./edit.txt" +import { File } from "../file" +import { Bus } from "../bus" +import { FileTime } from "../file/time" +import { Filesystem } from "../util/filesystem" +import { Instance } from "../project/instance" +import { Agent } from "../agent/agent" +import { Snapshot } from "@/snapshot" +import type { ACP } from "../acp/agent" +import { replace, trimDiff } from "./edit" + +export function createACPEditTool(acpAgent: ACP.Agent) { + return Tool.define("edit", async () => ({ + description: DESCRIPTION, + parameters: z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + oldString: z.string().describe("The text to replace"), + newString: z + .string() + .describe("The text to replace it with (must be different from oldString)"), + replaceAll: z + .boolean() + .optional() + .describe("Replace all occurrences of oldString (default false)"), + }), + async execute(params, ctx) { + if (!params.filePath) { + throw new Error("filePath is required") + } + + if (params.oldString === params.newString) { + throw new Error("oldString and newString must be different") + } + + const filePath = path.isAbsolute(params.filePath) + ? params.filePath + : path.join(Instance.directory, params.filePath) + if (!Filesystem.contains(Instance.directory, filePath)) { + const parentDir = path.dirname(filePath) + await Permission.ask({ + type: "external-directory", + pattern: parentDir, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Edit file outside working directory: ${filePath}`, + metadata: { + filepath: filePath, + parentDir, + }, + }) + } + + const agent = await Agent.get(ctx.agent) + let diff = "" + let contentOld = "" + let contentNew = "" + await (async () => { + if (params.oldString === "") { + contentNew = params.newString + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + if (agent.permission.edit === "ask") { + await Permission.ask({ + type: "edit", + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: "Edit this file: " + filePath, + metadata: { + filePath, + diff, + }, + }) + } + await acpAgent.writeTextFile({ + sessionId: ctx.sessionID, + path: filePath, + content: params.newString, + }) + await Bus.publish(File.Event.Edited, { + file: filePath, + }) + return + } + + const file = Bun.file(filePath) + const stats = await file.stat().catch(() => {}) + if (!stats) throw new Error(`File ${filePath} not found`) + if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) + + const result = await acpAgent.readTextFile({ + sessionId: ctx.sessionID, + path: filePath, + }) + contentOld = result.content + + await FileTime.assert(ctx.sessionID, filePath) + contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) + + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + if (agent.permission.edit === "ask") { + await Permission.ask({ + type: "edit", + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: "Edit this file: " + filePath, + metadata: { + filePath, + diff, + }, + }) + } + + await acpAgent.writeTextFile({ + sessionId: ctx.sessionID, + path: filePath, + content: contentNew, + }) + await Bus.publish(File.Event.Edited, { + file: filePath, + }) + + const verifyResult = await acpAgent.readTextFile({ + sessionId: ctx.sessionID, + path: filePath, + }) + contentNew = verifyResult.content + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + })() + + FileTime.read(ctx.sessionID, filePath) + + let output = "" + await LSP.touchFile(filePath, true) + const diagnostics = await LSP.diagnostics() + for (const [file, issues] of Object.entries(diagnostics)) { + if (issues.length === 0) continue + if (file === filePath) { + output += `\nThis file has errors, please fix\n\n${issues + .filter((item) => item.severity === 1) + .map(LSP.Diagnostic.pretty) + .join("\n")}\n\n` + continue + } + } + + const filediff: Snapshot.FileDiff = { + file: filePath, + before: contentOld, + after: contentNew, + additions: 0, + deletions: 0, + } + for (const change of diffLines(contentOld, contentNew)) { + if (change.added) filediff.additions += change.count || 0 + if (change.removed) filediff.deletions += change.count || 0 + } + + return { + metadata: { + diagnostics, + diff, + filediff, + }, + title: `${path.relative(Instance.worktree, filePath)}`, + output, + } + }, + })) +} diff --git a/packages/opencode/src/tool/acp-read.ts b/packages/opencode/src/tool/acp-read.ts new file mode 100644 index 000000000000..f28a9c36c555 --- /dev/null +++ b/packages/opencode/src/tool/acp-read.ts @@ -0,0 +1,233 @@ +import z from "zod" +import * as fs from "fs" +import * as path from "path" +import { Tool } from "./tool" +import { LSP } from "../lsp" +import { FileTime } from "../file/time" +import DESCRIPTION from "./read.txt" +import { Filesystem } from "../util/filesystem" +import { Instance } from "../project/instance" +import { Provider } from "../provider/provider" +import { Identifier } from "../id/id" +import { Permission } from "../permission" +import type { ACP } from "../acp/agent" + +const DEFAULT_READ_LIMIT = 2000 +const MAX_LINE_LENGTH = 2000 + +export function createACPReadTool(acpAgent: ACP.Agent) { + return Tool.define("read", async () => ({ + description: DESCRIPTION, + parameters: z.object({ + filePath: z.string().describe("The path to the file to read"), + offset: z.coerce + .number() + .describe("The line number to start reading from (0-based)") + .optional(), + limit: z.coerce + .number() + .describe("The number of lines to read (defaults to 2000)") + .optional(), + }), + async execute(params, ctx) { + let filepath = params.filePath + if (!path.isAbsolute(filepath)) { + filepath = path.join(process.cwd(), filepath) + } + const title = path.relative(Instance.worktree, filepath) + + if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { + const parentDir = path.dirname(filepath) + await Permission.ask({ + type: "external-directory", + pattern: parentDir, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Access file outside working directory: ${filepath}`, + metadata: { + filepath, + parentDir, + }, + }) + } + + const file = Bun.file(filepath) + if (!(await file.exists())) { + const dir = path.dirname(filepath) + const base = path.basename(filepath) + + const dirEntries = fs.readdirSync(dir) + const suggestions = dirEntries + .filter( + (entry) => + entry.toLowerCase().includes(base.toLowerCase()) || + base.toLowerCase().includes(entry.toLowerCase()), + ) + .map((entry) => path.join(dir, entry)) + .slice(0, 3) + + if (suggestions.length > 0) { + throw new Error( + `File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`, + ) + } + + throw new Error(`File not found: ${filepath}`) + } + + const isImage = isImageFile(filepath) + const supportsImages = await (async () => { + if (!ctx.extra?.["providerID"] || !ctx.extra?.["modelID"]) return false + const providerID = ctx.extra["providerID"] as string + const modelID = ctx.extra["modelID"] as string + const model = await Provider.getModel(providerID, modelID).catch(() => undefined) + if (!model) return false + return model.info.modalities?.input?.includes("image") ?? false + })() + if (isImage) { + if (!supportsImages) { + throw new Error(`Failed to read image: ${filepath}, model may not be able to read images`) + } + const mime = file.type + const msg = "Image read successfully" + return { + title, + output: msg, + metadata: { + preview: msg, + }, + attachments: [ + { + id: Identifier.ascending("part"), + sessionID: ctx.sessionID, + messageID: ctx.messageID, + type: "file", + mime, + url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, + }, + ], + } + } + + const isBinary = await isBinaryFile(filepath, file) + if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) + + const limit = params.limit ?? DEFAULT_READ_LIMIT + const offset = params.offset || 0 + + const result = await acpAgent.readTextFile({ + sessionId: ctx.sessionID, + path: filepath, + line: offset, + limit, + }) + const text = result.content + + const lines = text.split("\n") + const raw = lines.map((line) => { + return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line + }) + const content = raw.map((line, index) => { + return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}` + }) + const preview = raw.slice(0, 20).join("\n") + + let output = "\n" + output += content.join("\n") + + if (lines.length > offset + content.length) { + output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${offset + content.length})` + } + output += "\n" + + // just warms the lsp client + LSP.touchFile(filepath, false) + FileTime.read(ctx.sessionID, filepath) + + return { + title, + output, + metadata: { + preview, + }, + } + }, + })) +} + +function isImageFile(filePath: string): string | false { + const ext = path.extname(filePath).toLowerCase() + switch (ext) { + case ".jpg": + case ".jpeg": + return "JPEG" + case ".png": + return "PNG" + case ".gif": + return "GIF" + case ".bmp": + return "BMP" + case ".webp": + return "WebP" + default: + return false + } +} + +async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { + const ext = path.extname(filepath).toLowerCase() + // binary check for common non-text extensions + switch (ext) { + case ".zip": + case ".tar": + case ".gz": + case ".exe": + case ".dll": + case ".so": + case ".class": + case ".jar": + case ".war": + case ".7z": + case ".doc": + case ".docx": + case ".xls": + case ".xlsx": + case ".ppt": + case ".pptx": + case ".odt": + case ".ods": + case ".odp": + case ".bin": + case ".dat": + case ".obj": + case ".o": + case ".a": + case ".lib": + case ".wasm": + case ".pyc": + case ".pyo": + return true + default: + break + } + + const stat = await file.stat() + const fileSize = stat.size + if (fileSize === 0) return false + + const bufferSize = Math.min(4096, fileSize) + const buffer = await file.arrayBuffer() + if (buffer.byteLength === 0) return false + const bytes = new Uint8Array(buffer.slice(0, bufferSize)) + + let nonPrintableCount = 0 + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { + nonPrintableCount++ + } + } + // If >30% non-printable characters, consider it binary + return nonPrintableCount / bytes.length > 0.3 +} diff --git a/packages/opencode/src/tool/acp-write.ts b/packages/opencode/src/tool/acp-write.ts new file mode 100644 index 000000000000..14046b1ed7dc --- /dev/null +++ b/packages/opencode/src/tool/acp-write.ts @@ -0,0 +1,97 @@ +import z from "zod" +import * as path from "path" +import { Tool } from "./tool" +import { LSP } from "../lsp" +import { Permission } from "../permission" +import DESCRIPTION from "./write.txt" +import { Bus } from "../bus" +import { File } from "../file" +import { FileTime } from "../file/time" +import { Filesystem } from "../util/filesystem" +import { Instance } from "../project/instance" +import { Agent } from "../agent/agent" +import type { ACP } from "../acp/agent" + +export function createACPWriteTool(acpAgent: ACP.Agent) { + return Tool.define("write", async () => ({ + description: DESCRIPTION, + parameters: z.object({ + content: z.string().describe("The content to write to the file"), + filePath: z + .string() + .describe("The absolute path to the file to write (must be absolute, not relative)"), + }), + async execute(params, ctx) { + const filepath = path.isAbsolute(params.filePath) + ? params.filePath + : path.join(Instance.directory, params.filePath) + if (!Filesystem.contains(Instance.directory, filepath)) { + const parentDir = path.dirname(filepath) + await Permission.ask({ + type: "external-directory", + pattern: parentDir, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Write file outside working directory: ${filepath}`, + metadata: { + filepath, + parentDir, + }, + }) + } + + const file = Bun.file(filepath) + const exists = await file.exists() + if (exists) await FileTime.assert(ctx.sessionID, filepath) + + const agent = await Agent.get(ctx.agent) + if (agent.permission.edit === "ask") + await Permission.ask({ + type: "write", + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath, + metadata: { + filePath: filepath, + content: params.content, + exists, + }, + }) + + await acpAgent.writeTextFile({ + sessionId: ctx.sessionID, + path: filepath, + content: params.content, + }) + + await Bus.publish(File.Event.Edited, { + file: filepath, + }) + FileTime.read(ctx.sessionID, filepath) + + let output = "" + await LSP.touchFile(filepath, true) + const diagnostics = await LSP.diagnostics() + for (const [file, issues] of Object.entries(diagnostics)) { + if (issues.length === 0) continue + if (file === filepath) { + output += `\nThis file has errors, please fix\n\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` + continue + } + output += `\n\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` + } + + return { + title: path.relative(Instance.worktree, filepath), + metadata: { + diagnostics, + filepath, + exists: exists, + }, + output, + } + }, + })) +} diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index e4a1ae85c26d..ba3d2c0bf6c0 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -17,7 +17,6 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Snapshot } from "@/snapshot" -import { ACPSessionManager } from "../acp/session" export const EditTool = Tool.define("edit", { description: DESCRIPTION, @@ -54,8 +53,6 @@ export const EditTool = Tool.define("edit", { } const agent = await Agent.get(ctx.agent) - const sessionManager = ACPSessionManager.getManagerForSession(ctx.sessionID) - const acpAgent = sessionManager?.get(ctx.sessionID)?.acpAgent let diff = "" let contentOld = "" let contentNew = "" @@ -76,15 +73,7 @@ export const EditTool = Tool.define("edit", { }, }) } - if (acpAgent) { - await acpAgent.writeTextFile({ - sessionId: ctx.sessionID, - path: filePath, - content: params.newString, - }) - } else { - await Bun.write(filePath, params.newString) - } + await Bun.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, }) @@ -95,17 +84,8 @@ export const EditTool = Tool.define("edit", { const stats = await file.stat().catch(() => {}) if (!stats) throw new Error(`File ${filePath} not found`) if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) - if (acpAgent) { - const result = await acpAgent.readTextFile({ - sessionId: ctx.sessionID, - path: filePath, - }) - contentOld = result.content - } else { - contentOld = await file.text() - } - await FileTime.assert(ctx.sessionID, filePath) + contentOld = await file.text() contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) @@ -123,28 +103,11 @@ export const EditTool = Tool.define("edit", { }) } - if (acpAgent) { - await acpAgent.writeTextFile({ - sessionId: ctx.sessionID, - path: filePath, - content: contentNew, - }) - } else { - await Bun.write(filePath, contentNew) - } + await file.write(contentNew) await Bus.publish(File.Event.Edited, { file: filePath, }) - if (acpAgent) { - const result = await acpAgent.readTextFile({ - sessionId: ctx.sessionID, - path: filePath, - }) - contentNew = result.content - } else { - const file = Bun.file(filePath) - contentNew = await file.text() - } + contentNew = await file.text() diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) })() diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index ed407daac829..963636fd10dc 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,7 +10,6 @@ import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { Identifier } from "../id/id" import { Permission } from "../permission" -import { ACPSessionManager } from "../acp/session" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -103,22 +102,9 @@ export const ReadTool = Tool.define("read", { const isBinary = await isBinaryFile(filepath, file) if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) - const sessionManager = ACPSessionManager.getManagerForSession(ctx.sessionID) - const acpAgent = sessionManager?.get(ctx.sessionID)?.acpAgent const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - const text = acpAgent - ? ( - await acpAgent.readTextFile({ - sessionId: ctx.sessionID, - path: filepath, - line: offset, - limit, - }) - ).content - : await file.text() - - const lines = text.split("\n") + const lines = await file.text().then((text) => text.split("\n")) const raw = lines.slice(offset, offset + limit).map((line) => { return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c4d54597d2d2..0927564e4e10 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -73,7 +73,8 @@ export namespace ToolRegistry { async function all(): Promise { const custom = await state().then((x) => x.custom) - return [ + const customIds = new Set(custom.map((t) => t.id)) + const nativeTools = [ InvalidTool, BashTool, EditTool, @@ -86,8 +87,8 @@ export namespace ToolRegistry { TodoWriteTool, TodoReadTool, TaskTool, - ...custom, - ] + ].filter((tool) => !customIds.has(tool.id)) + return [...nativeTools, ...custom] } export async function ids() { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index c83f3402972a..acaa123921c1 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,7 +10,6 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" -import { ACPSessionManager } from "../acp/session" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -55,18 +54,7 @@ export const WriteTool = Tool.define("write", { }, }) - const sessionManager = ACPSessionManager.getManagerForSession(ctx.sessionID) - const acpAgent = sessionManager?.get(ctx.sessionID)?.acpAgent - if (acpAgent) { - await acpAgent.writeTextFile({ - sessionId: ctx.sessionID, - path: filepath, - content: params.content, - }) - } else { - await Bun.write(filepath, params.content) - } - + await Bun.write(filepath, params.content) await Bus.publish(File.Event.Edited, { file: filepath, }) From b011b6b8f410064f036bffaeab7df92080fa4211 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Thu, 6 Nov 2025 14:39:40 -0800 Subject: [PATCH 09/25] address PR --- packages/opencode/src/acp/agent.ts | 27 ++++++-------- packages/opencode/src/acp/types.ts | 13 ++++++- packages/opencode/src/session/prompt.ts | 5 +++ packages/opencode/src/tool/edit.ts | 47 ++++++++++++++++++++++--- packages/opencode/src/tool/read.ts | 19 ++++++++-- packages/opencode/src/tool/registry.ts | 7 ++-- packages/opencode/src/tool/write.ts | 13 ++++++- 7 files changed, 102 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 7c2c91f867fa..c3313ea9fdde 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1,4 +1,3 @@ -// ACP Agent implementation for handling Agent Communication Protocol sessions and interactions import { RequestError, type Agent as ACPAgent, @@ -26,7 +25,7 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" -import type { ACPConfig } from "./types" +import type { ACPConfig, ACPTools } from "./types" import { Provider } from "../provider/provider" import { SessionPrompt } from "../session/prompt" import { Installation } from "@/installation" @@ -43,10 +42,6 @@ import { MCP } from "@/mcp" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" -import { ToolRegistry } from "@/tool/registry" -import { createACPReadTool } from "@/tool/acp-read" -import { createACPWriteTool } from "@/tool/acp-write" -import { createACPEditTool } from "@/tool/acp-edit" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -373,21 +368,10 @@ export namespace ACP { async newSession(params: NewSessionRequest) { try { const model = await defaultModel(this.config) - const clientSupportsRead = !!this.clientCapabilities?.fs?.readTextFile - const clientSupportsWrite = !!this.clientCapabilities?.fs?.writeTextFile const session = await this.sessionManager.create(params.cwd, params.mcpServers, model) - if (clientSupportsRead) { - await ToolRegistry.register(createACPReadTool(this)) - } - if (clientSupportsWrite) { - await ToolRegistry.register(createACPWriteTool(this)) - await ToolRegistry.register(createACPEditTool(this)) - } log.info("creating_session", { mcpServers: params.mcpServers.length, - clientSupportsRead, - clientSupportsWrite, }) const load = await this.loadSession({ cwd: params.cwd, @@ -618,6 +602,14 @@ export namespace ACP { } if (!cmd) { + const { readTextFile, writeTextFile } = this.clientCapabilities?.fs ?? {} + const acpTools: ACPTools | undefined = + readTextFile || writeTextFile + ? { + ...(readTextFile && { readTextFile: (params) => this.readTextFile(params) }), + ...(writeTextFile && { writeTextFile: (params) => this.writeTextFile(params) }), + } + : undefined await SessionPrompt.prompt({ sessionID, model: { @@ -626,6 +618,7 @@ export namespace ACP { }, parts, agent, + acpTools, }) return done } diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 119b335ceb34..ac7d45d59b10 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,4 +1,15 @@ -import type { McpServer } from "@agentclientprotocol/sdk" +import type { + McpServer, + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from "@agentclientprotocol/sdk" + +export interface ACPTools { + readTextFile?(params: ReadTextFileRequest): Promise + writeTextFile?(params: WriteTextFileRequest): Promise +} export interface ACPSessionState { id: string diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a700453498cf..7aecbedcb64c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -9,6 +9,7 @@ import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" +import type { ACPTools } from "../acp/types" import { generateText, streamText, @@ -109,6 +110,7 @@ export namespace SessionPrompt { noReply: z.boolean().optional(), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + acpTools: z.custom().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -200,6 +202,7 @@ export namespace SessionPrompt { modelID: model.modelID, providerID: model.providerID, tools: input.tools, + acpTools: input.acpTools, processor, }) @@ -518,6 +521,7 @@ export namespace SessionPrompt { providerID: string tools?: Record processor: Processor + acpTools?: ACPTools }) { const tools: Record = {} const enabledTools = pipe( @@ -556,6 +560,7 @@ export namespace SessionPrompt { extra: { modelID: input.modelID, providerID: input.providerID, + acpTools: input.acpTools, }, agent: input.agent.name, metadata: async (val) => { diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index ba3d2c0bf6c0..cdd2dc6c5635 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 type { ACPTools } from "../acp/types" export const EditTool = Tool.define("edit", { description: DESCRIPTION, @@ -53,6 +54,7 @@ export const EditTool = Tool.define("edit", { } const agent = await Agent.get(ctx.agent) + const acpTools = ctx.extra?.["acpTools"] as ACPTools | undefined let diff = "" let contentOld = "" let contentNew = "" @@ -73,7 +75,15 @@ export const EditTool = Tool.define("edit", { }, }) } - await Bun.write(filePath, params.newString) + if (acpTools?.writeTextFile) { + await acpTools.writeTextFile({ + sessionId: ctx.sessionID, + path: filePath, + content: params.newString, + }) + } else { + await Bun.write(filePath, params.newString) + } await Bus.publish(File.Event.Edited, { file: filePath, }) @@ -85,7 +95,17 @@ 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() + if (acpTools?.readTextFile) { + contentOld = ( + await acpTools.readTextFile({ + sessionId: ctx.sessionID, + path: filePath, + }) + ).content + } else { + contentOld = await file.text() + } + contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) @@ -103,11 +123,30 @@ export const EditTool = Tool.define("edit", { }) } - await file.write(contentNew) + if (acpTools?.writeTextFile) { + await acpTools.writeTextFile({ + sessionId: ctx.sessionID, + path: filePath, + content: contentNew, + }) + if (acpTools.readTextFile) { + contentNew = ( + await acpTools.readTextFile({ + sessionId: ctx.sessionID, + path: filePath, + }) + ).content + } else { + contentNew = await Bun.file(filePath).text() + } + } else { + await Bun.write(filePath, contentNew) + contentNew = await file.text() + } + await Bus.publish(File.Event.Edited, { file: filePath, }) - contentNew = await file.text() diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) })() diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 963636fd10dc..64934879092a 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,6 +10,7 @@ import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { Identifier } from "../id/id" import { Permission } from "../permission" +import type { ACPTools } from "../acp/types" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -104,8 +105,22 @@ 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) => { + const acpTools = ctx.extra?.["acpTools"] as ACPTools | undefined + let lines: string[] + if (acpTools?.readTextFile) { + const result = await acpTools.readTextFile({ + sessionId: ctx.sessionID, + path: filepath, + line: offset, + limit, + }) + lines = result.content.split("\n") + } else { + const allLines = (await file.text()).split("\n") + lines = allLines.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/registry.ts b/packages/opencode/src/tool/registry.ts index 0927564e4e10..c4d54597d2d2 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -73,8 +73,7 @@ export namespace ToolRegistry { async function all(): Promise { const custom = await state().then((x) => x.custom) - const customIds = new Set(custom.map((t) => t.id)) - const nativeTools = [ + return [ InvalidTool, BashTool, EditTool, @@ -87,8 +86,8 @@ export namespace ToolRegistry { TodoWriteTool, TodoReadTool, TaskTool, - ].filter((tool) => !customIds.has(tool.id)) - return [...nativeTools, ...custom] + ...custom, + ] } export async function ids() { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index acaa123921c1..e4e243bfb063 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 type { ACPTools } from "../acp/types" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -40,6 +41,7 @@ export const WriteTool = Tool.define("write", { if (exists) await FileTime.assert(ctx.sessionID, filepath) const agent = await Agent.get(ctx.agent) + const acpTools = ctx.extra?.["acpTools"] as ACPTools | undefined if (agent.permission.edit === "ask") await Permission.ask({ type: "write", @@ -54,7 +56,16 @@ export const WriteTool = Tool.define("write", { }, }) - await Bun.write(filepath, params.content) + if (acpTools?.writeTextFile) { + await acpTools.writeTextFile({ + sessionId: ctx.sessionID, + path: filepath, + content: params.content, + }) + } else { + await Bun.write(filepath, params.content) + } + await Bus.publish(File.Event.Edited, { file: filepath, }) From 07de79ecbf8708801515a84245b6e137f7b52ac1 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Thu, 6 Nov 2025 14:54:59 -0800 Subject: [PATCH 10/25] cleanup --- packages/opencode/src/acp/agent.ts | 4 +- packages/opencode/src/tool/acp-edit.ts | 182 ------------------ packages/opencode/src/tool/acp-read.ts | 233 ------------------------ packages/opencode/src/tool/acp-write.ts | 97 ---------- 4 files changed, 1 insertion(+), 515 deletions(-) delete mode 100644 packages/opencode/src/tool/acp-edit.ts delete mode 100644 packages/opencode/src/tool/acp-read.ts delete mode 100644 packages/opencode/src/tool/acp-write.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index c3313ea9fdde..2743add60dfc 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -370,9 +370,7 @@ export namespace ACP { const model = await defaultModel(this.config) const session = await this.sessionManager.create(params.cwd, params.mcpServers, model) - log.info("creating_session", { - mcpServers: params.mcpServers.length, - }) + log.info("creating_session", { mcpServers: params.mcpServers.length }) const load = await this.loadSession({ cwd: params.cwd, mcpServers: params.mcpServers, diff --git a/packages/opencode/src/tool/acp-edit.ts b/packages/opencode/src/tool/acp-edit.ts deleted file mode 100644 index 0e48bdf128fb..000000000000 --- a/packages/opencode/src/tool/acp-edit.ts +++ /dev/null @@ -1,182 +0,0 @@ -// the approaches in this edit tool are sourced from -// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts -// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts -// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts - -import z from "zod" -import * as path from "path" -import { Tool } from "./tool" -import { LSP } from "../lsp" -import { createTwoFilesPatch, diffLines } from "diff" -import { Permission } from "../permission" -import DESCRIPTION from "./edit.txt" -import { File } from "../file" -import { Bus } from "../bus" -import { FileTime } from "../file/time" -import { Filesystem } from "../util/filesystem" -import { Instance } from "../project/instance" -import { Agent } from "../agent/agent" -import { Snapshot } from "@/snapshot" -import type { ACP } from "../acp/agent" -import { replace, trimDiff } from "./edit" - -export function createACPEditTool(acpAgent: ACP.Agent) { - return Tool.define("edit", async () => ({ - description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z - .string() - .describe("The text to replace it with (must be different from oldString)"), - replaceAll: z - .boolean() - .optional() - .describe("Replace all occurrences of oldString (default false)"), - }), - async execute(params, ctx) { - if (!params.filePath) { - throw new Error("filePath is required") - } - - if (params.oldString === params.newString) { - throw new Error("oldString and newString must be different") - } - - const filePath = path.isAbsolute(params.filePath) - ? params.filePath - : path.join(Instance.directory, params.filePath) - if (!Filesystem.contains(Instance.directory, filePath)) { - const parentDir = path.dirname(filePath) - await Permission.ask({ - type: "external-directory", - pattern: parentDir, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Edit file outside working directory: ${filePath}`, - metadata: { - filepath: filePath, - parentDir, - }, - }) - } - - const agent = await Agent.get(ctx.agent) - let diff = "" - let contentOld = "" - let contentNew = "" - await (async () => { - if (params.oldString === "") { - contentNew = params.newString - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - if (agent.permission.edit === "ask") { - await Permission.ask({ - type: "edit", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Edit this file: " + filePath, - metadata: { - filePath, - diff, - }, - }) - } - await acpAgent.writeTextFile({ - sessionId: ctx.sessionID, - path: filePath, - content: params.newString, - }) - await Bus.publish(File.Event.Edited, { - file: filePath, - }) - return - } - - const file = Bun.file(filePath) - const stats = await file.stat().catch(() => {}) - if (!stats) throw new Error(`File ${filePath} not found`) - if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) - - const result = await acpAgent.readTextFile({ - sessionId: ctx.sessionID, - path: filePath, - }) - contentOld = result.content - - await FileTime.assert(ctx.sessionID, filePath) - contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) - - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - if (agent.permission.edit === "ask") { - await Permission.ask({ - type: "edit", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Edit this file: " + filePath, - metadata: { - filePath, - diff, - }, - }) - } - - await acpAgent.writeTextFile({ - sessionId: ctx.sessionID, - path: filePath, - content: contentNew, - }) - await Bus.publish(File.Event.Edited, { - file: filePath, - }) - - const verifyResult = await acpAgent.readTextFile({ - sessionId: ctx.sessionID, - path: filePath, - }) - contentNew = verifyResult.content - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - })() - - FileTime.read(ctx.sessionID, filePath) - - let output = "" - await LSP.touchFile(filePath, true) - const diagnostics = await LSP.diagnostics() - for (const [file, issues] of Object.entries(diagnostics)) { - if (issues.length === 0) continue - if (file === filePath) { - output += `\nThis file has errors, please fix\n\n${issues - .filter((item) => item.severity === 1) - .map(LSP.Diagnostic.pretty) - .join("\n")}\n\n` - continue - } - } - - const filediff: Snapshot.FileDiff = { - file: filePath, - before: contentOld, - after: contentNew, - additions: 0, - deletions: 0, - } - for (const change of diffLines(contentOld, contentNew)) { - if (change.added) filediff.additions += change.count || 0 - if (change.removed) filediff.deletions += change.count || 0 - } - - return { - metadata: { - diagnostics, - diff, - filediff, - }, - title: `${path.relative(Instance.worktree, filePath)}`, - output, - } - }, - })) -} diff --git a/packages/opencode/src/tool/acp-read.ts b/packages/opencode/src/tool/acp-read.ts deleted file mode 100644 index f28a9c36c555..000000000000 --- a/packages/opencode/src/tool/acp-read.ts +++ /dev/null @@ -1,233 +0,0 @@ -import z from "zod" -import * as fs from "fs" -import * as path from "path" -import { Tool } from "./tool" -import { LSP } from "../lsp" -import { FileTime } from "../file/time" -import DESCRIPTION from "./read.txt" -import { Filesystem } from "../util/filesystem" -import { Instance } from "../project/instance" -import { Provider } from "../provider/provider" -import { Identifier } from "../id/id" -import { Permission } from "../permission" -import type { ACP } from "../acp/agent" - -const DEFAULT_READ_LIMIT = 2000 -const MAX_LINE_LENGTH = 2000 - -export function createACPReadTool(acpAgent: ACP.Agent) { - return Tool.define("read", async () => ({ - description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The path to the file to read"), - offset: z.coerce - .number() - .describe("The line number to start reading from (0-based)") - .optional(), - limit: z.coerce - .number() - .describe("The number of lines to read (defaults to 2000)") - .optional(), - }), - async execute(params, ctx) { - let filepath = params.filePath - if (!path.isAbsolute(filepath)) { - filepath = path.join(process.cwd(), filepath) - } - const title = path.relative(Instance.worktree, filepath) - - if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { - const parentDir = path.dirname(filepath) - await Permission.ask({ - type: "external-directory", - pattern: parentDir, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Access file outside working directory: ${filepath}`, - metadata: { - filepath, - parentDir, - }, - }) - } - - const file = Bun.file(filepath) - if (!(await file.exists())) { - const dir = path.dirname(filepath) - const base = path.basename(filepath) - - const dirEntries = fs.readdirSync(dir) - const suggestions = dirEntries - .filter( - (entry) => - entry.toLowerCase().includes(base.toLowerCase()) || - base.toLowerCase().includes(entry.toLowerCase()), - ) - .map((entry) => path.join(dir, entry)) - .slice(0, 3) - - if (suggestions.length > 0) { - throw new Error( - `File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`, - ) - } - - throw new Error(`File not found: ${filepath}`) - } - - const isImage = isImageFile(filepath) - const supportsImages = await (async () => { - if (!ctx.extra?.["providerID"] || !ctx.extra?.["modelID"]) return false - const providerID = ctx.extra["providerID"] as string - const modelID = ctx.extra["modelID"] as string - const model = await Provider.getModel(providerID, modelID).catch(() => undefined) - if (!model) return false - return model.info.modalities?.input?.includes("image") ?? false - })() - if (isImage) { - if (!supportsImages) { - throw new Error(`Failed to read image: ${filepath}, model may not be able to read images`) - } - const mime = file.type - const msg = "Image read successfully" - return { - title, - output: msg, - metadata: { - preview: msg, - }, - attachments: [ - { - id: Identifier.ascending("part"), - sessionID: ctx.sessionID, - messageID: ctx.messageID, - type: "file", - mime, - url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, - }, - ], - } - } - - const isBinary = await isBinaryFile(filepath, file) - if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) - - const limit = params.limit ?? DEFAULT_READ_LIMIT - const offset = params.offset || 0 - - const result = await acpAgent.readTextFile({ - sessionId: ctx.sessionID, - path: filepath, - line: offset, - limit, - }) - const text = result.content - - const lines = text.split("\n") - const raw = lines.map((line) => { - return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line - }) - const content = raw.map((line, index) => { - return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}` - }) - const preview = raw.slice(0, 20).join("\n") - - let output = "\n" - output += content.join("\n") - - if (lines.length > offset + content.length) { - output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${offset + content.length})` - } - output += "\n" - - // just warms the lsp client - LSP.touchFile(filepath, false) - FileTime.read(ctx.sessionID, filepath) - - return { - title, - output, - metadata: { - preview, - }, - } - }, - })) -} - -function isImageFile(filePath: string): string | false { - const ext = path.extname(filePath).toLowerCase() - switch (ext) { - case ".jpg": - case ".jpeg": - return "JPEG" - case ".png": - return "PNG" - case ".gif": - return "GIF" - case ".bmp": - return "BMP" - case ".webp": - return "WebP" - default: - return false - } -} - -async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { - const ext = path.extname(filepath).toLowerCase() - // binary check for common non-text extensions - switch (ext) { - case ".zip": - case ".tar": - case ".gz": - case ".exe": - case ".dll": - case ".so": - case ".class": - case ".jar": - case ".war": - case ".7z": - case ".doc": - case ".docx": - case ".xls": - case ".xlsx": - case ".ppt": - case ".pptx": - case ".odt": - case ".ods": - case ".odp": - case ".bin": - case ".dat": - case ".obj": - case ".o": - case ".a": - case ".lib": - case ".wasm": - case ".pyc": - case ".pyo": - return true - default: - break - } - - const stat = await file.stat() - const fileSize = stat.size - if (fileSize === 0) return false - - const bufferSize = Math.min(4096, fileSize) - const buffer = await file.arrayBuffer() - if (buffer.byteLength === 0) return false - const bytes = new Uint8Array(buffer.slice(0, bufferSize)) - - let nonPrintableCount = 0 - for (let i = 0; i < bytes.length; i++) { - if (bytes[i] === 0) return true - if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { - nonPrintableCount++ - } - } - // If >30% non-printable characters, consider it binary - return nonPrintableCount / bytes.length > 0.3 -} diff --git a/packages/opencode/src/tool/acp-write.ts b/packages/opencode/src/tool/acp-write.ts deleted file mode 100644 index 14046b1ed7dc..000000000000 --- a/packages/opencode/src/tool/acp-write.ts +++ /dev/null @@ -1,97 +0,0 @@ -import z from "zod" -import * as path from "path" -import { Tool } from "./tool" -import { LSP } from "../lsp" -import { Permission } from "../permission" -import DESCRIPTION from "./write.txt" -import { Bus } from "../bus" -import { File } from "../file" -import { FileTime } from "../file/time" -import { Filesystem } from "../util/filesystem" -import { Instance } from "../project/instance" -import { Agent } from "../agent/agent" -import type { ACP } from "../acp/agent" - -export function createACPWriteTool(acpAgent: ACP.Agent) { - return Tool.define("write", async () => ({ - description: DESCRIPTION, - parameters: z.object({ - content: z.string().describe("The content to write to the file"), - filePath: z - .string() - .describe("The absolute path to the file to write (must be absolute, not relative)"), - }), - async execute(params, ctx) { - const filepath = path.isAbsolute(params.filePath) - ? params.filePath - : path.join(Instance.directory, params.filePath) - if (!Filesystem.contains(Instance.directory, filepath)) { - const parentDir = path.dirname(filepath) - await Permission.ask({ - type: "external-directory", - pattern: parentDir, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Write file outside working directory: ${filepath}`, - metadata: { - filepath, - parentDir, - }, - }) - } - - const file = Bun.file(filepath) - const exists = await file.exists() - if (exists) await FileTime.assert(ctx.sessionID, filepath) - - const agent = await Agent.get(ctx.agent) - if (agent.permission.edit === "ask") - await Permission.ask({ - type: "write", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath, - metadata: { - filePath: filepath, - content: params.content, - exists, - }, - }) - - await acpAgent.writeTextFile({ - sessionId: ctx.sessionID, - path: filepath, - content: params.content, - }) - - await Bus.publish(File.Event.Edited, { - file: filepath, - }) - FileTime.read(ctx.sessionID, filepath) - - let output = "" - await LSP.touchFile(filepath, true) - const diagnostics = await LSP.diagnostics() - for (const [file, issues] of Object.entries(diagnostics)) { - if (issues.length === 0) continue - if (file === filepath) { - output += `\nThis file has errors, please fix\n\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` - continue - } - output += `\n\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` - } - - return { - title: path.relative(Instance.worktree, filepath), - metadata: { - diagnostics, - filepath, - exists: exists, - }, - output, - } - }, - })) -} From 268dd0737646da82bef26aa3058c1a6bf0a74102 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Thu, 6 Nov 2025 15:09:37 -0800 Subject: [PATCH 11/25] cleanup --- packages/opencode/src/session/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8a28e138fdc9..680c4caecd67 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -522,8 +522,8 @@ export namespace SessionPrompt { modelID: string providerID: string tools?: Record - processor: Processor acpTools?: ACPTools + processor: Processor }) { const tools: Record = {} const enabledTools = pipe( From 63e85c3db2b9b6abe83e0dc3bc00e39268b049ff Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Fri, 7 Nov 2025 23:56:31 -0800 Subject: [PATCH 12/25] refactor merge after 'dev' --- packages/opencode/src/acp/agent.ts | 14 +++++++++++++- packages/opencode/src/acp/registry.ts | 17 +++++++++++++++++ packages/opencode/src/acp/types.ts | 13 ++++++++++++- packages/opencode/src/session/prompt.ts | 5 ----- packages/opencode/src/tool/edit.ts | 4 ++-- packages/opencode/src/tool/read.ts | 4 ++-- packages/opencode/src/tool/write.ts | 4 ++-- 7 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/src/acp/registry.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index fec9595beaa3..2fcd7e09b037 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -30,11 +30,12 @@ import { Provider } from "../provider/provider" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" -import { MCP } from "@/mcp" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" import type { OpencodeClient } from "@opencode-ai/sdk" +import type { ACPTools } from "./types" +import { ACPToolRegistry } from "./registry" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -56,6 +57,7 @@ export namespace ACP { private config: ACPConfig private sdk: OpencodeClient private sessionManager + private clientCapabilities?: ClientCapabilities constructor(connection: AgentSideConnection, config: ACPConfig) { this.connection = connection @@ -64,6 +66,15 @@ export namespace ACP { this.sessionManager = new ACPSessionManager(this.sdk) } + private registerACPTools(sessionID: string) { + const { readTextFile, writeTextFile } = this.clientCapabilities?.fs ?? {} + const acpTools: ACPTools = { + ...(readTextFile && { readTextFile: (params) => this.readTextFile(params) }), + ...(writeTextFile && { writeTextFile: (params) => this.writeTextFile(params) }), + } + ACPToolRegistry.set(sessionID, acpTools) + } + private setupEventSubscriptions(session: ACPSessionState) { const sessionId = session.id const directory = session.cwd @@ -403,6 +414,7 @@ export namespace ACP { }) this.setupEventSubscriptions(state) + this.registerACPTools(sessionId) return { sessionId, diff --git a/packages/opencode/src/acp/registry.ts b/packages/opencode/src/acp/registry.ts new file mode 100644 index 000000000000..b2c576760660 --- /dev/null +++ b/packages/opencode/src/acp/registry.ts @@ -0,0 +1,17 @@ +import type { ACPTools } from "./types" + +export namespace ACPToolRegistry { + const registry = new Map() + + export function set(sessionID: string, tools: ACPTools) { + registry.set(sessionID, tools) + } + + export function get(sessionID: string): ACPTools | undefined { + return registry.get(sessionID) + } + + export function remove(sessionID: string) { + registry.delete(sessionID) + } +} diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 8507228edeaf..074da05af2be 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,4 +1,10 @@ -import type { McpServer } from "@agentclientprotocol/sdk" +import type { + McpServer, + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk" export interface ACPSessionState { @@ -20,3 +26,8 @@ export interface ACPConfig { modelID: string } } + +export interface ACPTools { + readTextFile?(params: ReadTextFileRequest): Promise + writeTextFile?(params: WriteTextFileRequest): Promise +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fdef191996e9..1802b1a3bc2a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -9,7 +9,6 @@ import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" -import type { ACPTools } from "../acp/types" import { generateText, streamText, @@ -110,7 +109,6 @@ export namespace SessionPrompt { noReply: z.boolean().optional(), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), - acpTools: z.custom().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -202,7 +200,6 @@ export namespace SessionPrompt { modelID: model.modelID, providerID: model.providerID, tools: input.tools, - acpTools: input.acpTools, processor, }) @@ -512,7 +509,6 @@ export namespace SessionPrompt { modelID: string providerID: string tools?: Record - acpTools?: ACPTools processor: Processor }) { const tools: Record = {} @@ -548,7 +544,6 @@ export namespace SessionPrompt { extra: { modelID: input.modelID, providerID: input.providerID, - acpTools: input.acpTools, }, agent: input.agent.name, metadata: async (val) => { diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index cdd2dc6c5635..a5dbe669bac9 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -17,7 +17,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Snapshot } from "@/snapshot" -import type { ACPTools } from "../acp/types" +import { ACPToolRegistry } from "@/acp/registry" export const EditTool = Tool.define("edit", { description: DESCRIPTION, @@ -54,7 +54,7 @@ export const EditTool = Tool.define("edit", { } const agent = await Agent.get(ctx.agent) - const acpTools = ctx.extra?.["acpTools"] as ACPTools | undefined + const acpTools = ACPToolRegistry.get(ctx.sessionID) let diff = "" let contentOld = "" let contentNew = "" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 64934879092a..e72343a47ad7 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,7 +10,7 @@ import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { Identifier } from "../id/id" import { Permission } from "../permission" -import type { ACPTools } from "../acp/types" +import { ACPToolRegistry } from "@/acp/registry" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -105,7 +105,7 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - const acpTools = ctx.extra?.["acpTools"] as ACPTools | undefined + const acpTools = ACPToolRegistry.get(ctx.sessionID) let lines: string[] if (acpTools?.readTextFile) { const result = await acpTools.readTextFile({ diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index e4e243bfb063..1bc353c8ffa3 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,7 +10,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" -import type { ACPTools } from "../acp/types" +import { ACPToolRegistry } from "@/acp/registry" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -41,7 +41,7 @@ export const WriteTool = Tool.define("write", { if (exists) await FileTime.assert(ctx.sessionID, filepath) const agent = await Agent.get(ctx.agent) - const acpTools = ctx.extra?.["acpTools"] as ACPTools | undefined + const acpTools = ACPToolRegistry.get(ctx.sessionID) if (agent.permission.edit === "ask") await Permission.ask({ type: "write", From c95d2f74119ea197dba07d95f5ebf731b42fecda Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Sat, 8 Nov 2025 00:23:16 -0800 Subject: [PATCH 13/25] update --- packages/opencode/src/acp/registry.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/opencode/src/acp/registry.ts b/packages/opencode/src/acp/registry.ts index b2c576760660..84c27cc8065a 100644 --- a/packages/opencode/src/acp/registry.ts +++ b/packages/opencode/src/acp/registry.ts @@ -10,8 +10,4 @@ export namespace ACPToolRegistry { export function get(sessionID: string): ACPTools | undefined { return registry.get(sessionID) } - - export function remove(sessionID: string) { - registry.delete(sessionID) - } } From 47a99f6fce57b89726dd3459f054eb026285afba Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Sun, 9 Nov 2025 18:34:44 -0800 Subject: [PATCH 14/25] cleanup --- packages/opencode/src/tool/edit.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 9e23503aadf0..5be75cbeae6f 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -37,6 +37,7 @@ export const EditTool = Tool.define("edit", { } const agent = await Agent.get(ctx.agent) + const acpTools = ACPToolRegistry.get(ctx.sessionID) const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) if (!Filesystem.contains(Instance.directory, filePath)) { @@ -57,7 +58,6 @@ export const EditTool = Tool.define("edit", { } } - const acpTools = ACPToolRegistry.get(ctx.sessionID) let diff = "" let contentOld = "" let contentNew = "" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 0e9411dc6486..5c32344e4ecf 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -30,6 +30,7 @@ export const ReadTool = Tool.define("read", { } const title = path.relative(Instance.worktree, filepath) const agent = await Agent.get(ctx.agent) + const acpTools = ACPToolRegistry.get(ctx.sessionID) if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) @@ -109,7 +110,6 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - const acpTools = ACPToolRegistry.get(ctx.sessionID) let lines: string[] if (acpTools?.readTextFile) { const result = await acpTools.readTextFile({ From 71bb16376702f5e7892e44ebc7c38ad714dbad9a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 13 Nov 2025 19:27:55 +0000 Subject: [PATCH 15/25] chore: format code --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ef647bae77a8..41fef23b6710 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 0ba3828302fe..ff3b9170caa1 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 5a2779cad2d0df3348a648660fe4bc2f6787da44 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Sun, 16 Nov 2025 11:41:25 -0800 Subject: [PATCH 16/25] fix after merge --- packages/opencode/src/tool/edit.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 3ab6c22ca397..2cc850d6c145 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -156,7 +156,10 @@ export const EditTool = Tool.define("edit", { await Bus.publish(File.Event.Edited, { file: filePath, }) - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + contentNew = await file.text() + diff = trimDiff( + createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), + ) })() FileTime.read(ctx.sessionID, filePath) From 1ff6c62aeedabe55bdced24a1d1b3db7c6f559c8 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Tue, 18 Nov 2025 12:17:43 -0800 Subject: [PATCH 17/25] rework for domain --- packages/opencode/src/acp/agent.ts | 29 ++++++++++++---- packages/opencode/src/acp/registry.ts | 13 -------- packages/opencode/src/acp/types.ts | 9 ----- packages/opencode/src/tool/delegate.ts | 21 ++++++++++++ packages/opencode/src/tool/edit.ts | 46 +++++--------------------- packages/opencode/src/tool/read.ts | 21 ++++-------- packages/opencode/src/tool/write.ts | 14 ++------ 7 files changed, 63 insertions(+), 90 deletions(-) delete mode 100644 packages/opencode/src/acp/registry.ts create mode 100644 packages/opencode/src/tool/delegate.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2fcd7e09b037..dd1689c3b0c4 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -34,8 +34,7 @@ import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" import type { OpencodeClient } from "@opencode-ai/sdk" -import type { ACPTools } from "./types" -import { ACPToolRegistry } from "./registry" +import { FileSystemDelegate } from "../tool/delegate" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -68,11 +67,29 @@ export namespace ACP { private registerACPTools(sessionID: string) { const { readTextFile, writeTextFile } = this.clientCapabilities?.fs ?? {} - const acpTools: ACPTools = { - ...(readTextFile && { readTextFile: (params) => this.readTextFile(params) }), - ...(writeTextFile && { writeTextFile: (params) => this.writeTextFile(params) }), + 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, + }) + }, + }), } - ACPToolRegistry.set(sessionID, acpTools) + FileSystemDelegate.register(sessionID, delegate) } private setupEventSubscriptions(session: ACPSessionState) { diff --git a/packages/opencode/src/acp/registry.ts b/packages/opencode/src/acp/registry.ts deleted file mode 100644 index 84c27cc8065a..000000000000 --- a/packages/opencode/src/acp/registry.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ACPTools } from "./types" - -export namespace ACPToolRegistry { - const registry = new Map() - - export function set(sessionID: string, tools: ACPTools) { - registry.set(sessionID, tools) - } - - export function get(sessionID: string): ACPTools | undefined { - return registry.get(sessionID) - } -} diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 074da05af2be..2b8869ccc409 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,9 +1,5 @@ import type { McpServer, - ReadTextFileRequest, - ReadTextFileResponse, - WriteTextFileRequest, - WriteTextFileResponse, } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk" @@ -26,8 +22,3 @@ export interface ACPConfig { modelID: string } } - -export interface ACPTools { - readTextFile?(params: ReadTextFileRequest): Promise - writeTextFile?(params: WriteTextFileRequest): Promise -} diff --git a/packages/opencode/src/tool/delegate.ts b/packages/opencode/src/tool/delegate.ts new file mode 100644 index 000000000000..6a510db43824 --- /dev/null +++ b/packages/opencode/src/tool/delegate.ts @@ -0,0 +1,21 @@ + +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 2cc850d6c145..56c8d7df5ed0 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -17,7 +17,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Snapshot } from "@/snapshot" -import { ACPToolRegistry } from "@/acp/registry" +import { FileSystemDelegate } from "./delegate" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") @@ -41,7 +41,7 @@ export const EditTool = Tool.define("edit", { } const agent = await Agent.get(ctx.agent) - const acpTools = ACPToolRegistry.get(ctx.sessionID) + 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)) { @@ -82,15 +82,9 @@ export const EditTool = Tool.define("edit", { }, }) } - if (acpTools?.writeTextFile) { - await acpTools.writeTextFile({ - sessionId: ctx.sessionID, - path: filePath, - content: params.newString, - }) - } else { - 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, }) @@ -102,16 +96,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) - if (acpTools?.readTextFile) { - contentOld = ( - await acpTools.readTextFile({ - sessionId: ctx.sessionID, - path: filePath, - }) - ).content - } else { - contentOld = await file.text() - } + contentOld = delegate?.read ? await delegate.read(filePath) : await file.text() contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) @@ -132,22 +117,9 @@ export const EditTool = Tool.define("edit", { }) } - if (acpTools?.writeTextFile) { - await acpTools.writeTextFile({ - sessionId: ctx.sessionID, - path: filePath, - content: contentNew, - }) - if (acpTools.readTextFile) { - contentNew = ( - await acpTools.readTextFile({ - sessionId: ctx.sessionID, - path: filePath, - }) - ).content - } else { - contentNew = await Bun.file(filePath).text() - } + if (delegate?.write) { + await delegate.write(filePath, contentNew) + contentNew = delegate.read ? await delegate.read(filePath) : await Bun.file(filePath).text() } else { await Bun.write(filePath, contentNew) contentNew = await file.text() diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 563ae9688091..1786f9bf0ae0 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,7 +10,7 @@ import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { Identifier } from "../id/id" import { Permission } from "../permission" -import { ACPToolRegistry } from "@/acp/registry" +import { FileSystemDelegate } from "./delegate" import { Agent } from "@/agent/agent" import { iife } from "@/util/iife" @@ -31,7 +31,7 @@ export const ReadTool = Tool.define("read", { } const title = path.relative(Instance.worktree, filepath) const agent = await Agent.get(ctx.agent) - const acpTools = ACPToolRegistry.get(ctx.sessionID) + const delegate = FileSystemDelegate.get(ctx.sessionID) if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) @@ -125,18 +125,11 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 let lines: string[] - if (acpTools?.readTextFile) { - const result = await acpTools.readTextFile({ - sessionId: ctx.sessionID, - path: filepath, - line: offset, - limit, - }) - lines = result.content.split("\n") - } else { - const allLines = (await file.text()).split("\n") - lines = allLines.slice(offset, offset + limit) - } + 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 diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index d1382cd0aa26..e7fa3a159ef2 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,7 +10,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" -import { ACPToolRegistry } from "@/acp/registry" +import { FileSystemDelegate } from "./delegate" export const WriteTool = Tool.define("write", { description: DESCRIPTION, @@ -20,7 +20,7 @@ export const WriteTool = Tool.define("write", { }), async execute(params, ctx) { const agent = await Agent.get(ctx.agent) - const acpTools = ACPToolRegistry.get(ctx.sessionID) + 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)) { @@ -59,15 +59,7 @@ export const WriteTool = Tool.define("write", { }, }) - if (acpTools?.writeTextFile) { - await acpTools.writeTextFile({ - sessionId: ctx.sessionID, - path: filepath, - content: params.content, - }) - } else { - 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, From 8bdc7fa18401858539ad8ca415d7920329219726 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 18 Nov 2025 20:18:34 +0000 Subject: [PATCH 18/25] chore: format code --- packages/opencode/src/acp/types.ts | 4 +--- packages/opencode/src/tool/delegate.ts | 1 - packages/opencode/src/tool/edit.ts | 4 +--- packages/opencode/src/tool/read.ts | 7 ++----- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 2b8869ccc409..8507228edeaf 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,6 +1,4 @@ -import type { - McpServer, -} from "@agentclientprotocol/sdk" +import type { McpServer } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk" export interface ACPSessionState { diff --git a/packages/opencode/src/tool/delegate.ts b/packages/opencode/src/tool/delegate.ts index 6a510db43824..722b512437c7 100644 --- a/packages/opencode/src/tool/delegate.ts +++ b/packages/opencode/src/tool/delegate.ts @@ -1,4 +1,3 @@ - export interface FileSystemDelegate { read?(path: string, options?: { offset?: number; limit?: number }): Promise write?(path: string, content: string): Promise diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 56c8d7df5ed0..4ec2ac3db855 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -82,9 +82,7 @@ export const EditTool = Tool.define("edit", { }, }) } - delegate?.write - ? await delegate.write(filePath, params.newString) - : 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, }) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 1786f9bf0ae0..c2e7211a50a0 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -125,11 +125,8 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 let lines: string[] - lines = (delegate?.read - ? await delegate.read(filepath, { offset, limit }) - : await file.text() - ).split("\n") - if (!delegate?.read) lines = lines.slice(offset, offset + limit) + 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 From 0761b3a14393c8443fe72a81eb87acc2a567c2ab Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Tue, 18 Nov 2025 12:23:04 -0800 Subject: [PATCH 19/25] update --- packages/opencode/src/tool/edit.ts | 12 ++---------- packages/opencode/src/tool/read.ts | 8 +++++--- packages/opencode/src/tool/write.ts | 1 - 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 4ec2ac3db855..ec4ab1c471c0 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -95,7 +95,6 @@ export const EditTool = Tool.define("edit", { if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) await FileTime.assert(ctx.sessionID, filePath) contentOld = delegate?.read ? await delegate.read(filePath) : await file.text() - contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) diff = trimDiff( @@ -115,18 +114,11 @@ export const EditTool = Tool.define("edit", { }) } - if (delegate?.write) { - await delegate.write(filePath, contentNew) - contentNew = delegate.read ? await delegate.read(filePath) : await Bun.file(filePath).text() - } else { - await Bun.write(filePath, contentNew) - contentNew = await file.text() - } - + 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 c2e7211a50a0..eecab4a2d80b 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -124,10 +124,12 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - let lines: string[] - lines = (delegate?.read ? await delegate.read(filepath, { offset, limit }) : await file.text()).split("\n") + 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 }) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index e7fa3a159ef2..2da5d616e830 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -60,7 +60,6 @@ export const WriteTool = Tool.define("write", { }) delegate?.write ? await delegate.write(filepath, params.content) : await Bun.write(filepath, params.content) - await Bus.publish(File.Event.Edited, { file: filepath, }) From 054907da81c135d2057e26b19c5fef2d026192fc Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 18 Nov 2025 20:24:15 +0000 Subject: [PATCH 20/25] chore: format code --- packages/opencode/src/tool/read.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index eecab4a2d80b..dd5996279fe2 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -124,11 +124,7 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 - let lines = ( - delegate?.read - ? await delegate.read(filepath, { offset, limit }) - : await file.text() - ).split("\n") + 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 From a29e72f3a1c3df6bee612abb83c8d41580a7f07c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 21 Nov 2025 00:04:24 +0000 Subject: [PATCH 21/25] chore: format code --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4b954456d4ea..f3eef128c4d3 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 3e58881dbed3..2930cd6b9cb9 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 8db3929b53d35a8fef568e6b1d82e77dd1639912 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 23 Nov 2025 23:07:30 +0000 Subject: [PATCH 22/25] Update Nix flake.lock and hashes --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 1150e275150d..d98587838cba 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1763618868, - "narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=", + "lastModified": 1763713012, + "narHash": "sha256-Pe2xPWKaftktdhyQiT4iqnCa+pqB4TsBM/H7k39YRyE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942", + "rev": "9fe0d00db1794fe493677b1abe9ca6d08965f4d1", "type": "github" }, "original": { From ae57cb89438bbcea0f87b83ca5318abcbd1f24e7 Mon Sep 17 00:00:00 2001 From: Github Action Date: Mon, 24 Nov 2025 17:16:46 +0000 Subject: [PATCH 23/25] Update Nix flake.lock and hashes --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index d98587838cba..826bf4d86081 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1763713012, - "narHash": "sha256-Pe2xPWKaftktdhyQiT4iqnCa+pqB4TsBM/H7k39YRyE=", + "lastModified": 1763806073, + "narHash": "sha256-FHsEKDvfWpzdADWj99z7vBk4D716Ujdyveo5+A048aI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9fe0d00db1794fe493677b1abe9ca6d08965f4d1", + "rev": "878e468e02bfabeda08c79250f7ad583037f2227", "type": "github" }, "original": { From 650f80b1f99532ccffdd8985461a57f252095cf8 Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 3 Dec 2025 12:13:58 -0800 Subject: [PATCH 24/25] bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index df7442f4bb34..a5e7c14621b3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.2", + "packageManager": "bun@1.3.3", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", From 300e4f5e2f9b738f7716920fb8114dec071d005e Mon Sep 17 00:00:00 2001 From: Hao Xiang Liew Date: Wed, 10 Dec 2025 10:59:37 -0800 Subject: [PATCH 25/25] update --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",